@nodaro/shared 2.14.0 → 2.16.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 +94 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -11
- package/dist/index.d.ts +145 -11
- package/dist/index.js +90 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/image-mention-slug.test.ts +303 -0
- package/src/__tests__/prompt-length-limits.test.ts +2 -2
- package/src/__tests__/to-connected-references.test.ts +45 -0
- package/src/__tests__/video-analysis.test.ts +11 -0
- package/src/character-voice.ts +4 -3
- package/src/image-mention-slug.ts +236 -0
- package/src/index.ts +9 -0
- package/src/model-catalog.ts +10 -7
- package/src/model-constants.ts +8 -6
- package/src/model-tree.ts +1 -0
- package/src/to-connected-references.ts +16 -2
- package/src/video-analysis.ts +9 -0
package/package.json
CHANGED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
imageMentionSlug,
|
|
4
|
+
parseImageMentionToken,
|
|
5
|
+
findImageMentionTokens,
|
|
6
|
+
knownImageSlugsFromRefs,
|
|
7
|
+
imageMentionSlugForRef,
|
|
8
|
+
} from "../image-mention-slug.js"
|
|
9
|
+
import type { ConnectedReference } from "../types.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Named-image mentions — `@<name-slug>:<index>[:<role>]`.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors `location-mention-slug.test.ts`, minus everything locations have and
|
|
15
|
+
* media references don't (buckets, variants, usage modes). The load-bearing
|
|
16
|
+
* assertions here are the two that keep this parser from colliding with the
|
|
17
|
+
* other two grammars: a 4-part token is NEVER claimed, and the known-slug set
|
|
18
|
+
* drops grammar-invalid slugs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const media = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
22
|
+
id: "img-1",
|
|
23
|
+
defaultName: "Town",
|
|
24
|
+
source: "wired-image",
|
|
25
|
+
url: "https://cdn/town.png",
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("imageMentionSlug", () => {
|
|
30
|
+
it("lowercases and dash-joins", () => {
|
|
31
|
+
expect(imageMentionSlug("Town")).toBe("town")
|
|
32
|
+
expect(imageMentionSlug("Old Town Square")).toBe("old-town-square")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("collapses punctuation runs and strips leading/trailing dashes", () => {
|
|
36
|
+
expect(imageMentionSlug(" Town -- Square!! ")).toBe("town-square")
|
|
37
|
+
expect(imageMentionSlug("--town--")).toBe("town")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("keeps digits, including a leading one (emptiness is NOT the grammar gate)", () => {
|
|
41
|
+
// Non-empty yet UNPARSEABLE — the leading digit fails IMAGE_SLUG_PATTERN.
|
|
42
|
+
// `knownImageSlugsFromRefs` is what must drop it, not this function.
|
|
43
|
+
expect(imageMentionSlug("3D Render")).toBe("3d-render")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("returns empty for a name with no latin alphanumerics", () => {
|
|
47
|
+
expect(imageMentionSlug("עיר")).toBe("")
|
|
48
|
+
expect(imageMentionSlug("🎬🎬")).toBe("")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is byte-identical to the character/location slug algorithm", () => {
|
|
52
|
+
for (const name of ["Kira", "Old Library", "A B", "Ünï-cödé 12"]) {
|
|
53
|
+
expect(imageMentionSlug(name)).toBe(
|
|
54
|
+
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("parseImageMentionToken — rejects", () => {
|
|
61
|
+
it("rejects text that is not a token at all", () => {
|
|
62
|
+
expect(parseImageMentionToken("town:1")).toBeNull() // no @
|
|
63
|
+
expect(parseImageMentionToken("@")).toBeNull()
|
|
64
|
+
expect(parseImageMentionToken("@town")).toBeNull() // no index segment
|
|
65
|
+
expect(parseImageMentionToken("@town:")).toBeNull() // empty index
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("rejects a non-positive or non-numeric index", () => {
|
|
69
|
+
expect(parseImageMentionToken("@town:0")).toBeNull()
|
|
70
|
+
expect(parseImageMentionToken("@town:x")).toBeNull()
|
|
71
|
+
expect(parseImageMentionToken("@town:1x")).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("rejects out-of-grammar slugs", () => {
|
|
75
|
+
expect(parseImageMentionToken("@Town:1")).toBeNull() // uppercase
|
|
76
|
+
expect(parseImageMentionToken("@3d:1")).toBeNull() // leading digit
|
|
77
|
+
expect(parseImageMentionToken("@-town:1")).toBeNull() // leading dash
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("rejects a FOUR-part token — the collision guard against character mentions", () => {
|
|
81
|
+
expect(parseImageMentionToken("@town:1:a:b")).toBeNull()
|
|
82
|
+
expect(parseImageMentionToken("@kira:1:smile:face")).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("rejects an empty or out-of-grammar role segment", () => {
|
|
86
|
+
expect(parseImageMentionToken("@town:1:")).toBeNull()
|
|
87
|
+
expect(parseImageMentionToken("@town:1:Bad")).toBeNull()
|
|
88
|
+
expect(parseImageMentionToken("@town:1:1st")).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("parseImageMentionToken — accepts", () => {
|
|
93
|
+
it("parses a bare 2-part token and emits NO role key", () => {
|
|
94
|
+
const parsed = parseImageMentionToken("@town:3")
|
|
95
|
+
expect(parsed).toEqual({ imageSlug: "town", imageIndex: 3 })
|
|
96
|
+
// Shape rule: a 2-part token must stay shape-identical to a role-less parser.
|
|
97
|
+
expect(parsed && "role" in parsed).toBe(false)
|
|
98
|
+
expect(parsed && "lock" in parsed).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("parses a curated role in the 3rd segment", () => {
|
|
102
|
+
expect(parseImageMentionToken("@town:3:background")).toEqual({
|
|
103
|
+
imageSlug: "town",
|
|
104
|
+
imageIndex: 3,
|
|
105
|
+
role: "background",
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("passes a CUSTOM role through verbatim", () => {
|
|
110
|
+
expect(parseImageMentionToken("@town:3:my-custom-role")).toEqual({
|
|
111
|
+
imageSlug: "town",
|
|
112
|
+
imageIndex: 3,
|
|
113
|
+
role: "my-custom-role",
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("parses the ~lock / ~nolock sentinels as a tri-state", () => {
|
|
118
|
+
expect(parseImageMentionToken("@town:3~lock")).toEqual({
|
|
119
|
+
imageSlug: "town", imageIndex: 3, lock: true,
|
|
120
|
+
})
|
|
121
|
+
expect(parseImageMentionToken("@town:3~nolock")).toEqual({
|
|
122
|
+
imageSlug: "town", imageIndex: 3, lock: false,
|
|
123
|
+
})
|
|
124
|
+
expect(parseImageMentionToken("@town:3:background~nolock")).toEqual({
|
|
125
|
+
imageSlug: "town", imageIndex: 3, role: "background", lock: false,
|
|
126
|
+
})
|
|
127
|
+
// The role + force-ON pairing — the other half of the role×sentinel matrix.
|
|
128
|
+
expect(parseImageMentionToken("@town:3:background~lock")).toEqual({
|
|
129
|
+
imageSlug: "town", imageIndex: 3, role: "background", lock: true,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("accepts a multi-segment slug and a large index", () => {
|
|
134
|
+
expect(parseImageMentionToken("@old-town-square:12")).toEqual({
|
|
135
|
+
imageSlug: "old-town-square",
|
|
136
|
+
imageIndex: 12,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("findImageMentionTokens", () => {
|
|
142
|
+
it("finds a known slug and reports its exact offset", () => {
|
|
143
|
+
const prompt = "a shot of @town:3 at dusk"
|
|
144
|
+
const [t] = findImageMentionTokens(prompt, ["town"])
|
|
145
|
+
expect(t.token).toBe("@town:3")
|
|
146
|
+
expect(t.imageSlug).toBe("town")
|
|
147
|
+
expect(t.imageIndex).toBe(3)
|
|
148
|
+
expect(prompt.slice(t.offset, t.offset + t.token.length)).toBe("@town:3")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("matches a token at the very start of the prompt", () => {
|
|
152
|
+
const [t] = findImageMentionTokens("@town:1 at dusk", ["town"])
|
|
153
|
+
expect(t.offset).toBe(0)
|
|
154
|
+
expect(t.token).toBe("@town:1")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("filters out slugs that are not known", () => {
|
|
158
|
+
expect(findImageMentionTokens("a shot of @town:3", [])).toEqual([])
|
|
159
|
+
expect(findImageMentionTokens("a shot of @town:3", ["village"])).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("does not match an email-like `a@town:1` (preceding alphanumeric)", () => {
|
|
163
|
+
expect(findImageMentionTokens("mail a@town:1 now", ["town"])).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("yields NO token for a 4-part CHARACTER token, even when the slug is a known image", () => {
|
|
167
|
+
// The `(?![:a-z0-9-])` lookahead: without it this would be captured as the
|
|
168
|
+
// 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt.
|
|
169
|
+
expect(findImageMentionTokens("@kira:1:smile:face poses", ["kira"])).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("does not swallow `~locked` as a sentinel (byte-identical to the location finder)", () => {
|
|
173
|
+
// The sentinel's own `(?![a-z0-9-])` rejects `~locked`, so the regex falls
|
|
174
|
+
// back to the bare `@town:1` and the `~locked` text stays literal — exactly
|
|
175
|
+
// what `findLocationMentionTokens` does for the same input.
|
|
176
|
+
const [t] = findImageMentionTokens("@town:1~locked", ["town"])
|
|
177
|
+
expect(t.token).toBe("@town:1")
|
|
178
|
+
expect("lock" in t).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("does claim the sentinel when it is well-formed", () => {
|
|
182
|
+
const [t] = findImageMentionTokens("@town:1~lock", ["town"])
|
|
183
|
+
expect(t.token).toBe("@town:1~lock")
|
|
184
|
+
expect(t.lock).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("claims a `~nolock` sentinel through the FINDER too (force-OFF, tri-state)", () => {
|
|
188
|
+
const [t] = findImageMentionTokens("a shot of @town:1~nolock at dusk", ["town"])
|
|
189
|
+
expect(t.token).toBe("@town:1~nolock")
|
|
190
|
+
expect(t.lock).toBe(false)
|
|
191
|
+
expect(findImageMentionTokens("@town:1~nolockx", ["town"])[0].token).toBe("@town:1")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("yields NO token for a LOCATION bucket/variant token, even on a known slug", () => {
|
|
195
|
+
// The slash guard. Without it the finder claims the truncated 3-part prefix
|
|
196
|
+
// `@old-library:1:weather` and SPLICES it, leaving `/rain` dangling in the
|
|
197
|
+
// model-facing prompt — a corruption the character/location finders never
|
|
198
|
+
// commit (they leave an unresolvable token literal).
|
|
199
|
+
expect(findImageMentionTokens("a shot of @old-library:1:weather/rain", ["old-library"]))
|
|
200
|
+
.toEqual([])
|
|
201
|
+
// 4-part location token (variant + mode) — same rejection.
|
|
202
|
+
expect(findImageMentionTokens("@old-library:1:weather/rain:style", ["old-library"]))
|
|
203
|
+
.toEqual([])
|
|
204
|
+
// Sentinel + slash: the whole token stays literal rather than backtracking
|
|
205
|
+
// to `@town:1` and splicing that.
|
|
206
|
+
expect(findImageMentionTokens("@town:1~lock/rain", ["town"])).toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("still matches two mentions separated by a slash (`/` alone is not the signal)", () => {
|
|
210
|
+
const tokens = findImageMentionTokens("@town:1/@barn:2", ["town", "barn"])
|
|
211
|
+
expect(tokens.map((t) => t.token)).toEqual(["@town:1", "@barn:2"])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("finds several mentions in prompt order", () => {
|
|
215
|
+
const tokens = findImageMentionTokens("@town:1 then @barn:2:background", ["town", "barn"])
|
|
216
|
+
expect(tokens.map((t) => t.token)).toEqual(["@town:1", "@barn:2:background"])
|
|
217
|
+
expect(tokens[1].role).toBe("background")
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe("knownImageSlugsFromRefs", () => {
|
|
222
|
+
it("includes wired-image and manual media refs", () => {
|
|
223
|
+
expect(
|
|
224
|
+
knownImageSlugsFromRefs([
|
|
225
|
+
media(),
|
|
226
|
+
media({ id: "m", defaultName: "My Upload", source: "manual", url: "https://cdn/u.png" }),
|
|
227
|
+
]),
|
|
228
|
+
).toEqual(["town", "my-upload"])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("excludes every non-media source", () => {
|
|
232
|
+
expect(
|
|
233
|
+
knownImageSlugsFromRefs([
|
|
234
|
+
media({ source: "wired-character", defaultName: "Kira" }),
|
|
235
|
+
media({ source: "wired-location", defaultName: "Old Library" }),
|
|
236
|
+
media({ source: "wired-object", defaultName: "Chair" }),
|
|
237
|
+
media({ source: "wired-creature", defaultName: "Ember" }),
|
|
238
|
+
]),
|
|
239
|
+
).toEqual([])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("excludes extra refs (they render through the extras path)", () => {
|
|
243
|
+
expect(knownImageSlugsFromRefs([media({ isExtraRef: true })])).toEqual([])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("excludes url-less and name-less refs", () => {
|
|
247
|
+
expect(knownImageSlugsFromRefs([media({ url: "" })])).toEqual([])
|
|
248
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "" })])).toEqual([])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("drops a grammar-invalid slug even though it is non-empty", () => {
|
|
252
|
+
// "3D Render" → "3d-render": truthy, but no token can ever match it.
|
|
253
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "3D Render" })])).toEqual([])
|
|
254
|
+
expect(knownImageSlugsFromRefs([media({ defaultName: "🎬" })])).toEqual([])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("dedupes refs that slug to the same name", () => {
|
|
258
|
+
expect(
|
|
259
|
+
knownImageSlugsFromRefs([
|
|
260
|
+
media({ id: "a", defaultName: "Upload Image", url: "https://cdn/a.png" }),
|
|
261
|
+
media({ id: "b", defaultName: "Upload Image", url: "https://cdn/b.png" }),
|
|
262
|
+
]),
|
|
263
|
+
).toEqual(["upload-image"])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("is the exact gate the finder uses (no slug in the set is unmatchable)", () => {
|
|
267
|
+
const refs = [media(), media({ id: "b", defaultName: "3D Render", url: "https://cdn/b.png" })]
|
|
268
|
+
const slugs = knownImageSlugsFromRefs(refs)
|
|
269
|
+
for (const slug of slugs) {
|
|
270
|
+
expect(findImageMentionTokens(`@${slug}:1`, slugs)).toHaveLength(1)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe("imageMentionSlugForRef — the single mentionability gate", () => {
|
|
276
|
+
it("returns the slug for a mentionable media ref", () => {
|
|
277
|
+
expect(imageMentionSlugForRef(media())).toBe("town")
|
|
278
|
+
expect(imageMentionSlugForRef(media({ source: "manual", defaultName: "My Upload" })))
|
|
279
|
+
.toBe("my-upload")
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("returns null for every non-mentionable ref", () => {
|
|
283
|
+
expect(imageMentionSlugForRef(media({ source: "wired-character" }))).toBeNull()
|
|
284
|
+
expect(imageMentionSlugForRef(media({ isExtraRef: true }))).toBeNull()
|
|
285
|
+
expect(imageMentionSlugForRef(media({ url: "" }))).toBeNull()
|
|
286
|
+
expect(imageMentionSlugForRef(media({ defaultName: "" }))).toBeNull()
|
|
287
|
+
// Non-empty but grammar-invalid — emptiness is NOT the gate.
|
|
288
|
+
expect(imageMentionSlugForRef(media({ defaultName: "3D Render" }))).toBeNull()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("is the SAME gate `knownImageSlugsFromRefs` applies (the two views cannot drift)", () => {
|
|
292
|
+
const refs = [
|
|
293
|
+
media(),
|
|
294
|
+
media({ id: "b", defaultName: "3D Render", url: "https://cdn/b.png" }),
|
|
295
|
+
media({ id: "c", defaultName: "Extra", isExtraRef: true, url: "https://cdn/c.png" }),
|
|
296
|
+
media({ id: "d", source: "wired-location", defaultName: "Old Library", url: "https://cdn/d.png" }),
|
|
297
|
+
media({ id: "e", source: "manual", defaultName: "My Upload", url: "https://cdn/e.png" }),
|
|
298
|
+
]
|
|
299
|
+
expect(knownImageSlugsFromRefs(refs)).toEqual(
|
|
300
|
+
[...new Set(refs.map(imageMentionSlugForRef).filter((s): s is string => s !== null))],
|
|
301
|
+
)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
@@ -92,8 +92,8 @@ describe("per-model prompt length limits", () => {
|
|
|
92
92
|
it("returns verified per-model TTS caps", () => {
|
|
93
93
|
expect(getMaxTtsChars("elevenlabs-turbo")).toBe(40000)
|
|
94
94
|
expect(getMaxTtsChars("elevenlabs-multilingual")).toBe(10000)
|
|
95
|
-
expect(getMaxTtsChars("elevenlabs-v3")).toBe(
|
|
96
|
-
expect(getMaxTtsChars("elevenlabs-dialogue")).toBe(
|
|
95
|
+
expect(getMaxTtsChars("elevenlabs-v3")).toBe(5000) // official cap (probed 2026-08-30)
|
|
96
|
+
expect(getMaxTtsChars("elevenlabs-dialogue")).toBe(5000) // total across lines
|
|
97
97
|
})
|
|
98
98
|
it("defaults to TTS_TEXT_MAX for the legacy/unknown provider", () => {
|
|
99
99
|
expect(getMaxTtsChars("elevenlabs")).toBe(TTS_TEXT_MAX)
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"
|
|
|
2
2
|
|
|
3
3
|
import { characterMentionSlug } from "../character-mention-slug.js"
|
|
4
4
|
import { locationMentionSlug } from "../location-mention-slug.js"
|
|
5
|
+
import { imageMentionSlug, knownImageSlugsFromRefs } from "../image-mention-slug.js"
|
|
5
6
|
import {
|
|
6
7
|
toConnectedReference,
|
|
7
8
|
toConnectedReferences,
|
|
@@ -109,6 +110,50 @@ describe("toConnectedReference", () => {
|
|
|
109
110
|
).toBe(false)
|
|
110
111
|
})
|
|
111
112
|
|
|
113
|
+
it("maps an image binding to a wired-image reference (no slug field — derived at prompt time)", () => {
|
|
114
|
+
expect(
|
|
115
|
+
toConnectedReference({
|
|
116
|
+
id: "img-1",
|
|
117
|
+
kind: "image",
|
|
118
|
+
name: "Town",
|
|
119
|
+
url: "https://r2.example/town.png",
|
|
120
|
+
}),
|
|
121
|
+
).toEqual({
|
|
122
|
+
id: "img-1",
|
|
123
|
+
defaultName: "Town",
|
|
124
|
+
source: "wired-image",
|
|
125
|
+
url: "https://r2.example/town.png",
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("keeps the image name addressable by the derived mention slug", () => {
|
|
130
|
+
const ref = toConnectedReference({
|
|
131
|
+
id: "img-1",
|
|
132
|
+
kind: "image",
|
|
133
|
+
name: "Old Town Square",
|
|
134
|
+
url: "https://r2.example/town.png",
|
|
135
|
+
})
|
|
136
|
+
// There is no `imageSlug` on the wire — `knownImageSlugsFromRefs` derives it
|
|
137
|
+
// from `defaultName`, so this ref answers to `@old-town-square:N`.
|
|
138
|
+
expect(knownImageSlugsFromRefs([ref])).toEqual([imageMentionSlug("Old Town Square")])
|
|
139
|
+
expect(knownImageSlugsFromRefs([ref])).toEqual(["old-town-square"])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("folds an image description into the reference; absent adds no key", () => {
|
|
143
|
+
expect(
|
|
144
|
+
toConnectedReference({
|
|
145
|
+
id: "img-1",
|
|
146
|
+
kind: "image",
|
|
147
|
+
name: "Town",
|
|
148
|
+
url: "https://r2.example/town.png",
|
|
149
|
+
description: "a quiet town square at dusk",
|
|
150
|
+
}).description,
|
|
151
|
+
).toBe("a quiet town square at dusk")
|
|
152
|
+
expect(
|
|
153
|
+
"description" in toConnectedReference({ id: "img-2", kind: "image", name: "Barn" }),
|
|
154
|
+
).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
112
157
|
it("falls back to a placeholder-safe empty url when the entity has no thumbnail", () => {
|
|
113
158
|
expect(
|
|
114
159
|
toConnectedReference({
|
|
@@ -287,6 +287,17 @@ describe("cinematography fields (angle / speed / onScreenText / look)", () => {
|
|
|
287
287
|
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene }] }).success).toBe(true)
|
|
288
288
|
})
|
|
289
289
|
|
|
290
|
+
it("round 4: a scene carries a free-text `transition` beside the legacy enum", () => {
|
|
291
|
+
// F.8: the analyser describes the edit in its own words; the enum is legacy.
|
|
292
|
+
const ok = windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transition: "fast blurred pan left" }] })
|
|
293
|
+
expect(ok.success).toBe(true)
|
|
294
|
+
expect(ok.success && ok.data.scenes[0]!.transition).toBe("fast blurred pan left")
|
|
295
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transition: "" }] }).success).toBe(false)
|
|
296
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transition: "x".repeat(121) }] }).success).toBe(false)
|
|
297
|
+
// legacy blueprints still parse
|
|
298
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transitionOut: "whip" }] }).success).toBe(true)
|
|
299
|
+
})
|
|
300
|
+
|
|
290
301
|
it("marks the viewpoints where no face is visible — auto-cast reads this", () => {
|
|
291
302
|
// A reference frame shot from behind cannot cast a face, however well framed.
|
|
292
303
|
expect([...VIDEO_ANALYSIS_FACELESS_ANGLES].sort()).toEqual(["from-behind", "over-the-shoulder"])
|
package/src/character-voice.ts
CHANGED
|
@@ -40,9 +40,10 @@ export interface DialogueLine {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* A dialogue line resolved to a concrete voice,
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* A dialogue line resolved to a concrete voice: `{ text, voice }` per line, in
|
|
44
|
+
* order. `voice` is OUR identifier (premade name or library/custom UUID) — the
|
|
45
|
+
* direct ElevenLabs Dialogue call resolves it per line to the `voice_id` the
|
|
46
|
+
* wire shape (`inputs[]`) wants. `voiceType` rides along so the TTS layer
|
|
46
47
|
* resolves premade-by-name vs library/custom-by-id correctly.
|
|
47
48
|
*/
|
|
48
49
|
export interface ResolvedDialogueVoiceLine {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-image `@-mention` parser — `@<name-slug>:<index>[:<role>]`.
|
|
3
|
+
*
|
|
4
|
+
* The media analog of `character-mention-slug.ts` / `location-mention-slug.ts`,
|
|
5
|
+
* for a wired image (`wired-image` / `manual` reference) addressed by the slug of
|
|
6
|
+
* its NAME: an upload node's label on the canvas, or the name a thin client puts
|
|
7
|
+
* on the reference. The grammar is the SIMPLEST of the three — 2 or 3 segments,
|
|
8
|
+
* no buckets, no variants, no usage-mode enum — because a media reference has no
|
|
9
|
+
* variant array to select from and no identity mode to override. Every valid 3rd
|
|
10
|
+
* segment is a ROLE.
|
|
11
|
+
*
|
|
12
|
+
* @town:3 — bare mention; renders the reference's binding
|
|
13
|
+
* ("reference image C") at the typed position
|
|
14
|
+
* @town:3:background — role phrase ("the background from reference image C")
|
|
15
|
+
* @town:3:my-custom-role — custom roles pass through verbatim
|
|
16
|
+
* @town:3~lock — additive identity-lock sentinel (also `~nolock`)
|
|
17
|
+
* @town:1:a:b — NULL. A 4-part token is never an image mention.
|
|
18
|
+
*
|
|
19
|
+
* NO WIRE FIELD. Unlike `characterSlug` / `locationSlug`, there is no `imageSlug`
|
|
20
|
+
* on `ConnectedReference`: the slug is DERIVED from `defaultName` at resolve time
|
|
21
|
+
* (`knownImageSlugsFromRefs`), so a client cannot drift from the grammar and the
|
|
22
|
+
* reference schema is untouched.
|
|
23
|
+
*
|
|
24
|
+
* NO LEGACY RESOLVER. Only the hybrid reference format resolves these tokens;
|
|
25
|
+
* under the legacy format an `@name:N` token stays literal text and the
|
|
26
|
+
* reference auto-attaches exactly as it does today.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ConnectedReference } from "./types.js"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Grammar-valid slug shape — the exact shape `findImageMentionTokens`' regex can
|
|
33
|
+
* produce, and therefore the gate on BOTH sides of the match.
|
|
34
|
+
*
|
|
35
|
+
* Emptiness is NOT the gate: `imageMentionSlug("3D Render")` → `"3d-render"` is
|
|
36
|
+
* non-empty yet UNPARSEABLE (a leading digit), so a ref named "3D Render" must be
|
|
37
|
+
* dropped from the known-slug set even though its slug is truthy. This pattern is
|
|
38
|
+
* what drops it.
|
|
39
|
+
*/
|
|
40
|
+
const IMAGE_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Slugify an image reference's display name for `@`-mention tokens. Byte-
|
|
44
|
+
* identical algorithm to `characterMentionSlug` / `locationMentionSlug`; kept as
|
|
45
|
+
* a separate export to make the call site's intent explicit and to allow future
|
|
46
|
+
* divergence.
|
|
47
|
+
*/
|
|
48
|
+
export function imageMentionSlug(name: string): string {
|
|
49
|
+
return name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/-+/g, "-")
|
|
53
|
+
.replace(/^-|-$/g, "")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ImageMentionTokenInfo {
|
|
57
|
+
/** The matched token text, verbatim — spliced out of the prompt at resolve time. */
|
|
58
|
+
readonly token: string
|
|
59
|
+
readonly imageSlug: string
|
|
60
|
+
/**
|
|
61
|
+
* 1-based correlation index assigned at insertion by the autocomplete
|
|
62
|
+
* (`nextMentionIndex` = max(existing N) + 1, unified across characters,
|
|
63
|
+
* locations and images). The hybrid resolver binds by its own numbering walk,
|
|
64
|
+
* so the index is CORRELATION ONLY — it is never echoed into the prompt.
|
|
65
|
+
*/
|
|
66
|
+
readonly imageIndex: number
|
|
67
|
+
/**
|
|
68
|
+
* Per-mention ROLE from the 3rd segment (`@town:3:background`) — curated
|
|
69
|
+
* (`REFERENCE_ROLE_PRESETS["wired-image"]`) or custom, stored VERBATIM. Media
|
|
70
|
+
* role presets are all single-word, so this never needs `normalizeRoleSlug`
|
|
71
|
+
* (the location-only remapping for multi-word presets). OMITTED (undefined,
|
|
72
|
+
* never null) for 2-part tokens, so those stay shape-identical to a parser
|
|
73
|
+
* with no role support.
|
|
74
|
+
*/
|
|
75
|
+
readonly role?: string
|
|
76
|
+
/**
|
|
77
|
+
* Additive `~lock` / `~nolock` sentinel. Tri-state: `true` (force ON) |
|
|
78
|
+
* `false` (force OFF, suppressing a ref-level `identityLock.enabled`) |
|
|
79
|
+
* ABSENT/undefined (inherit the ref default). Honored only by the hybrid
|
|
80
|
+
* resolver — there is no legacy image resolver to make it inert on.
|
|
81
|
+
*/
|
|
82
|
+
readonly lock?: boolean
|
|
83
|
+
/** Byte offset into the source prompt — used to splice the token out. */
|
|
84
|
+
readonly offset: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a single `@<name-slug>:<index>[:<role>]` token. Returns null when the
|
|
89
|
+
* token doesn't match a supported shape (the caller falls back to literal text).
|
|
90
|
+
*
|
|
91
|
+
* Segment count is 2 or 3 — NOT 2–4 like the character/location parsers. A media
|
|
92
|
+
* reference has no variant/bucket slot, so there is nothing for a 4th segment to
|
|
93
|
+
* mean, and claiming one would let this parser swallow a character token.
|
|
94
|
+
*/
|
|
95
|
+
export function parseImageMentionToken(text: string): {
|
|
96
|
+
imageSlug: string
|
|
97
|
+
imageIndex: number
|
|
98
|
+
/** Present ONLY for a 3-part token; omitted otherwise (shape rule). */
|
|
99
|
+
role?: string
|
|
100
|
+
/** Present ONLY when a sentinel was found; omitted otherwise (shape rule). */
|
|
101
|
+
lock?: boolean
|
|
102
|
+
} | null {
|
|
103
|
+
if (!text.startsWith("@")) return null
|
|
104
|
+
let rest = text.slice(1)
|
|
105
|
+
if (rest.length === 0 || !/^[a-z]/.test(rest)) return null
|
|
106
|
+
|
|
107
|
+
// Strip a trailing `~nolock` (force OFF) or `~lock` (force ON) BEFORE splitting
|
|
108
|
+
// so the segment grammar is untouched (a `~` never appears inside a segment).
|
|
109
|
+
// Check `~nolock` FIRST — `~lock` is its suffix. A token with NEITHER sentinel
|
|
110
|
+
// gains NO `lock` key.
|
|
111
|
+
let lockField: { lock?: boolean } = {}
|
|
112
|
+
if (rest.endsWith("~nolock")) {
|
|
113
|
+
rest = rest.slice(0, -"~nolock".length)
|
|
114
|
+
lockField = { lock: false }
|
|
115
|
+
} else if (rest.endsWith("~lock")) {
|
|
116
|
+
rest = rest.slice(0, -"~lock".length)
|
|
117
|
+
lockField = { lock: true }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parts = rest.split(":")
|
|
121
|
+
if (parts.length < 2 || parts.length > 3) return null
|
|
122
|
+
|
|
123
|
+
const [imageSlug, indexStr, third] = parts
|
|
124
|
+
if (!IMAGE_SLUG_PATTERN.test(imageSlug)) return null
|
|
125
|
+
if (!/^\d+$/.test(indexStr)) return null
|
|
126
|
+
const imageIndex = parseInt(indexStr, 10)
|
|
127
|
+
if (!Number.isInteger(imageIndex) || imageIndex < 1) return null
|
|
128
|
+
|
|
129
|
+
if (parts.length === 2) return { imageSlug, imageIndex, ...lockField }
|
|
130
|
+
if (!IMAGE_SLUG_PATTERN.test(third)) return null
|
|
131
|
+
return { imageSlug, imageIndex, role: third, ...lockField }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find every image `@-mention` in a prompt whose slug is a known image slug.
|
|
136
|
+
*
|
|
137
|
+
* `knownImageSlugs` (from `knownImageSlugsFromRefs`) is what keeps this parser
|
|
138
|
+
* off the other two grammars' tokens — all three finders match the same
|
|
139
|
+
* `@slug:N…` surface and only the known-slug set separates them.
|
|
140
|
+
*/
|
|
141
|
+
export function findImageMentionTokens(
|
|
142
|
+
prompt: string,
|
|
143
|
+
knownImageSlugs: readonly string[],
|
|
144
|
+
): ImageMentionTokenInfo[] {
|
|
145
|
+
const tokens: ImageMentionTokenInfo[] = []
|
|
146
|
+
// ONE optional segment (the role) — images have no variant/bucket slot.
|
|
147
|
+
//
|
|
148
|
+
// The trailing `(?![:a-z0-9-])` is the DELIBERATE divergence from the character
|
|
149
|
+
// and location finders. Without it, a 4-part CHARACTER token that the character
|
|
150
|
+
// pass failed to resolve (`@kira:1:smile:face`) would be captured here as the
|
|
151
|
+
// 3-part `@kira:1:smile`, leaving `:face` dangling in the prompt. The lookahead
|
|
152
|
+
// makes the regex backtrack and match nothing, so a 4-part token is NEVER an
|
|
153
|
+
// image mention. `~lock` still matches (`~` is outside the class), and its own
|
|
154
|
+
// `(?![a-z0-9-])` keeps `~locked` / `~nolockx` literal.
|
|
155
|
+
//
|
|
156
|
+
// Linear-scan shape (a fixed prefix then bounded optional groups, no nested
|
|
157
|
+
// quantifiers) — matching the sibling finders, and ReDoS-free.
|
|
158
|
+
const regex =
|
|
159
|
+
/(?:^|[^a-zA-Z0-9])(@[a-z][a-z0-9-]*:\d+(?::[a-z][a-z0-9-]*)?(?:~(?:no)?lock(?![a-z0-9-]))?)(?![:a-z0-9-])/g
|
|
160
|
+
const knownSet = new Set(knownImageSlugs)
|
|
161
|
+
for (const match of prompt.matchAll(regex)) {
|
|
162
|
+
const token = match[1]
|
|
163
|
+
const offset = (match.index ?? 0) + (match[0].length - token.length)
|
|
164
|
+
// SLASH GUARD — the second half of the collision guard, and the reason it
|
|
165
|
+
// is a post-match check instead of another lookahead in the regex. `/` is
|
|
166
|
+
// the LOCATION grammar's bucket/variant separator (`@lib:1:weather/rain`),
|
|
167
|
+
// so a token immediately followed by `/<segment>` is a sibling-grammar
|
|
168
|
+
// token, never an image mention. A lookahead cannot express this: the
|
|
169
|
+
// engine would just BACKTRACK to a shorter prefix (`@lib:1:weather` →
|
|
170
|
+
// `@lib:1`, or `@town:1~lock` → `@town:1`) and splice THAT, which is the
|
|
171
|
+
// very corruption being prevented. Rejecting the whole match here leaves
|
|
172
|
+
// the token literal, exactly as the character/location finders do.
|
|
173
|
+
//
|
|
174
|
+
// `/` alone is NOT the signal — `@town:1/@barn:2` (two mentions separated
|
|
175
|
+
// by a slash) must keep matching, and a location segment always starts
|
|
176
|
+
// `[a-z]`. So the guard is `/` + a segment start.
|
|
177
|
+
if (/^\/[a-z]/.test(prompt.slice(offset + token.length))) continue
|
|
178
|
+
const parsed = parseImageMentionToken(token)
|
|
179
|
+
if (parsed && knownSet.has(parsed.imageSlug)) {
|
|
180
|
+
tokens.push({ token, ...parsed, offset })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return tokens
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The mention slug a single reference contributes, or `null` when the ref
|
|
188
|
+
* cannot carry a mention at all — the SINGLE gate, so every view of "which
|
|
189
|
+
* refs are mentionable" is the same view.
|
|
190
|
+
*
|
|
191
|
+
* Shared by `knownImageSlugsFromRefs` (the finder's known-slug set) and the
|
|
192
|
+
* prompt-builder's hybrid resolver (its slug → ref lookup map). Those two must
|
|
193
|
+
* admit exactly the same refs: a slug the finder accepts but the resolver drops
|
|
194
|
+
* would splice a token with nothing to bind, and a ref the resolver keys under
|
|
195
|
+
* a slug no token can match is dead weight. Emptiness is NOT the gate (see
|
|
196
|
+
* `IMAGE_SLUG_PATTERN`).
|
|
197
|
+
*/
|
|
198
|
+
export function imageMentionSlugForRef(r: ConnectedReference): string | null {
|
|
199
|
+
if (r.source !== "wired-image" && r.source !== "manual") return null
|
|
200
|
+
if (r.isExtraRef === true) return null
|
|
201
|
+
if (!r.url || !r.defaultName) return null
|
|
202
|
+
const slug = imageMentionSlug(r.defaultName)
|
|
203
|
+
return IMAGE_SLUG_PATTERN.test(slug) ? slug : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The known-image-slug set for a reference list — the SINGLE source of truth for
|
|
208
|
+
* the derivation, shared by `buildImagePrompt`'s Phase 0 and the backend
|
|
209
|
+
* orchestrator's structured-branch gate so the two can never disagree about
|
|
210
|
+
* whether a prompt carries a resolvable image mention.
|
|
211
|
+
*
|
|
212
|
+
* Only MEDIA refs (`wired-image` / `manual`) with a URL participate — the other
|
|
213
|
+
* sources have their own mention grammars (characters, locations) or no mention
|
|
214
|
+
* path at all (objects, creatures).
|
|
215
|
+
*
|
|
216
|
+
* `isExtraRef` refs are EXCLUDED: an extra renders through the extras path with
|
|
217
|
+
* its own body line, so letting a mention also bind one would double-emit prose.
|
|
218
|
+
*
|
|
219
|
+
* Grammar-invalid slugs are DROPPED (see `IMAGE_SLUG_PATTERN`) — a ref named
|
|
220
|
+
* "3D Render" slugs to the non-empty but unparseable `"3d-render"`, and admitting
|
|
221
|
+
* it would put a slug in the set that no token can ever match.
|
|
222
|
+
*
|
|
223
|
+
* All four of those gates live in `imageMentionSlugForRef`, which the hybrid
|
|
224
|
+
* resolver's own lookup map uses too — one predicate, so the two views cannot
|
|
225
|
+
* drift apart.
|
|
226
|
+
*/
|
|
227
|
+
export function knownImageSlugsFromRefs(
|
|
228
|
+
refs: readonly ConnectedReference[],
|
|
229
|
+
): string[] {
|
|
230
|
+
const out = new Set<string>()
|
|
231
|
+
for (const r of refs) {
|
|
232
|
+
const slug = imageMentionSlugForRef(r)
|
|
233
|
+
if (slug) out.add(slug)
|
|
234
|
+
}
|
|
235
|
+
return [...out]
|
|
236
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -809,6 +809,15 @@ export type {
|
|
|
809
809
|
LocationMentionTokenInfo,
|
|
810
810
|
} from "./location-mention-slug.js"
|
|
811
811
|
|
|
812
|
+
export {
|
|
813
|
+
imageMentionSlug,
|
|
814
|
+
parseImageMentionToken,
|
|
815
|
+
findImageMentionTokens,
|
|
816
|
+
knownImageSlugsFromRefs,
|
|
817
|
+
imageMentionSlugForRef,
|
|
818
|
+
} from "./image-mention-slug.js"
|
|
819
|
+
export type { ImageMentionTokenInfo } from "./image-mention-slug.js"
|
|
820
|
+
|
|
812
821
|
export {
|
|
813
822
|
toConnectedReference,
|
|
814
823
|
toConnectedReferences,
|