@nodaro/prompts 1.10.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 +693 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1084 -27
- package/dist/index.d.ts +1084 -27
- package/dist/index.js +664 -55
- 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-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -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-direction.test.ts +154 -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 +160 -58
- package/src/assemble-video-input.ts +244 -0
- package/src/direction-registry.ts +371 -0
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +10 -2
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +728 -58
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +233 -0
- package/src/subject-registry.ts +464 -0
package/src/prompt-builder.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { usageModeDirective, DEFAULT_USAGE_MODE, type UsageMode } from "@nodaro/
|
|
|
12
12
|
import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRoleSlug, resolveDefaultRole } from "@nodaro/shared"
|
|
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
|
+
import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
|
|
16
|
+
import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
|
|
15
17
|
import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
|
|
16
18
|
import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
|
|
17
19
|
|
|
@@ -205,6 +207,56 @@ export function resolveCharacterMentions(
|
|
|
205
207
|
return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
|
|
206
208
|
}
|
|
207
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
|
+
|
|
208
260
|
interface ResolveCharacterMentionsHybridResult {
|
|
209
261
|
/** Body with each `@`-mention replaced INLINE by its role phrase
|
|
210
262
|
* ("the {role} from reference image {LETTER}"). No directive block. */
|
|
@@ -248,6 +300,7 @@ interface ResolveCharacterMentionsHybridResult {
|
|
|
248
300
|
* parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
|
|
249
301
|
* reference instead of being dropped (the legacy resolver skips variant misses).
|
|
250
302
|
*/
|
|
303
|
+
|
|
251
304
|
function resolveCharacterMentionsHybrid(
|
|
252
305
|
prompt: string,
|
|
253
306
|
tokens: readonly CharacterMentionTokenInfo[],
|
|
@@ -326,12 +379,12 @@ function resolveCharacterMentionsHybrid(
|
|
|
326
379
|
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
327
380
|
}
|
|
328
381
|
|
|
329
|
-
// 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.
|
|
330
384
|
let resolvedPrompt = prompt
|
|
331
385
|
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
332
386
|
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
333
|
-
resolvedPrompt =
|
|
334
|
-
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
387
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
335
388
|
}
|
|
336
389
|
|
|
337
390
|
// One identity-lock + one element directive per UNIQUE attached URL (a
|
|
@@ -398,28 +451,111 @@ function locationModeDirective(mode: LocationUsageMode): string | null {
|
|
|
398
451
|
* `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
|
|
399
452
|
* location analog of the character mention's role segment, for the 4-mode
|
|
400
453
|
* location enum:
|
|
401
|
-
* - identical → "
|
|
402
|
-
* - style → "style"
|
|
403
|
-
* - layout → "layout"
|
|
404
|
-
* - 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.
|
|
405
473
|
*
|
|
406
474
|
* Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
|
|
407
475
|
* the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
|
|
408
476
|
* cast — unknown / non-location modes fall through to the safe source default
|
|
409
|
-
* instead of throwing. The
|
|
477
|
+
* instead of throwing. The two mode-specific roles are members of
|
|
410
478
|
* `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
|
|
411
479
|
*/
|
|
412
480
|
function locationModeToRole(mode: string | null | undefined): string {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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)
|
|
423
559
|
}
|
|
424
560
|
|
|
425
561
|
/**
|
|
@@ -594,9 +730,12 @@ interface ResolveLocationMentionsHybridResult {
|
|
|
594
730
|
* fallback on a variant miss) so the set of attached URLs / matched tokens never
|
|
595
731
|
* diverges between formats — only the rendered phrasing differs.
|
|
596
732
|
*
|
|
597
|
-
* ROLE is `
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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 …".
|
|
600
739
|
*
|
|
601
740
|
* SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
|
|
602
741
|
* ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
|
|
@@ -644,15 +783,12 @@ function resolveLocationMentionsHybrid(
|
|
|
644
783
|
mentionedLocationSlugs.add(t.locationSlug)
|
|
645
784
|
refByUrl.set(match.url, match)
|
|
646
785
|
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
647
|
-
const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
|
|
648
786
|
// A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
|
|
649
787
|
// `empty-background`, `as-is`, or a curated custom role) is used VERBATIM:
|
|
650
|
-
// it's acting as a role, not selecting a bucket/variant.
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
// byte-identical to the prior behavior for every non-role mention.
|
|
655
|
-
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)
|
|
656
792
|
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
657
793
|
}
|
|
658
794
|
|
|
@@ -667,12 +803,12 @@ function resolveLocationMentionsHybrid(
|
|
|
667
803
|
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
668
804
|
}
|
|
669
805
|
|
|
670
|
-
// 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.
|
|
671
808
|
let resolvedPrompt = prompt
|
|
672
809
|
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
673
810
|
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
674
|
-
resolvedPrompt =
|
|
675
|
-
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
811
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
676
812
|
}
|
|
677
813
|
|
|
678
814
|
// One opt-in lock + one element directive per UNIQUE attached URL.
|
|
@@ -694,6 +830,313 @@ function resolveLocationMentionsHybrid(
|
|
|
694
830
|
return { prompt: resolvedPrompt, additionalUrls, mentionedLocationSlugs, lockLines, elementDirectives }
|
|
695
831
|
}
|
|
696
832
|
|
|
833
|
+
interface ResolveImageMentionsHybridResult {
|
|
834
|
+
/** Body with each `@<image-name>` mention replaced INLINE by its role phrase
|
|
835
|
+
* ("the {role} from reference image {LETTER}", or the bare binding when the
|
|
836
|
+
* role resolves to the media default `""`). No directive block. */
|
|
837
|
+
prompt: string
|
|
838
|
+
/** Matched image URLs in mention order (deduped by the caller). */
|
|
839
|
+
additionalUrls: string[]
|
|
840
|
+
/** NO `mentionedImageSlugs` — the location result's analog exists to FILTER
|
|
841
|
+
* mentioned refs out of `connectedReferences`, and this pass deliberately
|
|
842
|
+
* does no such filtering (see the caller's NOTE). Carrying the set anyway
|
|
843
|
+
* would advertise a filter that does not exist. */
|
|
844
|
+
/** Per-reference identity-lock lines (deduped per URL). Caller prepends them
|
|
845
|
+
* as ONE block, merged with the character/location lock lines. */
|
|
846
|
+
lockLines: string[]
|
|
847
|
+
/** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
|
|
848
|
+
* them as trailing scene directives. */
|
|
849
|
+
elementDirectives: string[]
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* HYBRID named-image mention convergence (P3). The media analog of
|
|
854
|
+
* `resolveLocationMentionsHybrid`, for `wired-image` / `manual` references
|
|
855
|
+
* addressed by the slug of their `defaultName` (an upload node's label on the
|
|
856
|
+
* canvas, or the name a thin client puts on the reference).
|
|
857
|
+
*
|
|
858
|
+
* ASYMMETRY vs. characters and locations: a media ref AUTO-ATTACHES its URL
|
|
859
|
+
* through the New path whether or not it is mentioned, and carries NO canonical
|
|
860
|
+
* prose. So a mention here is BINDING + RE-SEATING, never attach-gating — which
|
|
861
|
+
* is why the caller does NOT filter mentioned refs out of `connectedReferences`
|
|
862
|
+
* the way the location pass does.
|
|
863
|
+
*
|
|
864
|
+
* NO LEGACY COUNTERPART. The Phase-0 arm that reaches this is hybrid-gated, so
|
|
865
|
+
* an `@name:N` token under the legacy reference format stays literal text and
|
|
866
|
+
* the ref attaches exactly as it does today (the `resolveLocationMentions`
|
|
867
|
+
* role-token precedent, where `if (t.role) continue` leaves the token alone).
|
|
868
|
+
*
|
|
869
|
+
* DUPLICATE SLUGS: FIRST WINS (`if (!bySlug.has(...))`), matching
|
|
870
|
+
* `buildTileIdForUrl`. Every unrenamed upload node shares its default label, so
|
|
871
|
+
* ties are the common case, not an edge.
|
|
872
|
+
*
|
|
873
|
+
* ROLE precedence: the per-mention 3rd segment (VERBATIM — media role presets
|
|
874
|
+
* are all single-word, so `normalizeRoleSlug` is location-only and must NOT be
|
|
875
|
+
* called here) → the node default via `resolveDefaultRole` → `""`, which
|
|
876
|
+
* `roleToPhrase` renders as the BARE binding ("reference image C"), i.e. today's
|
|
877
|
+
* ref-only default with a name attached to it.
|
|
878
|
+
*
|
|
879
|
+
* CAPPED REFS: `imageReferenceLimit(provider)` truncates `connectedReferences`
|
|
880
|
+
* BEFORE Phase 0, so a mention whose ref was capped out silently falls through
|
|
881
|
+
* as literal text — matching how a capped character mention behaves today.
|
|
882
|
+
*/
|
|
883
|
+
function resolveImageMentionsHybrid(
|
|
884
|
+
prompt: string,
|
|
885
|
+
tokens: readonly ImageMentionTokenInfo[],
|
|
886
|
+
refs: readonly ConnectedReference[],
|
|
887
|
+
existingUrls: readonly string[],
|
|
888
|
+
): ResolveImageMentionsHybridResult {
|
|
889
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
890
|
+
for (const r of refs) {
|
|
891
|
+
// `imageMentionSlugForRef` is the SAME predicate `knownImageSlugsFromRefs`
|
|
892
|
+
// applies to build the finder's known-slug set — one gate, so this map and
|
|
893
|
+
// that set can never admit different refs. (It also drops extras, which
|
|
894
|
+
// render through `renderExtraRefsHybrid` with their own body lines, and
|
|
895
|
+
// grammar-invalid slugs, where emptiness is NOT the gate.)
|
|
896
|
+
const slug = imageMentionSlugForRef(r)
|
|
897
|
+
if (!slug) continue
|
|
898
|
+
if (!bySlug.has(slug)) bySlug.set(slug, r)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const additionalUrls: string[] = []
|
|
902
|
+
const refByUrl = new Map<string, ConnectedReference>()
|
|
903
|
+
// Per-mention `~lock` / `~nolock`: the tri-state lock OVERRIDE per attached
|
|
904
|
+
// URL, fed to `withForcedIdentityLock` below. Only sentinel-bearing mentions
|
|
905
|
+
// write here (last sentinel wins), mirroring the location resolver.
|
|
906
|
+
const lockOverrideByUrl = new Map<string, boolean>()
|
|
907
|
+
const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
|
|
908
|
+
|
|
909
|
+
for (const t of tokens) {
|
|
910
|
+
const match = bySlug.get(t.imageSlug)
|
|
911
|
+
if (!match || !match.url) continue
|
|
912
|
+
additionalUrls.push(match.url)
|
|
913
|
+
refByUrl.set(match.url, match)
|
|
914
|
+
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
915
|
+
const role = (t.role ?? "").trim()
|
|
916
|
+
|| resolveDefaultRole(match.defaultRole, match.defaultUsageMode, match.source)
|
|
917
|
+
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Slot letters from the deduped [existing, mention] URL list — the prefix of
|
|
921
|
+
// the caller's `finalIndexByUrl`, so the letters agree.
|
|
922
|
+
const slotByUrl = new Map<string, number>()
|
|
923
|
+
for (const u of [...existingUrls, ...additionalUrls]) {
|
|
924
|
+
if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
|
|
925
|
+
}
|
|
926
|
+
const bindingFor = (url: string): string => {
|
|
927
|
+
const slot = slotByUrl.get(url)
|
|
928
|
+
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid;
|
|
932
|
+
// `spliceMentionPhrase` also tidies the horizontal whitespace at each seam.
|
|
933
|
+
let resolvedPrompt = prompt
|
|
934
|
+
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
935
|
+
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
936
|
+
resolvedPrompt = spliceMentionPhrase(resolvedPrompt, m.offset, m.token.length, phrase)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// One lock + one element directive per UNIQUE attached URL.
|
|
940
|
+
const lockLines: string[] = []
|
|
941
|
+
const elementDirectives: string[] = []
|
|
942
|
+
const seenUrls = new Set<string>()
|
|
943
|
+
for (const m of matched) {
|
|
944
|
+
if (seenUrls.has(m.url)) continue
|
|
945
|
+
seenUrls.add(m.url)
|
|
946
|
+
const ref = refByUrl.get(m.url)
|
|
947
|
+
if (!ref) continue
|
|
948
|
+
const binding = bindingFor(m.url)
|
|
949
|
+
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
950
|
+
if (lock) lockLines.push(lock)
|
|
951
|
+
const inject = ref.elementInjection?.trim()
|
|
952
|
+
if (inject) elementDirectives.push(inject)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
|
|
956
|
+
}
|
|
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
|
+
|
|
697
1140
|
/**
|
|
698
1141
|
* Build the canonical-fallback directive lines + URLs for wired characters
|
|
699
1142
|
* that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
|
|
@@ -1054,8 +1497,9 @@ function renderExtraRefsHybrid(
|
|
|
1054
1497
|
* Render the hybrid canonical convergence for UNMENTIONED wired locations (the
|
|
1055
1498
|
* location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
|
|
1056
1499
|
* `wired-location` ref → the inline role phrase
|
|
1057
|
-
* `roleToPhrase(
|
|
1058
|
-
*
|
|
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
|
|
1059
1503
|
* (null unless the ref enables one — locations have no built-in lock wording) +
|
|
1060
1504
|
* its wired `elementInjection` as a trailing directive.
|
|
1061
1505
|
*
|
|
@@ -1089,7 +1533,10 @@ function renderLocationCanonicalHybrid(
|
|
|
1089
1533
|
if (!slot) continue
|
|
1090
1534
|
seenUrls.add(r.url)
|
|
1091
1535
|
const binding = `reference image ${slotToLetter(slot)}`
|
|
1092
|
-
|
|
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))
|
|
1093
1540
|
const lock = buildIdentityLockLine(r, binding)
|
|
1094
1541
|
if (lock) lockLines.push(lock)
|
|
1095
1542
|
const inject = r.elementInjection?.trim()
|
|
@@ -1110,11 +1557,19 @@ function renderLocationCanonicalHybrid(
|
|
|
1110
1557
|
* has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
|
|
1111
1558
|
* default-off flip) + its wired `elementInjection` as a trailing directive.
|
|
1112
1559
|
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
* token
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
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).
|
|
1118
1573
|
*/
|
|
1119
1574
|
function renderObjectCreatureCanonicalHybrid(
|
|
1120
1575
|
nonCharacterRefs: readonly ConnectedReference[],
|
|
@@ -1495,6 +1950,12 @@ export interface BuildImagePromptConfig {
|
|
|
1495
1950
|
* (`flux-lora-character`) — the trigger word + LoRA carries identity, so
|
|
1496
1951
|
* the directive bullets are redundant and the wired-character refs
|
|
1497
1952
|
* shouldn't be injected as `Image N`.
|
|
1953
|
+
*
|
|
1954
|
+
* The strip regex is grammar-agnostic — it eats LOCATION and named-IMAGE
|
|
1955
|
+
* mentions (`@town:3:background`) as well as character ones. That is
|
|
1956
|
+
* INTENDED, not an oversight: this path drops `connectedReferences` outright,
|
|
1957
|
+
* so there is no reference left for any mention to bind to and a surviving
|
|
1958
|
+
* token would reach the model as literal noise. Do not narrow it.
|
|
1498
1959
|
*/
|
|
1499
1960
|
skipCharacterMentions?: boolean
|
|
1500
1961
|
}
|
|
@@ -1539,12 +2000,18 @@ export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
|
|
|
1539
2000
|
* - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
|
|
1540
2001
|
* style/avoid suffixes were appended (covers the common no-truncation case).
|
|
1541
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.
|
|
1542
2008
|
*/
|
|
1543
2009
|
interface AssemblyMarks {
|
|
1544
2010
|
directivesPrefix: string
|
|
1545
2011
|
bodyBeforeSuffixes: string
|
|
1546
2012
|
styleSuffix: string
|
|
1547
2013
|
avoidSuffix: string
|
|
2014
|
+
overflowChars: number
|
|
1548
2015
|
}
|
|
1549
2016
|
|
|
1550
2017
|
/** Keep `bodySegments` when the assembled body still equals their join;
|
|
@@ -1571,6 +2038,47 @@ export function buildImagePrompt(config: BuildImagePromptConfig): BuildImageProm
|
|
|
1571
2038
|
return buildImagePromptInternal(config)
|
|
1572
2039
|
}
|
|
1573
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
|
+
|
|
1574
2082
|
/**
|
|
1575
2083
|
* Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
|
|
1576
2084
|
* When `marks` is provided, records the directive-prefix / body / suffix spans
|
|
@@ -1606,6 +2114,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1606
2114
|
// line-initials (which would corrupt "the face from reference image A" → "The
|
|
1607
2115
|
// face …" / "the style from reference image A" → "The style …").
|
|
1608
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>()
|
|
1609
2125
|
|
|
1610
2126
|
// Character LoRA inference path: trigger word + LoRA model carry identity,
|
|
1611
2127
|
// so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
|
|
@@ -1714,7 +2230,37 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1714
2230
|
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
|
1715
2231
|
)
|
|
1716
2232
|
)
|
|
1717
|
-
|
|
2233
|
+
// Named-image mentions (P3). Slugs derive from each media ref's
|
|
2234
|
+
// `defaultName` — there is no `imageSlug` wire field; the derivation is
|
|
2235
|
+
// shared with the backend orchestrator's structured-branch gate via
|
|
2236
|
+
// `knownImageSlugsFromRefs`, so the two can never disagree about whether a
|
|
2237
|
+
// prompt carries a resolvable image mention.
|
|
2238
|
+
const knownImageSlugs = knownImageSlugsFromRefs(connectedReferences)
|
|
2239
|
+
// EXISTENCE-ONLY precheck, gating the Phase-0 arm below. Unlike characters
|
|
2240
|
+
// (whose canonical fallback must run even with ZERO mentions), images have
|
|
2241
|
+
// NO canonical fallback, so an image-only graph with no mention has no
|
|
2242
|
+
// reason to enter Phase 0 — gating on TOKEN presence keeps today's control
|
|
2243
|
+
// flow and byte output for every unmentioned graph BY CONSTRUCTION.
|
|
2244
|
+
// HYBRID-only: under the legacy format an `@name:N` token stays literal text.
|
|
2245
|
+
const hasImageMentionTokens = isHybrid
|
|
2246
|
+
&& knownImageSlugs.length > 0
|
|
2247
|
+
&& findImageMentionTokens(config.prompt, knownImageSlugs).length > 0
|
|
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) {
|
|
1718
2264
|
const mentionTokens = knownCharacterSlugs.length > 0
|
|
1719
2265
|
? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
|
|
1720
2266
|
: []
|
|
@@ -1806,6 +2352,85 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1806
2352
|
if (r.locationVariantBucket) return false
|
|
1807
2353
|
return !mentionedLocationSlugs.has(r.locationSlug)
|
|
1808
2354
|
})
|
|
2355
|
+
// Pass 3 (P3): named-image mentions, resolved on the POST-character,
|
|
2356
|
+
// POST-location prompt. The finder MUST re-run here — the earlier passes
|
|
2357
|
+
// spliced their own tokens out, so any offset computed before them is
|
|
2358
|
+
// stale. PRECEDENCE character → location → image falls out of this pass
|
|
2359
|
+
// order: a name shared by a character and an image resolves as the
|
|
2360
|
+
// character, and the image token never fires.
|
|
2361
|
+
//
|
|
2362
|
+
// `existingUrls` is already location-inclusive because `additionalUrls`
|
|
2363
|
+
// absorbed the location URLs just above, so the mention slot letters stay
|
|
2364
|
+
// a prefix of the final `finalIndexByUrl`.
|
|
2365
|
+
let hybridImageLockLines: string[] = []
|
|
2366
|
+
let hybridImageElementDirectives: string[] = []
|
|
2367
|
+
if (hasImageMentionTokens) {
|
|
2368
|
+
const imageTokens = findImageMentionTokens(resolved.prompt, knownImageSlugs)
|
|
2369
|
+
if (imageTokens.length > 0) {
|
|
2370
|
+
const hi = resolveImageMentionsHybrid(
|
|
2371
|
+
resolved.prompt,
|
|
2372
|
+
imageTokens,
|
|
2373
|
+
connectedReferences,
|
|
2374
|
+
[...(referenceImageUrls || []), ...resolved.additionalUrls],
|
|
2375
|
+
)
|
|
2376
|
+
resolved.prompt = hi.prompt
|
|
2377
|
+
resolved.additionalUrls = [...resolved.additionalUrls, ...hi.additionalUrls]
|
|
2378
|
+
hybridImageLockLines = hi.lockLines
|
|
2379
|
+
hybridImageElementDirectives = hi.elementDirectives
|
|
2380
|
+
// Inline role phrases now live in the body → skip line-initial
|
|
2381
|
+
// capitalization, which would otherwise corrupt a mid-sentence
|
|
2382
|
+
// "the background from reference image C".
|
|
2383
|
+
if (hi.additionalUrls.length > 0) hybridBodyConverged = true
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
// NOTE — deliberately NO `connectedReferences` filter for mentioned image
|
|
2387
|
+
// refs (the location pass filters, just above). The New-path URL merge
|
|
2388
|
+
// dedups by URL so the ref keeps its earlier Phase-0 slot; wired-image /
|
|
2389
|
+
// manual refs have no canonical-fallback prose to double-emit (that loop
|
|
2390
|
+
// is gated to wired-location/object/creature); and leaving the ref in
|
|
2391
|
+
// place keeps `nonCharacterRefs[N-1]` positional indexing stable for
|
|
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
|
+
}
|
|
1809
2434
|
// Default-fallback canonical URLs + directives for any wired character
|
|
1810
2435
|
// that has zero mentions in the prompt. Mirrors the legacy behavior the
|
|
1811
2436
|
// mention feature replaced — wire a character with no typing required.
|
|
@@ -1944,12 +2569,16 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
1944
2569
|
const allLockLines = [...new Set([
|
|
1945
2570
|
...hybridLockLines,
|
|
1946
2571
|
...hybridLocationLockLines,
|
|
2572
|
+
...hybridImageLockLines,
|
|
2573
|
+
...hybridEntityLockLines,
|
|
1947
2574
|
...canonical.lockLines,
|
|
1948
2575
|
...extrasRendered.lockLines,
|
|
1949
2576
|
])]
|
|
1950
2577
|
const trailingLines = [
|
|
1951
2578
|
...hybridElementDirectives,
|
|
1952
2579
|
...hybridLocationElementDirectives,
|
|
2580
|
+
...hybridImageElementDirectives,
|
|
2581
|
+
...hybridEntityElementDirectives,
|
|
1953
2582
|
...canonical.phrases,
|
|
1954
2583
|
...canonical.elementDirectives,
|
|
1955
2584
|
...extrasRendered.bodyLines,
|
|
@@ -2080,10 +2709,22 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2080
2709
|
// (Phase C): each → the trailing role phrase "the {role} from reference
|
|
2081
2710
|
// image {LETTER}" + opt-in lock + wired elementInjection, numbered against
|
|
2082
2711
|
// `finalIndexByUrl`. Mentioned locations were converged inline in Phase 0
|
|
2083
|
-
// and filtered out of `nonCharacterRefs`; objects/creatures
|
|
2084
|
-
//
|
|
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.
|
|
2085
2715
|
const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
|
|
2086
|
-
|
|
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)
|
|
2087
2728
|
const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
|
|
2088
2729
|
const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
|
|
2089
2730
|
const canonTrailingLines = [
|
|
@@ -2160,9 +2801,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2160
2801
|
// the `Avoid:` negative whenever the body was long. The style/avoid suffixes
|
|
2161
2802
|
// are RESERVED first so a long body can never drop them (the control text is
|
|
2162
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.
|
|
2163
2806
|
const maxLen = getMaxImagePromptChars(provider)
|
|
2164
2807
|
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2165
2808
|
if (prompt.length + reserved > maxLen) {
|
|
2809
|
+
if (marks) marks.overflowChars += prompt.length + reserved - maxLen
|
|
2166
2810
|
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2167
2811
|
}
|
|
2168
2812
|
// Body span = everything after the captured directive prefix, taken from the
|
|
@@ -2173,6 +2817,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2173
2817
|
prompt = prompt + styleSuffix + avoidSuffix
|
|
2174
2818
|
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2175
2819
|
if (prompt.length > maxLen) {
|
|
2820
|
+
if (marks) marks.overflowChars += prompt.length - maxLen
|
|
2176
2821
|
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2177
2822
|
}
|
|
2178
2823
|
|
|
@@ -2268,10 +2913,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2268
2913
|
// Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
|
|
2269
2914
|
// style/avoid suffixes so a long body never severs the appended control text
|
|
2270
2915
|
// (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
|
|
2271
|
-
// BODY, THEN append the suffixes.
|
|
2916
|
+
// BODY, THEN append the suffixes. Same order-blind cut as the
|
|
2917
|
+
// connectedReferences path above → same `overflowChars` bookkeeping.
|
|
2272
2918
|
const maxLen = getMaxImagePromptChars(provider)
|
|
2273
2919
|
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2274
2920
|
if (prompt.length + reserved > maxLen) {
|
|
2921
|
+
if (marks) marks.overflowChars += prompt.length + reserved - maxLen
|
|
2275
2922
|
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2276
2923
|
}
|
|
2277
2924
|
// Legacy path has no directive prefix; the body is the char-desc-wrapped
|
|
@@ -2280,6 +2927,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2280
2927
|
prompt = prompt + styleSuffix + avoidSuffix
|
|
2281
2928
|
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2282
2929
|
if (prompt.length > maxLen) {
|
|
2930
|
+
if (marks) marks.overflowChars += prompt.length - maxLen
|
|
2283
2931
|
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2284
2932
|
}
|
|
2285
2933
|
|
|
@@ -2312,12 +2960,7 @@ export function buildImagePromptSegments(
|
|
|
2312
2960
|
config: BuildImagePromptConfig,
|
|
2313
2961
|
bodySegments?: readonly PromptSegment[],
|
|
2314
2962
|
): BuildImagePromptSegmentsResult {
|
|
2315
|
-
const marks
|
|
2316
|
-
directivesPrefix: "",
|
|
2317
|
-
bodyBeforeSuffixes: "",
|
|
2318
|
-
styleSuffix: "",
|
|
2319
|
-
avoidSuffix: "",
|
|
2320
|
-
}
|
|
2963
|
+
const marks = newAssemblyMarks()
|
|
2321
2964
|
|
|
2322
2965
|
const result = buildImagePromptInternal(config, marks)
|
|
2323
2966
|
|
|
@@ -2700,21 +3343,48 @@ function hybridRolePhrase(label: string, letter: string): string {
|
|
|
2700
3343
|
return `the ${label} from reference image ${letter}`
|
|
2701
3344
|
}
|
|
2702
3345
|
|
|
2703
|
-
/**
|
|
2704
|
-
*
|
|
2705
|
-
*
|
|
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
|
+
*/
|
|
2706
3362
|
function expandImageRefTokensHybrid(
|
|
2707
3363
|
prompt: string,
|
|
2708
3364
|
refs: readonly ConnectedReference[],
|
|
2709
3365
|
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2710
3366
|
): string {
|
|
2711
|
-
|
|
2712
|
-
|
|
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)
|
|
2713
3370
|
const ref = refs[n - 1]
|
|
2714
3371
|
const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
|
|
2715
|
-
if (!finalIdx)
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
|
2718
3388
|
}
|
|
2719
3389
|
|
|
2720
3390
|
/** Capitalize the first alphabetic character of a string (line-initial). */
|