@nodaro/prompts 1.9.0 → 1.10.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 +108 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +241 -31
- package/dist/index.d.ts +241 -31
- package/dist/index.js +105 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +3 -1
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/character-fx.ts +102 -19
- package/src/picker-catalogs.ts +18 -1
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/video-reference-resolver.ts +78 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^2.
|
|
23
|
+
"@nodaro/shared": "^2.14.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The character-fx node's three timing parameters (position / duration /
|
|
3
|
+
* intensity) are enumerable catalogs, not free values, so a consumer that can
|
|
4
|
+
* only send ids — Studio, the SDK, MCP — can offer them without composing any
|
|
5
|
+
* prompt text of its own. This is the character-fx twin of
|
|
6
|
+
* `transition-timing-catalogs.test.ts`; the two nodes share ids and shape but
|
|
7
|
+
* NOT wording, and nothing here may couple them.
|
|
8
|
+
*
|
|
9
|
+
* Three properties matter, and each is easy to lose silently:
|
|
10
|
+
*
|
|
11
|
+
* 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
|
|
12
|
+
* the same string. They are derived from one array in `character-fx.ts`,
|
|
13
|
+
* and these tests fail the moment someone re-introduces a second copy.
|
|
14
|
+
* 2. The prompt text is exactly what shipped before it was enumerable. The
|
|
15
|
+
* parameter-hint golden covers only three of the twelve clauses, so the
|
|
16
|
+
* twelve literals are pinned here: a change is a deliberate diff, never a
|
|
17
|
+
* side effect.
|
|
18
|
+
* 3. The dimensions survive the wire projection — `projectAllCatalogs()` is
|
|
19
|
+
* what an id-only client actually reads, and a green in-memory catalog
|
|
20
|
+
* proves nothing about it.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from "vitest"
|
|
23
|
+
import {
|
|
24
|
+
CHARACTER_FX_POSITIONS,
|
|
25
|
+
CHARACTER_FX_DURATIONS,
|
|
26
|
+
CHARACTER_FX_INTENSITIES,
|
|
27
|
+
composeCharacterFxHintFromConnections,
|
|
28
|
+
} from "../character-fx.js"
|
|
29
|
+
import { TRANSITION_POSITIONS, TRANSITION_DURATIONS } from "../transitions.js"
|
|
30
|
+
import { PICKER_CATALOGS, projectPickerCatalog, projectAllCatalogs } from "../picker-catalogs.js"
|
|
31
|
+
|
|
32
|
+
const DIMENSIONS = [
|
|
33
|
+
["position", CHARACTER_FX_POSITIONS],
|
|
34
|
+
["duration", CHARACTER_FX_DURATIONS],
|
|
35
|
+
["intensity", CHARACTER_FX_INTENSITIES],
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
const characterFxCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "character-fx")!
|
|
39
|
+
|
|
40
|
+
describe("character-fx timing catalogs", () => {
|
|
41
|
+
// The `CharacterFxPosition` / `Duration` / `Intensity` unions are DERIVED
|
|
42
|
+
// from these arrays, so nothing at the type level can pin the values
|
|
43
|
+
// themselves. These are the values the editor dropdowns, the node data, and
|
|
44
|
+
// every stored workflow already use: changing one is a data migration, not a
|
|
45
|
+
// rename.
|
|
46
|
+
it("spells exactly the ids every stored workflow already uses", () => {
|
|
47
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.id)).toEqual([
|
|
48
|
+
"auto", "start", "middle", "end", "full",
|
|
49
|
+
])
|
|
50
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.id)).toEqual([
|
|
51
|
+
"auto", "instant", "short", "medium", "long",
|
|
52
|
+
])
|
|
53
|
+
expect(CHARACTER_FX_INTENSITIES.map((o) => o.id)).toEqual([
|
|
54
|
+
"auto", "subtle", "natural", "dynamic", "crazy",
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("injects exactly the clauses that shipped before the scales were enumerable", () => {
|
|
59
|
+
// Byte-for-byte the literals the hand-written clause tables carried. This
|
|
60
|
+
// task was exposure, not authoring: if any of these move, that is a prompt
|
|
61
|
+
// change for real users and must be its own deliberate diff.
|
|
62
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).toEqual([
|
|
63
|
+
"",
|
|
64
|
+
"the effect occurs at the opening of the clip",
|
|
65
|
+
"the effect occurs in the middle of the clip",
|
|
66
|
+
"the effect occurs at the end of the clip",
|
|
67
|
+
"the effect persists for the entire clip",
|
|
68
|
+
])
|
|
69
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).toEqual([
|
|
70
|
+
"",
|
|
71
|
+
"manifesting instantaneously",
|
|
72
|
+
"manifesting over approximately 1 second",
|
|
73
|
+
"manifesting over approximately 2 seconds",
|
|
74
|
+
"manifesting over approximately 3 seconds",
|
|
75
|
+
])
|
|
76
|
+
expect(CHARACTER_FX_INTENSITIES.map((o) => o.promptHint)).toEqual([
|
|
77
|
+
"",
|
|
78
|
+
"with subtle restrained energy and minimal flourish",
|
|
79
|
+
"with natural unhurried timing",
|
|
80
|
+
"with dynamic energy and assertive flourish",
|
|
81
|
+
"with extreme exaggerated energy, wild flourishes, and dramatic distortion",
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("keeps its own wording — an effect manifests and persists, a transition occurs and spans", () => {
|
|
86
|
+
// The one constraint that matters most: these scales must never be unified
|
|
87
|
+
// with the transition ones. Position and duration are worded differently
|
|
88
|
+
// on purpose, so pointing character-fx at the transition rows fails here.
|
|
89
|
+
// Intensity is deliberately NOT compared: its three clauses happen to be
|
|
90
|
+
// identical today, and that is a coincidence, not a shared definition.
|
|
91
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).not.toEqual(
|
|
92
|
+
TRANSITION_POSITIONS.map((o) => o.promptHint),
|
|
93
|
+
)
|
|
94
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).not.toEqual(
|
|
95
|
+
TRANSITION_DURATIONS.map((o) => o.promptHint),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("carries the timing precision users read off the dropdown", () => {
|
|
100
|
+
// The editor renders straight from here (its own catalogs, not the
|
|
101
|
+
// transition ones), so this is the one place the numbers live.
|
|
102
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.label)).toEqual([
|
|
103
|
+
"Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("gives every injecting step a compact term, so compact mode never drops it", () => {
|
|
108
|
+
// `catalog-terms.test.ts` enforces this globally; pinned here too because
|
|
109
|
+
// the platform composes timing from `promptHint` in BOTH hint modes, so a
|
|
110
|
+
// broken `term` would only ever surface on an external compact client.
|
|
111
|
+
for (const [field, options] of DIMENSIONS) {
|
|
112
|
+
for (const o of options.slice(1)) {
|
|
113
|
+
expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
|
|
114
|
+
}
|
|
115
|
+
expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
|
|
120
|
+
// The regression this guards: a step present in the catalog but missing
|
|
121
|
+
// from the clause table made `parts.push(undefined)` render as a dangling
|
|
122
|
+
// ", " on a prompt that shipped to the provider. Walks the ARRAYS, not a
|
|
123
|
+
// hardcoded id list, so a step added to a catalog is exercised here too.
|
|
124
|
+
for (const [field, options] of DIMENSIONS) {
|
|
125
|
+
for (const o of options.slice(1)) {
|
|
126
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], {
|
|
127
|
+
[field]: o.id,
|
|
128
|
+
} as never)
|
|
129
|
+
expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
|
|
130
|
+
expect(composed, `${field}/${o.id}`).not.toContain("undefined")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("every scale leads with a no-op `auto` that injects nothing", () => {
|
|
136
|
+
for (const [field, options] of DIMENSIONS) {
|
|
137
|
+
expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
|
|
138
|
+
expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
|
|
139
|
+
// Every other step must actually carry a clause.
|
|
140
|
+
for (const o of options.slice(1)) {
|
|
141
|
+
expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("the composed clause is the catalog's own promptHint, verbatim", () => {
|
|
147
|
+
// One source of truth: if the composer ever grows a private copy of these
|
|
148
|
+
// strings, the composed sentence stops containing the advertised hint.
|
|
149
|
+
for (const o of CHARACTER_FX_POSITIONS.slice(1)) {
|
|
150
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { position: o.id as never })
|
|
151
|
+
expect(composed, `position/${o.id}`).toContain(o.promptHint)
|
|
152
|
+
}
|
|
153
|
+
for (const o of CHARACTER_FX_DURATIONS.slice(1)) {
|
|
154
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { duration: o.id as never })
|
|
155
|
+
expect(composed, `duration/${o.id}`).toContain(o.promptHint)
|
|
156
|
+
}
|
|
157
|
+
for (const o of CHARACTER_FX_INTENSITIES.slice(1)) {
|
|
158
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { intensity: o.id as never })
|
|
159
|
+
expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("the clauses are emitted identically in compact hint mode", () => {
|
|
164
|
+
// Timing is composed from `promptHint` in BOTH modes — only the effect's
|
|
165
|
+
// base fragment swaps to its term. Pinned so a compact-mode refactor cannot
|
|
166
|
+
// quietly start reading `term` for the timing rows.
|
|
167
|
+
for (const [field, options] of DIMENSIONS) {
|
|
168
|
+
for (const o of options.slice(1)) {
|
|
169
|
+
const compact = composeCharacterFxHintFromConnections("werewolf", [], {
|
|
170
|
+
[field]: o.id,
|
|
171
|
+
} as never, "compact")
|
|
172
|
+
expect(compact, `${field}/${o.id} in compact mode`).toContain(o.promptHint)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("`auto` adds nothing to the composed sentence", () => {
|
|
178
|
+
const bare = composeCharacterFxHintFromConnections("werewolf", [])
|
|
179
|
+
const allAuto = composeCharacterFxHintFromConnections("werewolf", [], { position: "auto", duration: "auto", intensity: "auto" })
|
|
180
|
+
expect(allAuto).toBe(bare)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("exposes the three dimensions on the character-fx catalog", () => {
|
|
184
|
+
expect(characterFxCatalog.kind).toBe("single")
|
|
185
|
+
expect(characterFxCatalog.dimensions?.map((d) => d.field)).toEqual([
|
|
186
|
+
"position",
|
|
187
|
+
"duration",
|
|
188
|
+
"intensity",
|
|
189
|
+
])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("keeps the dimensions through the wire projection", () => {
|
|
193
|
+
const wire = projectPickerCatalog(characterFxCatalog, { detail: "full" })
|
|
194
|
+
|
|
195
|
+
// The 57-option effect list is untouched by the addition.
|
|
196
|
+
expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
|
|
197
|
+
|
|
198
|
+
expect(wire.dimensions).toHaveLength(3)
|
|
199
|
+
for (const [field, options] of DIMENSIONS) {
|
|
200
|
+
const dim = wire.dimensions?.find((d) => d.field === field)
|
|
201
|
+
expect(dim, `${field} must reach the wire`).toBeDefined()
|
|
202
|
+
expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
|
|
203
|
+
expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("field filter narrows a single-dim catalog to one secondary dimension", () => {
|
|
208
|
+
const wire = projectPickerCatalog(characterFxCatalog, { field: "duration" })
|
|
209
|
+
expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
|
|
210
|
+
expect(wire.dimensions?.map((d) => d.field)).toEqual(["duration"])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe("projectAllCatalogs — what an id-only client actually reads", () => {
|
|
214
|
+
// `GET /v1/catalogs` is built from this call. Both detail levels must carry
|
|
215
|
+
// the three dimensions with ids and labels; `full` must also carry the
|
|
216
|
+
// clause, and `compact` must carry the term so a thin client can inject
|
|
217
|
+
// without a second fetch.
|
|
218
|
+
for (const detail of ["compact", "full"] as const) {
|
|
219
|
+
it(`detail=${detail}: three dimensions with ids, labels${detail === "full" ? " and hints" : " and terms"}`, () => {
|
|
220
|
+
const wire = projectAllCatalogs({ detail }).find((c) => c.nodeType === "character-fx")!
|
|
221
|
+
expect(wire.detail).toBe(detail)
|
|
222
|
+
expect(wire.kind).toBe("single")
|
|
223
|
+
expect(wire.dimensions?.map((d) => d.field)).toEqual(["position", "duration", "intensity"])
|
|
224
|
+
|
|
225
|
+
for (const [field, options] of DIMENSIONS) {
|
|
226
|
+
const dim = wire.dimensions!.find((d) => d.field === field)!
|
|
227
|
+
expect(dim.label, `${field} label`).toBeTruthy()
|
|
228
|
+
expect(dim.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
|
|
229
|
+
expect(dim.options.map((o) => o.label)).toEqual(options.map((o) => o.label))
|
|
230
|
+
expect(dim.options.map((o) => o.term)).toEqual(options.map((o) => o.term))
|
|
231
|
+
if (detail === "full") {
|
|
232
|
+
expect(dim.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
|
|
233
|
+
} else {
|
|
234
|
+
for (const o of dim.options) expect(o, `${field}/${o.id} compact`).not.toHaveProperty("promptHint")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -143,9 +143,11 @@ describe("transition timing catalogs", () => {
|
|
|
143
143
|
})
|
|
144
144
|
|
|
145
145
|
it("leaves every other single-dim catalog without dimensions", () => {
|
|
146
|
+
// `character-fx` carries the same three timing fields with its own,
|
|
147
|
+
// deliberately different wording (see `character-fx-timing-catalogs.test.ts`).
|
|
146
148
|
const singlesWithDims = PICKER_CATALOGS.filter(
|
|
147
149
|
(c) => c.kind === "single" && c.dimensions,
|
|
148
150
|
).map((c) => c.nodeType)
|
|
149
|
-
expect(singlesWithDims).toEqual(["transition"])
|
|
151
|
+
expect(singlesWithDims).toEqual(["transition", "character-fx"])
|
|
150
152
|
})
|
|
151
153
|
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
|
|
3
|
+
*
|
|
4
|
+
* A client that names a reference by the `connectedReferences[].id` it sent
|
|
5
|
+
* (Studio's bound `@`-chips) gets the `@image_N` slot substituted by the
|
|
6
|
+
* platform AFTER the platform has numbered the references. That removes the
|
|
7
|
+
* client-side mirror of the numbering walk — the one duplicated rule that could
|
|
8
|
+
* silently misbind pictures for a client built against an older package.
|
|
9
|
+
*
|
|
10
|
+
* Contract pinned here:
|
|
11
|
+
* - the slot comes from the SAME walk that numbers the directives (mention
|
|
12
|
+
* URLs → canonical fallback → extras, offset by the leading flat refs);
|
|
13
|
+
* - the token is resolved BEFORE the `referenceOrder` reorder, so the
|
|
14
|
+
* renumber pass carries the binding to the ref's final seat — the opposite
|
|
15
|
+
* of `{image:N}`, which is resolved AFTER it to keep the author's N;
|
|
16
|
+
* - an unresolvable token never ships raw: label → the ref's display name
|
|
17
|
+
* (when the id is known) → "";
|
|
18
|
+
* - a prompt with no `{ref:` token is byte-identical to before.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from "vitest"
|
|
21
|
+
import {
|
|
22
|
+
resolveVideoReferenceCore,
|
|
23
|
+
resolveRefIdTokens,
|
|
24
|
+
resolveReferenceTokens,
|
|
25
|
+
} from "../video-reference-resolver.js"
|
|
26
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
27
|
+
|
|
28
|
+
const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
29
|
+
id: "char-kira", defaultName: "Kira", source: "wired-character", url: "https://r2/kira.png",
|
|
30
|
+
characterSlug: "kira", variantSlug: undefined, characterCanonicalDescription: null,
|
|
31
|
+
variantDescription: null, variantDisplayName: "canonical", ...over,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const A = "https://cdn/a.png"
|
|
35
|
+
const B = "https://cdn/b.png"
|
|
36
|
+
|
|
37
|
+
/** Every case: the token must be gone, whatever it resolved to. */
|
|
38
|
+
function expectNoRawToken(prompt: string | undefined) {
|
|
39
|
+
expect(prompt ?? "").not.toMatch(/\{ref:/i)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("resolveVideoReferenceCore — {ref:<id>} id-addressed tokens", () => {
|
|
43
|
+
it("bare {ref:<id>} binds an image extra to its @image_N slot", () => {
|
|
44
|
+
const out = resolveVideoReferenceCore({
|
|
45
|
+
prompt: "drive {ref:car-1} fast",
|
|
46
|
+
wiredCharRefs: [],
|
|
47
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
48
|
+
})
|
|
49
|
+
expect(out.additionalUrls).toEqual(["https://r2/car.png"])
|
|
50
|
+
expect(out.prompt).toContain("- @image_1 (reference): a red car.")
|
|
51
|
+
expect(out.prompt).toContain("drive @image_1 fast")
|
|
52
|
+
expectNoRawToken(out.prompt)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("labeled {ref:<id>:<label>} binds through REF_BINDING.image (parity with {image:N:label})", () => {
|
|
56
|
+
const out = resolveVideoReferenceCore({
|
|
57
|
+
prompt: "drive {ref:car-1:car} fast",
|
|
58
|
+
wiredCharRefs: [],
|
|
59
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
60
|
+
})
|
|
61
|
+
expect(out.prompt).toContain("drive the car from @image_1 fast")
|
|
62
|
+
expectNoRawToken(out.prompt)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("a canonical wired character binds to its canonical-fallback slot", () => {
|
|
66
|
+
const out = resolveVideoReferenceCore({
|
|
67
|
+
prompt: "{ref:char-kira} walks in",
|
|
68
|
+
wiredCharRefs: [charRef()],
|
|
69
|
+
})
|
|
70
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
|
|
71
|
+
expect(out.prompt).toContain("Use these characters:")
|
|
72
|
+
expect(out.prompt).toContain("@image_1 walks in")
|
|
73
|
+
expectNoRawToken(out.prompt)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("a character VIEW (extra with characterSlug) binds to its pair-back slot after the canonical", () => {
|
|
77
|
+
const out = resolveVideoReferenceCore({
|
|
78
|
+
prompt: "{ref:view-1} turns to face {ref:char-kira}",
|
|
79
|
+
wiredCharRefs: [charRef()],
|
|
80
|
+
extraRefs: [{ id: "view-1", url: "https://r2/kira-side.png", characterSlug: "kira", description: "side profile" }],
|
|
81
|
+
})
|
|
82
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png", "https://r2/kira-side.png"])
|
|
83
|
+
expect(out.prompt).toContain("- @image_2 is the same subject as @image_1, side profile.")
|
|
84
|
+
expect(out.prompt).toContain("@image_2 turns to face @image_1")
|
|
85
|
+
expectNoRawToken(out.prompt)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("leading flat refs offset the slot (D5 image-refs-first)", () => {
|
|
89
|
+
const out = resolveVideoReferenceCore({
|
|
90
|
+
prompt: "the {ref:obj} on the table",
|
|
91
|
+
wiredCharRefs: [],
|
|
92
|
+
leadingRefUrls: [A],
|
|
93
|
+
extraRefs: [{ id: "obj", url: B, description: "object" }],
|
|
94
|
+
})
|
|
95
|
+
expect(out.additionalUrls).toEqual([A, B])
|
|
96
|
+
expect(out.prompt).toContain("the @image_2 on the table")
|
|
97
|
+
expectNoRawToken(out.prompt)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("ids are opaque: `:` and `/` inside an id resolve, and a trailing label still parses", () => {
|
|
101
|
+
const out = resolveVideoReferenceCore({
|
|
102
|
+
prompt: "{ref:https://cdn/pic.png} beside {ref:kira:smile:smile}",
|
|
103
|
+
wiredCharRefs: [],
|
|
104
|
+
extraRefs: [
|
|
105
|
+
{ id: "https://cdn/pic.png", url: "https://cdn/pic.png", description: "pic" },
|
|
106
|
+
{ id: "kira:smile", url: "https://r2/kira-smile.png", description: "smile" },
|
|
107
|
+
],
|
|
108
|
+
})
|
|
109
|
+
expect(out.prompt).toContain("@image_1 beside the smile from @image_2")
|
|
110
|
+
expectNoRawToken(out.prompt)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("an unknown id degrades to its label, or to nothing — never the raw token", () => {
|
|
114
|
+
const out = resolveVideoReferenceCore({
|
|
115
|
+
prompt: "a {ref:nope:ghost} b {ref:nope2} c",
|
|
116
|
+
wiredCharRefs: [],
|
|
117
|
+
extraRefs: [{ id: "x", url: A, description: "d" }],
|
|
118
|
+
})
|
|
119
|
+
expect(out.prompt).toContain("a ghost b c")
|
|
120
|
+
expectNoRawToken(out.prompt)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("a known ref the walk never seated degrades to its display name", () => {
|
|
124
|
+
// A wired character with no url is skipped by the canonical loop — the id
|
|
125
|
+
// is known (so the name is), but there is no slot to bind.
|
|
126
|
+
const out = resolveVideoReferenceCore({
|
|
127
|
+
prompt: "{ref:char-kira} waves at {ref:capped-1}",
|
|
128
|
+
wiredCharRefs: [charRef({ url: "" })],
|
|
129
|
+
extraRefs: [{ id: "x", url: A, description: "d" }],
|
|
130
|
+
// The caller's full id → name map (the route builds it from EVERY
|
|
131
|
+
// connectedReference, including the ones it capped out before the walk).
|
|
132
|
+
refNamesById: new Map([["capped-1", "Truck"]]),
|
|
133
|
+
})
|
|
134
|
+
expect(out.prompt).toContain("Kira waves at Truck")
|
|
135
|
+
expectNoRawToken(out.prompt)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("a duplicate-URL extra never binds past the payload: its {ref:} degrades to its name", () => {
|
|
139
|
+
// The walk counts every extra with a url while `merged` dedups by URL, so
|
|
140
|
+
// the second extra's directive is numbered @image_2 although the payload
|
|
141
|
+
// carries ONE image (pre-existing walk-vs-merged drift). The token is
|
|
142
|
+
// range-gated against the image count, so it degrades instead of emitting
|
|
143
|
+
// a phantom binding.
|
|
144
|
+
const out = resolveVideoReferenceCore({
|
|
145
|
+
prompt: "{ref:x} then {ref:y}",
|
|
146
|
+
wiredCharRefs: [],
|
|
147
|
+
extraRefs: [
|
|
148
|
+
{ id: "x", url: A, description: "first" },
|
|
149
|
+
{ id: "y", url: A, description: "second" },
|
|
150
|
+
],
|
|
151
|
+
refNamesById: new Map([["y", "Second"]]),
|
|
152
|
+
})
|
|
153
|
+
expect(out.additionalUrls).toEqual([A])
|
|
154
|
+
expect(out.prompt).toContain("@image_1 then Second")
|
|
155
|
+
expectNoRawToken(out.prompt)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("resolves BEFORE the referenceOrder reorder, so the binding follows the ref to its final seat", () => {
|
|
159
|
+
const out = resolveVideoReferenceCore({
|
|
160
|
+
prompt: "{ref:y} leads, {ref:x} follows",
|
|
161
|
+
wiredCharRefs: [],
|
|
162
|
+
extraRefs: [
|
|
163
|
+
{ id: "x", url: A, description: "ax" },
|
|
164
|
+
{ id: "y", url: B, description: "by" },
|
|
165
|
+
],
|
|
166
|
+
// Extras' tile ids are `wired:<url>` (the reorder contract, unchanged).
|
|
167
|
+
referenceOrder: [`wired:${B}`, `wired:${A}`],
|
|
168
|
+
})
|
|
169
|
+
expect(out.additionalUrls).toEqual([B, A])
|
|
170
|
+
expect(out.prompt).toContain("@image_1 leads, @image_2 follows")
|
|
171
|
+
expect(out.prompt).toContain("- @image_1 (reference): by.")
|
|
172
|
+
expect(out.prompt).toContain("- @image_2 (reference): ax.")
|
|
173
|
+
expectNoRawToken(out.prompt)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("keeps {image:N} resolved AFTER the reorder (author's N kept) while {ref:} follows the ref", () => {
|
|
177
|
+
const out = resolveVideoReferenceCore({
|
|
178
|
+
prompt: "{ref:y} and {image:2:second}",
|
|
179
|
+
wiredCharRefs: [],
|
|
180
|
+
extraRefs: [
|
|
181
|
+
{ id: "x", url: A, description: "ax" },
|
|
182
|
+
{ id: "y", url: B, description: "by" },
|
|
183
|
+
],
|
|
184
|
+
referenceOrder: [`wired:${B}`, `wired:${A}`],
|
|
185
|
+
})
|
|
186
|
+
// y moved to seat 1 → {ref:y} rides along; {image:2} keeps the literal 2.
|
|
187
|
+
expect(out.prompt).toContain("@image_1 and the second from @image_2")
|
|
188
|
+
expectNoRawToken(out.prompt)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("an @-mentioned character's ref binds to the mention's slot", () => {
|
|
192
|
+
const out = resolveVideoReferenceCore({
|
|
193
|
+
prompt: "@kira:1 waves, then {ref:char-kira} sits",
|
|
194
|
+
wiredCharRefs: [charRef()],
|
|
195
|
+
})
|
|
196
|
+
expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
|
|
197
|
+
expect(out.prompt).toContain("Kira waves, then @image_1 sits")
|
|
198
|
+
expectNoRawToken(out.prompt)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("is independent of hybridRoles — same slot, no legend block", () => {
|
|
202
|
+
const out = resolveVideoReferenceCore({
|
|
203
|
+
prompt: "drive {ref:car-1} fast",
|
|
204
|
+
wiredCharRefs: [],
|
|
205
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
206
|
+
hybridRoles: true,
|
|
207
|
+
})
|
|
208
|
+
expect(out.prompt).not.toContain("Use these characters:")
|
|
209
|
+
expect(out.prompt).toContain("drive @image_1 fast")
|
|
210
|
+
expectNoRawToken(out.prompt)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("a prompt with no {ref: token is untouched — `{ref}` and `ref:` are not tokens", () => {
|
|
214
|
+
const out = resolveVideoReferenceCore({
|
|
215
|
+
prompt: "circle {image:1:object} {ref} ref: x",
|
|
216
|
+
wiredCharRefs: [],
|
|
217
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
218
|
+
})
|
|
219
|
+
expect(out.prompt).toContain("circle the object from @image_1 {ref} ref: x")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("an empty id drops to nothing and the keyword is case-insensitive", () => {
|
|
223
|
+
const out = resolveVideoReferenceCore({
|
|
224
|
+
prompt: "x {ref:} {REF:car-1} y",
|
|
225
|
+
wiredCharRefs: [],
|
|
226
|
+
extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
|
|
227
|
+
})
|
|
228
|
+
expect(out.prompt).toContain("x @image_1 y")
|
|
229
|
+
expectNoRawToken(out.prompt)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("imageRefCount: 0 (no image tokens may bind) degrades a seated ref to its name", () => {
|
|
233
|
+
const out = resolveVideoReferenceCore({
|
|
234
|
+
prompt: "{ref:char-kira} walks",
|
|
235
|
+
wiredCharRefs: [charRef()],
|
|
236
|
+
imageRefCount: 0,
|
|
237
|
+
})
|
|
238
|
+
expect(out.prompt).toContain("Kira walks")
|
|
239
|
+
expect(out.prompt).not.toContain("@image_1 walks")
|
|
240
|
+
expectNoRawToken(out.prompt)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("early-return path (no wired chars, no extras): degrades to name / label / nothing", () => {
|
|
244
|
+
const out = resolveVideoReferenceCore({
|
|
245
|
+
prompt: "{ref:a} and {ref:b:the dog} and {ref:c}",
|
|
246
|
+
wiredCharRefs: [],
|
|
247
|
+
leadingRefUrls: [A],
|
|
248
|
+
refNamesById: new Map([["a", "Alpha"]]),
|
|
249
|
+
})
|
|
250
|
+
expect(out.additionalUrls).toEqual([A])
|
|
251
|
+
expect(out.prompt).toBe("Alpha and the dog and")
|
|
252
|
+
expectNoRawToken(out.prompt)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe("resolveRefIdTokens — malformed and adversarial input", () => {
|
|
257
|
+
it("a malformed token (brace inside the id, no closing brace) never ships its `{ref:` prefix", () => {
|
|
258
|
+
const out = resolveRefIdTokens("x {ref:a{b} y {ref:unclosed z", {
|
|
259
|
+
slotById: new Map([["a", 1]]),
|
|
260
|
+
nameById: new Map(),
|
|
261
|
+
imageCount: 1,
|
|
262
|
+
})
|
|
263
|
+
expect(out).not.toMatch(/\{ref:/i)
|
|
264
|
+
// The net is bounded by whitespace/braces: the prose after each run survives.
|
|
265
|
+
expect(out).toContain(" y ")
|
|
266
|
+
expect(out).toContain(" z")
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("scans a prompt at the hard ceiling with an adversarial shape and still resolves (linear matcher)", () => {
|
|
270
|
+
// 30k chars of `{ref:` followed by label-class text with no closing brace —
|
|
271
|
+
// the shape that made a lazy-quantifier matcher quadratic.
|
|
272
|
+
const adversarial = "{ref:" + ":a".repeat(15000)
|
|
273
|
+
const out = resolveRefIdTokens(`${adversarial} end {ref:x}`, {
|
|
274
|
+
slotById: new Map([["x", 1]]),
|
|
275
|
+
nameById: new Map(),
|
|
276
|
+
imageCount: 1,
|
|
277
|
+
})
|
|
278
|
+
expect(out).not.toMatch(/\{ref:/i)
|
|
279
|
+
expect(out).toContain("end @image_1")
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe("resolveRefIdTokens (standalone — the route's no-image-ref early return)", () => {
|
|
284
|
+
it("binds in-range slots, and degrades label → name → nothing otherwise", () => {
|
|
285
|
+
const resolved = resolveRefIdTokens("{ref:x:car} {ref:x} {ref:y:dog} {ref:y} {ref:z}", {
|
|
286
|
+
slotById: new Map([["x", 2], ["y", 4]]),
|
|
287
|
+
nameById: new Map([["y", "Dog"]]),
|
|
288
|
+
imageCount: 3,
|
|
289
|
+
})
|
|
290
|
+
// y is seated at 4 but only 3 images ship → name; z is unknown → nothing.
|
|
291
|
+
expect(resolveReferenceTokens(resolved, { image: 3, video: 0, audio: 0 })).toBe(
|
|
292
|
+
"the car from @image_2 @image_2 dog Dog",
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("returns the prompt untouched when no {ref: token is present", () => {
|
|
297
|
+
const prompt = "plain {image:1} prose"
|
|
298
|
+
expect(resolveRefIdTokens(prompt, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBe(prompt)
|
|
299
|
+
expect(resolveRefIdTokens(undefined, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBeUndefined()
|
|
300
|
+
})
|
|
301
|
+
})
|