@nodaro/prompts 1.11.0 → 1.12.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/dist/index.cjs +383 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +516 -28
- package/dist/index.d.ts +516 -28
- package/dist/index.js +368 -51
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/subject-fold.test.ts +232 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +133 -30
- package/src/assemble-video-input.ts +173 -18
- package/src/direction-registry.ts +18 -1
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +2 -0
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +544 -61
- package/src/read-node-direction.ts +60 -1
- package/src/subject-registry.ts +464 -0
package/src/prompt-builder.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRo
|
|
|
13
13
|
import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
|
|
14
14
|
import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
|
|
15
15
|
import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
|
|
16
|
+
import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
|
|
16
17
|
import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
|
|
17
18
|
import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
|
|
18
19
|
|
|
@@ -206,6 +207,56 @@ export function resolveCharacterMentions(
|
|
|
206
207
|
return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Splice one resolved mention phrase in for its token, collapsing the horizontal
|
|
212
|
+
* whitespace ON EITHER SIDE OF THE SEAM to a single space. The single splice
|
|
213
|
+
* primitive for every HYBRID image mention splice — the four `@`-mention
|
|
214
|
+
* resolvers (character, location, named image, wired entity) and the
|
|
215
|
+
* `{image:N:label}` positional-pill expansion (`expandImageRefTokensHybrid`).
|
|
216
|
+
*
|
|
217
|
+
* WHY: an editor serializes a mention chip as its token plus its own trailing
|
|
218
|
+
* space, and the prose that follows the chip carries the space the author typed
|
|
219
|
+
* — so a perfectly ordinary sentence arrives as `@panda:1 and @panda2:2 …` and
|
|
220
|
+
* assembles to "the person from reference image A and reference image C …",
|
|
221
|
+
* with the doubled space sitting exactly where the model is being told what the
|
|
222
|
+
* reference IS. The VIDEO core never shows this because `resolveReferenceTokens`
|
|
223
|
+
* runs a `[^\S\r\n]{2,}` collapse over its fully-assembled prompt on every
|
|
224
|
+
* return; the image path has no such tidy. This is that collapse, SCOPED to the
|
|
225
|
+
* seam: a doubled space the author put elsewhere in their prose is theirs to
|
|
226
|
+
* keep, and a prompt whose seams are already single-spaced is byte-identical
|
|
227
|
+
* (the run must be 2+ to match).
|
|
228
|
+
*
|
|
229
|
+
* The class is `[^\S\r\n]` (NOT `\s`) for the same reason the video tidy uses
|
|
230
|
+
* it: `\n` / `\n\n` separate the assembled blocks, and a `\s`-based collapse
|
|
231
|
+
* would silently merge paragraphs.
|
|
232
|
+
*
|
|
233
|
+
* INDENTATION is structure, not a seam. The leading collapse is anchored with a
|
|
234
|
+
* `(?<=\S)` lookbehind so it only fires on a run that FOLLOWS prose on the same
|
|
235
|
+
* line. A mention that opens an indented line (`"Scene:\n @panda:1 stands"` —
|
|
236
|
+
* a shot list, a numbered beat sheet) keeps its indent verbatim; without the
|
|
237
|
+
* anchor that run IS the left seam and a 4-space indent flattened to one space,
|
|
238
|
+
* which is the same class of damage as merging paragraphs and would have made
|
|
239
|
+
* the "already-single-spaced prompts are byte-identical" claim false for every
|
|
240
|
+
* multi-line prompt. The trailing collapse stays unconditional: a run AFTER a
|
|
241
|
+
* chip is the reported bug itself and can never be indentation.
|
|
242
|
+
*
|
|
243
|
+
* OFFSET SAFETY: callers splice right-to-left over offsets taken against the
|
|
244
|
+
* pre-splice string. The left-hand collapse only ever removes characters from
|
|
245
|
+
* the whitespace run immediately preceding THIS token — positions at or after
|
|
246
|
+
* the end of any earlier token (tokens are non-whitespace and never overlap) —
|
|
247
|
+
* so every not-yet-applied (smaller) offset stays valid.
|
|
248
|
+
*/
|
|
249
|
+
function spliceMentionPhrase(
|
|
250
|
+
prompt: string,
|
|
251
|
+
offset: number,
|
|
252
|
+
tokenLength: number,
|
|
253
|
+
phrase: string,
|
|
254
|
+
): string {
|
|
255
|
+
const before = prompt.slice(0, offset).replace(/(?<=\S)[^\S\r\n]{2,}$/, " ")
|
|
256
|
+
const after = prompt.slice(offset + tokenLength).replace(/^[^\S\r\n]{2,}/, " ")
|
|
257
|
+
return before + phrase + after
|
|
258
|
+
}
|
|
259
|
+
|
|
209
260
|
interface ResolveCharacterMentionsHybridResult {
|
|
210
261
|
/** Body with each `@`-mention replaced INLINE by its role phrase
|
|
211
262
|
* ("the {role} from reference image {LETTER}"). No directive block. */
|
|
@@ -249,6 +300,7 @@ interface ResolveCharacterMentionsHybridResult {
|
|
|
249
300
|
* parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
|
|
250
301
|
* reference instead of being dropped (the legacy resolver skips variant misses).
|
|
251
302
|
*/
|
|
303
|
+
|
|
252
304
|
function resolveCharacterMentionsHybrid(
|
|
253
305
|
prompt: string,
|
|
254
306
|
tokens: readonly CharacterMentionTokenInfo[],
|
|
@@ -327,12 +379,12 @@ function resolveCharacterMentionsHybrid(
|
|
|
327
379
|
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
328
380
|
}
|
|
329
381
|
|
|
330
|
-
// Replace mention tokens right-to-left so earlier offsets stay valid
|
|
382
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid;
|
|
383
|
+
// `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
|
|
331
384
|
let resolvedPrompt = prompt
|
|
332
385
|
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
333
386
|
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
334
|
-
resolvedPrompt =
|
|
335
|
-
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
387
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
336
388
|
}
|
|
337
389
|
|
|
338
390
|
// One identity-lock + one element directive per UNIQUE attached URL (a
|
|
@@ -399,28 +451,111 @@ function locationModeDirective(mode: LocationUsageMode): string | null {
|
|
|
399
451
|
* `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
|
|
400
452
|
* location analog of the character mention's role segment, for the 4-mode
|
|
401
453
|
* location enum:
|
|
402
|
-
* - identical → "
|
|
403
|
-
* - style → "style"
|
|
404
|
-
* - layout → "layout"
|
|
405
|
-
* - none / undefined / anything else → the source default (`"
|
|
454
|
+
* - identical → the source default (`"location"`) — lock the scene to this image
|
|
455
|
+
* - style → "style" (borrow look / mood / palette)
|
|
456
|
+
* - layout → "layout" (borrow compositional framing)
|
|
457
|
+
* - none / undefined / anything else → the source default (`"location"`).
|
|
458
|
+
*
|
|
459
|
+
* A PLACE, NOT A BACKDROP — `identical` used to map to `"background"`, which is
|
|
460
|
+
* the exact word `DEFAULT_LABEL_BY_SOURCE["wired-location"]` stopped emitting on
|
|
461
|
+
* 2026-08-05 after it was measured harmful: `roleToPhrase` renders it as "the
|
|
462
|
+
* background from reference image B", and image models read that as *paste this
|
|
463
|
+
* behind the subject* — on gpt-image-2 (character + location, 4 draws per arm,
|
|
464
|
+
* only the role word varying) every `background` draw came back a cut-out
|
|
465
|
+
* composite with no shared light and no ground contact, while `location` put the
|
|
466
|
+
* subject inside the scene. That fix changed the SOURCE default but missed this
|
|
467
|
+
* branch, so `identical` — the DEFAULT location usage mode
|
|
468
|
+
* (`DEFAULT_LOCATION_USAGE_MODE`) — kept handing every un-roled location mention
|
|
469
|
+
* the harmful word. Both defaults now read from `defaultRoleForSource`, so the
|
|
470
|
+
* source default is the single source of truth and the two cannot drift again.
|
|
471
|
+
* `"background"` stays a curated pick in `REFERENCE_ROLE_PRESETS` for the genuine
|
|
472
|
+
* backdrop case — an explicit `@lib:1:background` token is untouched.
|
|
406
473
|
*
|
|
407
474
|
* Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
|
|
408
475
|
* the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
|
|
409
476
|
* cast — unknown / non-location modes fall through to the safe source default
|
|
410
|
-
* instead of throwing. The
|
|
477
|
+
* instead of throwing. The two mode-specific roles are members of
|
|
411
478
|
* `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
|
|
412
479
|
*/
|
|
413
480
|
function locationModeToRole(mode: string | null | undefined): string {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
481
|
+
return roleBearingLocationMode(mode) ?? defaultRoleForSource("wired-location")
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The ROLE a location usage mode expresses, or `null` when the mode expresses no
|
|
486
|
+
* role opinion at all. Splits the 4-mode enum the way the character chain splits
|
|
487
|
+
* `USAGE_MODES`: `style` / `layout` SAY what the reference is, while `identical`
|
|
488
|
+
* (which IS `DEFAULT_LOCATION_USAGE_MODE`) and `none` are directive-only — they
|
|
489
|
+
* say how the LEGACY bullet reads, not what the model should take from the
|
|
490
|
+
* image. Only a role-bearing mode may outrank a ref-level `defaultRole`; see
|
|
491
|
+
* `resolveLocationRole`.
|
|
492
|
+
*
|
|
493
|
+
* Data-driven rather than a hardcoded pair: a mode is role-bearing exactly when
|
|
494
|
+
* it is itself a member of `REFERENCE_ROLE_PRESETS["wired-location"]` — the same
|
|
495
|
+
* test the location pill runs (`LOCATION_ROLE_PRESETS.includes(attrs.usageMode)`)
|
|
496
|
+
* to decide whether a mode-slot value should surface as the pill's hybrid role,
|
|
497
|
+
* and the same shape as the character resolver's `presets.includes(segment)`. A
|
|
498
|
+
* mode added to both lists later is role-bearing on its own, with no list here
|
|
499
|
+
* to remember to update.
|
|
500
|
+
*/
|
|
501
|
+
function roleBearingLocationMode(mode: string | null | undefined): string | null {
|
|
502
|
+
const m = mode?.trim()
|
|
503
|
+
if (!m) return null
|
|
504
|
+
return REFERENCE_ROLE_PRESETS["wired-location"].includes(m) ? m : null
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The effective HYBRID role for a wired LOCATION reference — the location analog
|
|
509
|
+
* of `resolveDefaultRole`, and the single source of truth for the location role
|
|
510
|
+
* chain (read by the mention resolver AND the canonical-fallback renderer, so a
|
|
511
|
+
* location cannot phrase itself one way mentioned and another way unmentioned).
|
|
512
|
+
*
|
|
513
|
+
* Precedence:
|
|
514
|
+
* 1. the per-mention token ROLE (`@lib:1:atmosphere`, `@lib:1:x/y:lighting`) —
|
|
515
|
+
* verbatim, slug-normalized so the non-noun specials still hit
|
|
516
|
+
* (`empty-background` → `empty background`).
|
|
517
|
+
* 2. the per-mention token MODE, but ONLY when it is ROLE-BEARING
|
|
518
|
+
* (`@lib:1:style`, `@lib:1:layout` — `roleBearingLocationMode`).
|
|
519
|
+
* 3. the ref's own `defaultRole` — the node/caller's hybrid role pick, same
|
|
520
|
+
* slug normalization as (1).
|
|
521
|
+
* 4. the ref's legacy `defaultUsageMode` → `locationModeToRole`.
|
|
522
|
+
* 5. the source default (`locationModeToRole`'s fallback — `"location"`).
|
|
523
|
+
*
|
|
524
|
+
* Step 2 is GATED for the same reason the character chain gates its segment on
|
|
525
|
+
* `presets.includes(segment)`: `identical` and `none` express no role opinion —
|
|
526
|
+
* `identical` IS `DEFAULT_LOCATION_USAGE_MODE`, the un-roled state, and the
|
|
527
|
+
* location pill's `renderText` emits a mode segment whenever the attr is set, so
|
|
528
|
+
* an ungated step 2 would let a round-tripped `@lib:1:identical` suppress the
|
|
529
|
+
* very `defaultRole` this chain exists to honor — on the majority of real
|
|
530
|
+
* tokens. A directive-only mode therefore falls THROUGH to the ref's role, the
|
|
531
|
+
* same way a character's `identical` / `name` / `none` segment falls through to
|
|
532
|
+
* `resolveDefaultRole`. With no `defaultRole` the outcome is unchanged
|
|
533
|
+
* (`identical` → step 4 → the source default, `"location"`).
|
|
534
|
+
*
|
|
535
|
+
* Step 3 is what this exists for. `ConnectedReference.defaultRole` is on the
|
|
536
|
+
* wire schema for EVERY source, and the character and named-image mention paths
|
|
537
|
+
* have always consulted it via `resolveDefaultRole` — but the location paths
|
|
538
|
+
* read only the usage mode, so a ref-level default role was silently dropped.
|
|
539
|
+
* It is the ONLY channel a location has for a custom default role: a location
|
|
540
|
+
* mention's 3rd segment is a bucket/variant or a role, so a caller cannot pin a
|
|
541
|
+
* per-mention role AND keep the canonical image, and the character trick of a
|
|
542
|
+
* 4th segment needs a variant to sit in the third. An explicit token role or
|
|
543
|
+
* role-bearing mode still wins (steps 1–2); with no `defaultRole` the chain
|
|
544
|
+
* collapses to exactly the old `t.usageMode ?? defaultUsageMode ?? DEFAULT`
|
|
545
|
+
* derivation.
|
|
546
|
+
*/
|
|
547
|
+
function resolveLocationRole(
|
|
548
|
+
tokenRole: string | null | undefined,
|
|
549
|
+
tokenUsageMode: string | null | undefined,
|
|
550
|
+
ref: Pick<ConnectedReference, "defaultRole" | "defaultUsageMode">,
|
|
551
|
+
): string {
|
|
552
|
+
const explicitRole = tokenRole?.trim()
|
|
553
|
+
if (explicitRole) return normalizeRoleSlug(explicitRole)
|
|
554
|
+
const modeRole = roleBearingLocationMode(tokenUsageMode)
|
|
555
|
+
if (modeRole) return modeRole
|
|
556
|
+
const nodeRole = ref.defaultRole?.trim()
|
|
557
|
+
if (nodeRole) return normalizeRoleSlug(nodeRole)
|
|
558
|
+
return locationModeToRole(ref.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE)
|
|
424
559
|
}
|
|
425
560
|
|
|
426
561
|
/**
|
|
@@ -595,9 +730,12 @@ interface ResolveLocationMentionsHybridResult {
|
|
|
595
730
|
* fallback on a variant miss) so the set of attached URLs / matched tokens never
|
|
596
731
|
* diverges between formats — only the rendered phrasing differs.
|
|
597
732
|
*
|
|
598
|
-
* ROLE is `
|
|
599
|
-
*
|
|
600
|
-
*
|
|
733
|
+
* ROLE is `resolveLocationRole(t.role, t.usageMode, ref)` — per-mention role →
|
|
734
|
+
* per-mention ROLE-BEARING mode (`style` / `layout`; the directive-only
|
|
735
|
+
* `identical` / `none` fall through) → the ref's own `defaultRole` → its
|
|
736
|
+
* `defaultUsageMode` → the source default. So a node whose default mode is
|
|
737
|
+
* "style" renders "the style from …" for a bare `@old-library:1`, and one
|
|
738
|
+
* carrying `defaultRole: "atmosphere"` renders "the atmosphere from …".
|
|
601
739
|
*
|
|
602
740
|
* SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
|
|
603
741
|
* ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
|
|
@@ -645,15 +783,12 @@ function resolveLocationMentionsHybrid(
|
|
|
645
783
|
mentionedLocationSlugs.add(t.locationSlug)
|
|
646
784
|
refByUrl.set(match.url, match)
|
|
647
785
|
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
648
|
-
const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
|
|
649
786
|
// A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
|
|
650
787
|
// `empty-background`, `as-is`, or a curated custom role) is used VERBATIM:
|
|
651
|
-
// it's acting as a role, not selecting a bucket/variant.
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
// byte-identical to the prior behavior for every non-role mention.
|
|
656
|
-
const role = t.role ? normalizeRoleSlug(t.role) : locationModeToRole(mode)
|
|
788
|
+
// it's acting as a role, not selecting a bucket/variant. With no role
|
|
789
|
+
// segment, the chain falls through the token's ROLE-BEARING mode, then the
|
|
790
|
+
// REF's own `defaultRole`, then its usage mode — see `resolveLocationRole`.
|
|
791
|
+
const role = resolveLocationRole(t.role, t.usageMode, match)
|
|
657
792
|
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
658
793
|
}
|
|
659
794
|
|
|
@@ -668,12 +803,12 @@ function resolveLocationMentionsHybrid(
|
|
|
668
803
|
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
669
804
|
}
|
|
670
805
|
|
|
671
|
-
// Replace mention tokens right-to-left so earlier offsets stay valid
|
|
806
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid;
|
|
807
|
+
// `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
|
|
672
808
|
let resolvedPrompt = prompt
|
|
673
809
|
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
674
810
|
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
675
|
-
resolvedPrompt =
|
|
676
|
-
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
811
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
677
812
|
}
|
|
678
813
|
|
|
679
814
|
// One opt-in lock + one element directive per UNIQUE attached URL.
|
|
@@ -793,12 +928,12 @@ function resolveImageMentionsHybrid(
|
|
|
793
928
|
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
794
929
|
}
|
|
795
930
|
|
|
796
|
-
// Replace mention tokens right-to-left so earlier offsets stay valid
|
|
931
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid;
|
|
932
|
+
// `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
|
|
797
933
|
let resolvedPrompt = prompt
|
|
798
934
|
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
799
935
|
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
800
|
-
resolvedPrompt =
|
|
801
|
-
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
936
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
802
937
|
}
|
|
803
938
|
|
|
804
939
|
// One lock + one element directive per UNIQUE attached URL.
|
|
@@ -820,6 +955,188 @@ function resolveImageMentionsHybrid(
|
|
|
820
955
|
return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
|
|
821
956
|
}
|
|
822
957
|
|
|
958
|
+
interface ResolveEntityMentionsHybridResult {
|
|
959
|
+
/** Body with each `@<entity-name>` mention replaced INLINE by its role phrase
|
|
960
|
+
* ("the creature from reference image D", "the material from reference image
|
|
961
|
+
* B", …). No directive block. */
|
|
962
|
+
prompt: string
|
|
963
|
+
/** Matched entity URLs in mention order (deduped by the caller). */
|
|
964
|
+
additionalUrls: string[]
|
|
965
|
+
/**
|
|
966
|
+
* The URLs a mention BOUND — fed into `renderObjectCreatureCanonicalHybrid`'s
|
|
967
|
+
* `coveredUrls` so the ref's trailing canonical phrase is SUPPRESSED. This is
|
|
968
|
+
* the whole point of the pass: without it the same reference would render
|
|
969
|
+
* twice, once inline and once as the dangling trailing line the mention exists
|
|
970
|
+
* to replace. Distinct from `additionalUrls` only in being a set — kept as its
|
|
971
|
+
* own field so the suppression contract is explicit at both ends.
|
|
972
|
+
*/
|
|
973
|
+
mentionedUrls: Set<string>
|
|
974
|
+
/** Per-reference identity-lock lines (deduped per URL). Caller prepends them
|
|
975
|
+
* as ONE block, merged with the character/location/image lock lines. */
|
|
976
|
+
lockLines: string[]
|
|
977
|
+
/** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
|
|
978
|
+
* them as trailing scene directives. */
|
|
979
|
+
elementDirectives: string[]
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* HYBRID wired-entity mention convergence — the `wired-creature` /
|
|
984
|
+
* `wired-object` analog of `resolveImageMentionsHybrid`, for entities addressed
|
|
985
|
+
* by the slug of their `defaultName`.
|
|
986
|
+
*
|
|
987
|
+
* THE BUG THIS KILLS. An unmentioned creature renders through
|
|
988
|
+
* `renderObjectCreatureCanonicalHybrid` as a TRAILING phrase — "the creature
|
|
989
|
+
* from reference image D" — appended after the scene and the style hints, while
|
|
990
|
+
* the creature's name sits in the prose as plain text the model has no reason to
|
|
991
|
+
* connect to a reference. A mention binds the two: the phrase renders INLINE at
|
|
992
|
+
* the typed position, and this pass's `mentionedUrls` suppresses the trailing
|
|
993
|
+
* fallback for that ref.
|
|
994
|
+
*
|
|
995
|
+
* ASYMMETRY vs. images, and why suppression is needed here but not there: a
|
|
996
|
+
* media ref carries NO canonical prose, so the image pass has nothing to
|
|
997
|
+
* suppress. A creature/object DOES, so this pass must hand its bound URLs to the
|
|
998
|
+
* canonical renderer's `coveredUrls` — exactly the mechanism that already stops
|
|
999
|
+
* an `{image:N}`-token-referenced entity from double-rendering.
|
|
1000
|
+
*
|
|
1001
|
+
* NOT a `connectedReferences` FILTER, matching the image pass and unlike the
|
|
1002
|
+
* location pass: the ref stays in the list so `nonCharacterRefs[N-1]` positional
|
|
1003
|
+
* indexing for `{image:N}` tokens is unperturbed and the New-path URL merge keeps
|
|
1004
|
+
* the ref's earlier Phase-0 slot (the merge dedups by URL). Suppression is
|
|
1005
|
+
* coveredUrls-only.
|
|
1006
|
+
*
|
|
1007
|
+
* PRECEDENCE. The caller runs this AFTER the character, location and image
|
|
1008
|
+
* passes, each of which has already spliced its own tokens out — so a name shared
|
|
1009
|
+
* with an earlier kind never reaches this pass (character → location → image →
|
|
1010
|
+
* creature → object). The creature-before-object tail is settled HERE, in the
|
|
1011
|
+
* slug → ref map: creature refs are inserted first, so a name claimed by both a
|
|
1012
|
+
* creature and an object binds the CREATURE.
|
|
1013
|
+
*
|
|
1014
|
+
* DUPLICATE SLUGS within a kind: FIRST WINS, matching the image pass.
|
|
1015
|
+
*
|
|
1016
|
+
* ROLE precedence: the per-mention 3rd segment (VERBATIM — both entity preset
|
|
1017
|
+
* lists are single-word, so `normalizeRoleSlug` stays location-only and must NOT
|
|
1018
|
+
* be called here) → the ref's own `defaultRole`, then its `defaultUsageMode`, via
|
|
1019
|
+
* the SHARED `resolveDefaultRole` helper → the SOURCE default (`"creature"` /
|
|
1020
|
+
* `"object"`).
|
|
1021
|
+
*
|
|
1022
|
+
* ONE HELPER, NOT A COPY. `resolveDefaultRole` is the same chain the character
|
|
1023
|
+
* and named-image resolvers run, and `ConnectedReference.defaultRole` is a wire
|
|
1024
|
+
* field on EVERY source (see its doc in `types.ts`), so an entity ref's role pick
|
|
1025
|
+
* is honored here for free. The LOCATION paths need their own `resolveLocationRole`
|
|
1026
|
+
* only because a location token carries a MODE segment whose role-bearing members
|
|
1027
|
+
* (`style` / `layout`) sit between the token role and the ref's `defaultRole`, and
|
|
1028
|
+
* because location roles are slug-normalized; an entity token has no mode segment
|
|
1029
|
+
* (`EntityMentionTokenInfo` is role + lock only) and no multi-word roles, so there
|
|
1030
|
+
* is no gate to replicate and an entity analog of that helper would be two copies
|
|
1031
|
+
* of one chain.
|
|
1032
|
+
*
|
|
1033
|
+
* That LAST step is the one `renderObjectCreatureCanonicalHybrid` would also have
|
|
1034
|
+
* used for the trailing line — but only that one: the canonical renderer reads
|
|
1035
|
+
* `defaultRoleForSource(r.source)` and ignores `defaultRole` / `defaultUsageMode`
|
|
1036
|
+
* entirely. So a bare `@nessie:4` relocates the IDENTICAL phrase only when the ref
|
|
1037
|
+
* carries neither node field; with a node default the mention honors it and the
|
|
1038
|
+
* phrase CHANGES ("the anatomy from …" rather than "the creature from …"). That is
|
|
1039
|
+
* deliberate — it is what `resolveCharacterMentionsHybrid` and
|
|
1040
|
+
* `resolveImageMentionsHybrid` do, and it is pinned by
|
|
1041
|
+
* `entity-convergence-image.test.ts`. Suppression is
|
|
1042
|
+
* unaffected either way: the mention pass covers the same URL and emits the same
|
|
1043
|
+
* lock line (modulo a `~lock`/`~nolock` sentinel) and the same `elementInjection`.
|
|
1044
|
+
*
|
|
1045
|
+
* NO LEGACY COUNTERPART — the image-grammar precedent. The Phase-0 arm that
|
|
1046
|
+
* reaches this is hybrid-gated, so under the legacy reference format an
|
|
1047
|
+
* `@name:N` token stays literal text and the entity attaches with its numbered
|
|
1048
|
+
* directive exactly as it does today.
|
|
1049
|
+
*
|
|
1050
|
+
* CAPPED REFS: `imageReferenceLimit(provider)` truncates `connectedReferences`
|
|
1051
|
+
* BEFORE Phase 0, so a mention whose ref was capped out silently falls through as
|
|
1052
|
+
* literal text — matching how a capped character or image mention behaves today.
|
|
1053
|
+
*/
|
|
1054
|
+
function resolveEntityMentionsHybrid(
|
|
1055
|
+
prompt: string,
|
|
1056
|
+
tokens: readonly EntityMentionTokenInfo[],
|
|
1057
|
+
refs: readonly ConnectedReference[],
|
|
1058
|
+
existingUrls: readonly string[],
|
|
1059
|
+
): ResolveEntityMentionsHybridResult {
|
|
1060
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
1061
|
+
// Creature-first insertion IS the creature → object half of the precedence
|
|
1062
|
+
// chain (the other four steps are the caller's pass order). `entityMentionSlug
|
|
1063
|
+
// ForRef` is the SAME predicate `knownEntitySlugsFromRefs` applies to build the
|
|
1064
|
+
// finder's known-slug set — one gate, so this map and that set can never admit
|
|
1065
|
+
// different refs.
|
|
1066
|
+
for (const source of ["wired-creature", "wired-object"] as const) {
|
|
1067
|
+
for (const r of refs) {
|
|
1068
|
+
if (r.source !== source) continue
|
|
1069
|
+
const slug = entityMentionSlugForRef(r)
|
|
1070
|
+
if (!slug) continue
|
|
1071
|
+
if (!bySlug.has(slug)) bySlug.set(slug, r)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const additionalUrls: string[] = []
|
|
1076
|
+
const mentionedUrls = new Set<string>()
|
|
1077
|
+
const refByUrl = new Map<string, ConnectedReference>()
|
|
1078
|
+
// Per-mention `~lock` / `~nolock`: the tri-state lock OVERRIDE per attached
|
|
1079
|
+
// URL, fed to `withForcedIdentityLock` below. Only sentinel-bearing mentions
|
|
1080
|
+
// write here (last sentinel wins), mirroring the image/location resolvers.
|
|
1081
|
+
const lockOverrideByUrl = new Map<string, boolean>()
|
|
1082
|
+
const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
|
|
1083
|
+
|
|
1084
|
+
for (const t of tokens) {
|
|
1085
|
+
const match = bySlug.get(t.entitySlug)
|
|
1086
|
+
if (!match || !match.url) continue
|
|
1087
|
+
additionalUrls.push(match.url)
|
|
1088
|
+
mentionedUrls.add(match.url)
|
|
1089
|
+
refByUrl.set(match.url, match)
|
|
1090
|
+
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
1091
|
+
const role = (t.role ?? "").trim()
|
|
1092
|
+
|| resolveDefaultRole(match.defaultRole, match.defaultUsageMode, match.source)
|
|
1093
|
+
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Slot letters from the deduped [existing, mention] URL list — the prefix of
|
|
1097
|
+
// the caller's `finalIndexByUrl`, so the letters agree.
|
|
1098
|
+
const slotByUrl = new Map<string, number>()
|
|
1099
|
+
for (const u of [...existingUrls, ...additionalUrls]) {
|
|
1100
|
+
if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
|
|
1101
|
+
}
|
|
1102
|
+
const bindingFor = (url: string): string => {
|
|
1103
|
+
const slot = slotByUrl.get(url)
|
|
1104
|
+
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid;
|
|
1108
|
+
// `spliceMentionPhrase` also tidies the horizontal whitespace at each seam —
|
|
1109
|
+
// an entity chip serializes with its own trailing space exactly like every
|
|
1110
|
+
// other mention chip, so its seams get the same collapse as the character,
|
|
1111
|
+
// location and named-image ones.
|
|
1112
|
+
let resolvedPrompt = prompt
|
|
1113
|
+
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
1114
|
+
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
1115
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// One lock + one element directive per UNIQUE attached URL. These are the
|
|
1119
|
+
// SAME lines `renderObjectCreatureCanonicalHybrid` would have emitted for the
|
|
1120
|
+
// ref, moved here — which is why suppressing its canonical entry does not lose
|
|
1121
|
+
// an opt-in lock or a wired `elementInjection`.
|
|
1122
|
+
const lockLines: string[] = []
|
|
1123
|
+
const elementDirectives: string[] = []
|
|
1124
|
+
const seenUrls = new Set<string>()
|
|
1125
|
+
for (const m of matched) {
|
|
1126
|
+
if (seenUrls.has(m.url)) continue
|
|
1127
|
+
seenUrls.add(m.url)
|
|
1128
|
+
const ref = refByUrl.get(m.url)
|
|
1129
|
+
if (!ref) continue
|
|
1130
|
+
const binding = bindingFor(m.url)
|
|
1131
|
+
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
1132
|
+
if (lock) lockLines.push(lock)
|
|
1133
|
+
const inject = ref.elementInjection?.trim()
|
|
1134
|
+
if (inject) elementDirectives.push(inject)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedUrls, lockLines, elementDirectives }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
823
1140
|
/**
|
|
824
1141
|
* Build the canonical-fallback directive lines + URLs for wired characters
|
|
825
1142
|
* that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
|
|
@@ -1180,8 +1497,9 @@ function renderExtraRefsHybrid(
|
|
|
1180
1497
|
* Render the hybrid canonical convergence for UNMENTIONED wired locations (the
|
|
1181
1498
|
* location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
|
|
1182
1499
|
* `wired-location` ref → the inline role phrase
|
|
1183
|
-
* `roleToPhrase(
|
|
1184
|
-
*
|
|
1500
|
+
* `roleToPhrase(resolveLocationRole(null, null, ref), binding)` — the SAME role
|
|
1501
|
+
* chain the mention path runs, minus the token steps (the caller appends these as
|
|
1502
|
+
* trailing scene directives) + its opt-in identity-lock line
|
|
1185
1503
|
* (null unless the ref enables one — locations have no built-in lock wording) +
|
|
1186
1504
|
* its wired `elementInjection` as a trailing directive.
|
|
1187
1505
|
*
|
|
@@ -1215,7 +1533,10 @@ function renderLocationCanonicalHybrid(
|
|
|
1215
1533
|
if (!slot) continue
|
|
1216
1534
|
seenUrls.add(r.url)
|
|
1217
1535
|
const binding = `reference image ${slotToLetter(slot)}`
|
|
1218
|
-
|
|
1536
|
+
// Same role chain as the mention path (there is no token here, so it starts
|
|
1537
|
+
// at the ref's own `defaultRole`) — one helper, so a location cannot phrase
|
|
1538
|
+
// itself one way mentioned and another way unmentioned.
|
|
1539
|
+
phrases.push(roleToPhrase(resolveLocationRole(null, null, r), binding))
|
|
1219
1540
|
const lock = buildIdentityLockLine(r, binding)
|
|
1220
1541
|
if (lock) lockLines.push(lock)
|
|
1221
1542
|
const inject = r.elementInjection?.trim()
|
|
@@ -1236,11 +1557,19 @@ function renderLocationCanonicalHybrid(
|
|
|
1236
1557
|
* has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
|
|
1237
1558
|
* default-off flip) + its wired `elementInjection` as a trailing directive.
|
|
1238
1559
|
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
* token
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1560
|
+
* `coveredUrls` is every URL that ALREADY rendered inline in this scene, and it
|
|
1561
|
+
* carries TWO contributions the caller unions together: the URLs expanded by an
|
|
1562
|
+
* `{image:N}` token, and — since the creature/object mention leg — the URLs bound
|
|
1563
|
+
* by a Phase-0 `@creature` / `@object` mention. Either way the ref renders ONCE,
|
|
1564
|
+
* inline, never also as a trailing canonical phrase.
|
|
1565
|
+
*
|
|
1566
|
+
* The mention half is why this loop is NOT reached via a `connectedReferences`
|
|
1567
|
+
* filter the way mentioned locations are: an entity ref stays in the list to keep
|
|
1568
|
+
* `nonCharacterRefs[N-1]` positional `{image:N}` indexing stable, so suppression
|
|
1569
|
+
* has to happen HERE, per URL. The mention pass emits the same lock line and
|
|
1570
|
+
* `elementInjection` this loop would have, so nothing is lost by skipping it.
|
|
1571
|
+
*
|
|
1572
|
+
* Deduped per URL (an object wired twice → one phrase).
|
|
1244
1573
|
*/
|
|
1245
1574
|
function renderObjectCreatureCanonicalHybrid(
|
|
1246
1575
|
nonCharacterRefs: readonly ConnectedReference[],
|
|
@@ -1671,12 +2000,18 @@ export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
|
|
|
1671
2000
|
* - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
|
|
1672
2001
|
* style/avoid suffixes were appended (covers the common no-truncation case).
|
|
1673
2002
|
* - `styleSuffix` / `avoidSuffix`: the `\nStyle: …` / `\nAvoid: …` strings.
|
|
2003
|
+
* - `overflowChars`: how many characters the provider cap forced OFF the tail
|
|
2004
|
+
* (0 when the assembled prompt fit). Accumulated across every clamp site in
|
|
2005
|
+
* both assembly paths. Read by {@link buildImagePromptWithOverflow} so a
|
|
2006
|
+
* cap-aware caller can shed its own lowest-value text and re-assemble
|
|
2007
|
+
* instead of letting the order-blind tail cut decide.
|
|
1674
2008
|
*/
|
|
1675
2009
|
interface AssemblyMarks {
|
|
1676
2010
|
directivesPrefix: string
|
|
1677
2011
|
bodyBeforeSuffixes: string
|
|
1678
2012
|
styleSuffix: string
|
|
1679
2013
|
avoidSuffix: string
|
|
2014
|
+
overflowChars: number
|
|
1680
2015
|
}
|
|
1681
2016
|
|
|
1682
2017
|
/** Keep `bodySegments` when the assembled body still equals their join;
|
|
@@ -1703,6 +2038,47 @@ export function buildImagePrompt(config: BuildImagePromptConfig): BuildImageProm
|
|
|
1703
2038
|
return buildImagePromptInternal(config)
|
|
1704
2039
|
}
|
|
1705
2040
|
|
|
2041
|
+
/** {@link buildImagePrompt} plus `overflowChars`. */
|
|
2042
|
+
export interface BuildImagePromptOverflowResult extends BuildImagePromptResult {
|
|
2043
|
+
/**
|
|
2044
|
+
* Characters the provider's prompt cap forced off the tail — `0` when the
|
|
2045
|
+
* assembled prompt fit. This is the amount of text that must LEAVE the body
|
|
2046
|
+
* for the prompt to fit without a tail cut, so a caller can shed exactly
|
|
2047
|
+
* enough of its own droppable text and re-assemble.
|
|
2048
|
+
*/
|
|
2049
|
+
overflowChars: number
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* `buildImagePrompt` plus the size of the cap overflow it had to clamp away.
|
|
2054
|
+
*
|
|
2055
|
+
* The prompt (and every other field) is BYTE-IDENTICAL to `buildImagePrompt` —
|
|
2056
|
+
* the marks channel only observes. Exists because the tail clamp is ORDER-BLIND:
|
|
2057
|
+
* it cuts whatever happens to be last, which on a low-cap provider can sever a
|
|
2058
|
+
* reference directive or the user's own prose while a decorative hint clause
|
|
2059
|
+
* survives. `assembleImageInput` uses this to drop its lowest-value text
|
|
2060
|
+
* (direction-folded hint clauses) FIRST and re-assemble; the clamp then stays
|
|
2061
|
+
* the last-resort fallback for a body that overflows on prose alone.
|
|
2062
|
+
*/
|
|
2063
|
+
export function buildImagePromptWithOverflow(
|
|
2064
|
+
config: BuildImagePromptConfig,
|
|
2065
|
+
): BuildImagePromptOverflowResult {
|
|
2066
|
+
const marks = newAssemblyMarks()
|
|
2067
|
+
const result = buildImagePromptInternal(config, marks)
|
|
2068
|
+
return { ...result, overflowChars: marks.overflowChars }
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** Fresh zeroed marks — one construction site so a new field can't be forgotten. */
|
|
2072
|
+
function newAssemblyMarks(): AssemblyMarks {
|
|
2073
|
+
return {
|
|
2074
|
+
directivesPrefix: "",
|
|
2075
|
+
bodyBeforeSuffixes: "",
|
|
2076
|
+
styleSuffix: "",
|
|
2077
|
+
avoidSuffix: "",
|
|
2078
|
+
overflowChars: 0,
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1706
2082
|
/**
|
|
1707
2083
|
* Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
|
|
1708
2084
|
* When `marks` is provided, records the directive-prefix / body / suffix spans
|
|
@@ -1738,6 +2114,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1738
2114
|
// line-initials (which would corrupt "the face from reference image A" → "The
|
|
1739
2115
|
// face …" / "the style from reference image A" → "The style …").
|
|
1740
2116
|
let hybridBodyConverged = false
|
|
2117
|
+
// URLs bound by a Phase-0 wired-entity (`@creature` / `@object`) mention.
|
|
2118
|
+
// Declared at function scope because it must BRIDGE the two blocks below:
|
|
2119
|
+
// Phase 0 populates it, and the New path unions it into
|
|
2120
|
+
// `renderObjectCreatureCanonicalHybrid`'s `coveredUrls` so a mentioned
|
|
2121
|
+
// creature/object does NOT also emit its trailing canonical phrase. Empty for
|
|
2122
|
+
// every prompt without an entity mention, which is what keeps those outputs
|
|
2123
|
+
// byte-identical.
|
|
2124
|
+
const mentionedEntityUrls = new Set<string>()
|
|
1741
2125
|
|
|
1742
2126
|
// Character LoRA inference path: trigger word + LoRA model carry identity,
|
|
1743
2127
|
// so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
|
|
@@ -1861,7 +2245,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1861
2245
|
const hasImageMentionTokens = isHybrid
|
|
1862
2246
|
&& knownImageSlugs.length > 0
|
|
1863
2247
|
&& findImageMentionTokens(config.prompt, knownImageSlugs).length > 0
|
|
1864
|
-
|
|
2248
|
+
// Wired-entity mentions (`wired-creature` / `wired-object`). Slugs derive
|
|
2249
|
+
// from each entity ref's `defaultName` — no `entitySlug` wire field, matching
|
|
2250
|
+
// the image grammar — and the derivation is shared with the backend
|
|
2251
|
+
// orchestrator's structured-branch gate via `knownEntitySlugsFromRefs`.
|
|
2252
|
+
const knownEntitySlugs = knownEntitySlugsFromRefs(connectedReferences)
|
|
2253
|
+
// EXISTENCE-ONLY precheck, gating the Phase-0 arm below — the image
|
|
2254
|
+
// precedent. An unmentioned creature/object already renders correctly
|
|
2255
|
+
// through `renderObjectCreatureCanonicalHybrid`, so a graph with no entity
|
|
2256
|
+
// mention has no reason to enter Phase 0: gating on TOKEN presence keeps
|
|
2257
|
+
// today's control flow and byte output for every such graph BY
|
|
2258
|
+
// CONSTRUCTION. HYBRID-only — under the legacy format an `@name:N` token
|
|
2259
|
+
// stays literal text.
|
|
2260
|
+
const hasEntityMentionTokens = isHybrid
|
|
2261
|
+
&& knownEntitySlugs.length > 0
|
|
2262
|
+
&& findEntityMentionTokens(config.prompt, knownEntitySlugs).length > 0
|
|
2263
|
+
if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0 || hasImageMentionTokens || hasEntityMentionTokens) {
|
|
1865
2264
|
const mentionTokens = knownCharacterSlugs.length > 0
|
|
1866
2265
|
? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
|
|
1867
2266
|
: []
|
|
@@ -1991,6 +2390,47 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1991
2390
|
// is gated to wired-location/object/creature); and leaving the ref in
|
|
1992
2391
|
// place keeps `nonCharacterRefs[N-1]` positional indexing stable for
|
|
1993
2392
|
// `{image:N}` tokens.
|
|
2393
|
+
//
|
|
2394
|
+
// Pass 4: wired-entity (`@creature` / `@object`) mentions, resolved on the
|
|
2395
|
+
// POST-character, POST-location, POST-image prompt. The finder MUST re-run
|
|
2396
|
+
// here — every earlier pass spliced its own tokens out, so an offset
|
|
2397
|
+
// computed before them is stale. PRECEDENCE character → location → image →
|
|
2398
|
+
// creature → object falls out of this pass order plus the creature-first
|
|
2399
|
+
// map inside the resolver: a name shared with an earlier kind resolves as
|
|
2400
|
+
// that kind, and the entity token never fires.
|
|
2401
|
+
//
|
|
2402
|
+
// `existingUrls` is already location- AND image-inclusive because
|
|
2403
|
+
// `additionalUrls` absorbed both above, so the mention slot letters stay a
|
|
2404
|
+
// prefix of the final `finalIndexByUrl`.
|
|
2405
|
+
let hybridEntityLockLines: string[] = []
|
|
2406
|
+
let hybridEntityElementDirectives: string[] = []
|
|
2407
|
+
if (hasEntityMentionTokens) {
|
|
2408
|
+
const entityTokens = findEntityMentionTokens(resolved.prompt, knownEntitySlugs)
|
|
2409
|
+
if (entityTokens.length > 0) {
|
|
2410
|
+
const he = resolveEntityMentionsHybrid(
|
|
2411
|
+
resolved.prompt,
|
|
2412
|
+
entityTokens,
|
|
2413
|
+
connectedReferences,
|
|
2414
|
+
[...(referenceImageUrls || []), ...resolved.additionalUrls],
|
|
2415
|
+
)
|
|
2416
|
+
resolved.prompt = he.prompt
|
|
2417
|
+
resolved.additionalUrls = [...resolved.additionalUrls, ...he.additionalUrls]
|
|
2418
|
+
hybridEntityLockLines = he.lockLines
|
|
2419
|
+
hybridEntityElementDirectives = he.elementDirectives
|
|
2420
|
+
// The suppression handoff to the New path: every bound URL is skipped
|
|
2421
|
+
// by `renderObjectCreatureCanonicalHybrid`, so the phrase renders ONCE
|
|
2422
|
+
// — inline, where the user typed it — instead of also dangling as a
|
|
2423
|
+
// trailing "the creature from reference image D" line. Like the image
|
|
2424
|
+
// pass, the ref itself is NOT filtered out of `connectedReferences`
|
|
2425
|
+
// (positional `{image:N}` stability); coveredUrls is the whole
|
|
2426
|
+
// mechanism.
|
|
2427
|
+
for (const u of he.mentionedUrls) mentionedEntityUrls.add(u)
|
|
2428
|
+
// Inline role phrases now live in the body → skip line-initial
|
|
2429
|
+
// capitalization, which would otherwise corrupt a mid-sentence
|
|
2430
|
+
// "the creature from reference image D".
|
|
2431
|
+
if (he.additionalUrls.length > 0) hybridBodyConverged = true
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
1994
2434
|
// Default-fallback canonical URLs + directives for any wired character
|
|
1995
2435
|
// that has zero mentions in the prompt. Mirrors the legacy behavior the
|
|
1996
2436
|
// mention feature replaced — wire a character with no typing required.
|
|
@@ -2130,6 +2570,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2130
2570
|
...hybridLockLines,
|
|
2131
2571
|
...hybridLocationLockLines,
|
|
2132
2572
|
...hybridImageLockLines,
|
|
2573
|
+
...hybridEntityLockLines,
|
|
2133
2574
|
...canonical.lockLines,
|
|
2134
2575
|
...extrasRendered.lockLines,
|
|
2135
2576
|
])]
|
|
@@ -2137,6 +2578,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2137
2578
|
...hybridElementDirectives,
|
|
2138
2579
|
...hybridLocationElementDirectives,
|
|
2139
2580
|
...hybridImageElementDirectives,
|
|
2581
|
+
...hybridEntityElementDirectives,
|
|
2140
2582
|
...canonical.phrases,
|
|
2141
2583
|
...canonical.elementDirectives,
|
|
2142
2584
|
...extrasRendered.bodyLines,
|
|
@@ -2267,10 +2709,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2267
2709
|
// (Phase C): each → the trailing role phrase "the {role} from reference
|
|
2268
2710
|
// image {LETTER}" + opt-in lock + wired elementInjection, numbered against
|
|
2269
2711
|
// `finalIndexByUrl`. Mentioned locations were converged inline in Phase 0
|
|
2270
|
-
// and filtered out of `nonCharacterRefs`; objects/creatures
|
|
2271
|
-
//
|
|
2712
|
+
// and filtered out of `nonCharacterRefs`; mentioned objects/creatures were
|
|
2713
|
+
// converged inline too but stay in the list, so they are suppressed by URL
|
|
2714
|
+
// via the covered set below.
|
|
2272
2715
|
const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
|
|
2273
|
-
|
|
2716
|
+
// Objects/creatures now have a mention path too, so their covered set is
|
|
2717
|
+
// the union of the `{image:N}`-token URLs and the URLs a Phase-0
|
|
2718
|
+
// `@creature` / `@object` mention bound. That union is the SUPPRESSION —
|
|
2719
|
+
// a mentioned entity already rendered its role phrase inline, and without
|
|
2720
|
+
// this it would ALSO emit the trailing "the creature from reference image
|
|
2721
|
+
// D" line, which is the exact double-render this leg removes. Locations
|
|
2722
|
+
// keep `tokenCoveredUrls` alone: their mentioned refs are FILTERED out of
|
|
2723
|
+
// `nonCharacterRefs` upstream, so they never reach the loop at all.
|
|
2724
|
+
const objCoveredUrls = mentionedEntityUrls.size > 0
|
|
2725
|
+
? new Set([...tokenCoveredUrls, ...mentionedEntityUrls])
|
|
2726
|
+
: tokenCoveredUrls
|
|
2727
|
+
const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, objCoveredUrls)
|
|
2274
2728
|
const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
|
|
2275
2729
|
const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
|
|
2276
2730
|
const canonTrailingLines = [
|
|
@@ -2347,9 +2801,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2347
2801
|
// the `Avoid:` negative whenever the body was long. The style/avoid suffixes
|
|
2348
2802
|
// are RESERVED first so a long body can never drop them (the control text is
|
|
2349
2803
|
// the most important to keep). Truncate the BODY, THEN append the suffixes.
|
|
2804
|
+
// The cut is ORDER-BLIND — record how much it removed so a cap-aware caller
|
|
2805
|
+
// (`assembleImageInput`) can shed its own lowest-value text and re-assemble.
|
|
2350
2806
|
const maxLen = getMaxImagePromptChars(provider)
|
|
2351
2807
|
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2352
2808
|
if (prompt.length + reserved > maxLen) {
|
|
2809
|
+
if (marks) marks.overflowChars += prompt.length + reserved - maxLen
|
|
2353
2810
|
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2354
2811
|
}
|
|
2355
2812
|
// Body span = everything after the captured directive prefix, taken from the
|
|
@@ -2360,6 +2817,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2360
2817
|
prompt = prompt + styleSuffix + avoidSuffix
|
|
2361
2818
|
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2362
2819
|
if (prompt.length > maxLen) {
|
|
2820
|
+
if (marks) marks.overflowChars += prompt.length - maxLen
|
|
2363
2821
|
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2364
2822
|
}
|
|
2365
2823
|
|
|
@@ -2455,10 +2913,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2455
2913
|
// Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
|
|
2456
2914
|
// style/avoid suffixes so a long body never severs the appended control text
|
|
2457
2915
|
// (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
|
|
2458
|
-
// BODY, THEN append the suffixes.
|
|
2916
|
+
// BODY, THEN append the suffixes. Same order-blind cut as the
|
|
2917
|
+
// connectedReferences path above → same `overflowChars` bookkeeping.
|
|
2459
2918
|
const maxLen = getMaxImagePromptChars(provider)
|
|
2460
2919
|
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2461
2920
|
if (prompt.length + reserved > maxLen) {
|
|
2921
|
+
if (marks) marks.overflowChars += prompt.length + reserved - maxLen
|
|
2462
2922
|
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2463
2923
|
}
|
|
2464
2924
|
// Legacy path has no directive prefix; the body is the char-desc-wrapped
|
|
@@ -2467,6 +2927,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2467
2927
|
prompt = prompt + styleSuffix + avoidSuffix
|
|
2468
2928
|
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2469
2929
|
if (prompt.length > maxLen) {
|
|
2930
|
+
if (marks) marks.overflowChars += prompt.length - maxLen
|
|
2470
2931
|
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2471
2932
|
}
|
|
2472
2933
|
|
|
@@ -2499,12 +2960,7 @@ export function buildImagePromptSegments(
|
|
|
2499
2960
|
config: BuildImagePromptConfig,
|
|
2500
2961
|
bodySegments?: readonly PromptSegment[],
|
|
2501
2962
|
): BuildImagePromptSegmentsResult {
|
|
2502
|
-
const marks
|
|
2503
|
-
directivesPrefix: "",
|
|
2504
|
-
bodyBeforeSuffixes: "",
|
|
2505
|
-
styleSuffix: "",
|
|
2506
|
-
avoidSuffix: "",
|
|
2507
|
-
}
|
|
2963
|
+
const marks = newAssemblyMarks()
|
|
2508
2964
|
|
|
2509
2965
|
const result = buildImagePromptInternal(config, marks)
|
|
2510
2966
|
|
|
@@ -2887,21 +3343,48 @@ function hybridRolePhrase(label: string, letter: string): string {
|
|
|
2887
3343
|
return `the ${label} from reference image ${letter}`
|
|
2888
3344
|
}
|
|
2889
3345
|
|
|
2890
|
-
/**
|
|
2891
|
-
*
|
|
2892
|
-
*
|
|
3346
|
+
/**
|
|
3347
|
+
* Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
|
|
3348
|
+
* ("the subject from reference image A") instead of the legacy "Image N
|
|
3349
|
+
* (label)". Out-of-range tokens / URL-less refs are left untouched — visible so
|
|
3350
|
+
* the author can fix them.
|
|
3351
|
+
*
|
|
3352
|
+
* A `{image:N:label}` pill is a mention chip like any other: `buildRefPillNodes`
|
|
3353
|
+
* appends its own trailing space to EVERY pill it builds, the positional
|
|
3354
|
+
* `imageRef` node included, so `a man wearing {image:1:hat} in the park` is the
|
|
3355
|
+
* ordinary shape and it expanded to "…the hat from reference image A in the
|
|
3356
|
+
* park". Splice through `spliceMentionPhrase` for the same seam tidy the three
|
|
3357
|
+
* `@`-mention resolvers use, right-to-left over offsets taken against the
|
|
3358
|
+
* pre-splice string (its OFFSET SAFETY note covers exactly this loop). The
|
|
3359
|
+
* legacy `expandImageRefTokensForRefs` keeps its raw `.replace` — legacy output
|
|
3360
|
+
* is pinned byte-identical.
|
|
3361
|
+
*/
|
|
2893
3362
|
function expandImageRefTokensHybrid(
|
|
2894
3363
|
prompt: string,
|
|
2895
3364
|
refs: readonly ConnectedReference[],
|
|
2896
3365
|
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2897
3366
|
): string {
|
|
2898
|
-
|
|
2899
|
-
|
|
3367
|
+
const splices: Array<{ offset: number; length: number; phrase: string }> = []
|
|
3368
|
+
for (const m of prompt.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
3369
|
+
const n = parseInt(m[1], 10)
|
|
2900
3370
|
const ref = refs[n - 1]
|
|
2901
3371
|
const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
|
|
2902
|
-
if (!finalIdx)
|
|
2903
|
-
|
|
2904
|
-
|
|
3372
|
+
if (!finalIdx) continue
|
|
3373
|
+
splices.push({
|
|
3374
|
+
offset: m.index ?? 0,
|
|
3375
|
+
length: m[0].length,
|
|
3376
|
+
phrase: hybridRolePhrase((m[2] ?? "").trim(), slotToLetter(finalIdx)),
|
|
3377
|
+
})
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// `matchAll` yields ascending offsets; splice back-to-front so the
|
|
3381
|
+
// not-yet-applied (smaller) offsets stay valid.
|
|
3382
|
+
let resolved = prompt
|
|
3383
|
+
for (let i = splices.length - 1; i >= 0; i--) {
|
|
3384
|
+
const s = splices[i]
|
|
3385
|
+
resolved = spliceMentionPhrase(resolved, s.offset, s.length, s.phrase)
|
|
3386
|
+
}
|
|
3387
|
+
return resolved
|
|
2905
3388
|
}
|
|
2906
3389
|
|
|
2907
3390
|
/** Capitalize the first alphabetic character of a string (line-initial). */
|