@nodaro/shared 1.4.2 → 1.6.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 +61 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1368 -5033
- package/dist/index.d.ts +1368 -5033
- package/dist/index.js +58 -12
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__tests__/character-mention-slug.test.ts +53 -8
- package/src/__tests__/character-picker-display-order.test.ts +68 -0
- package/src/__tests__/entity-image-handle.test.ts +22 -1
- package/src/__tests__/location-mention-slug.test.ts +32 -3
- package/src/__tests__/pipeline-chat.test.ts +2 -2
- package/src/__tests__/reference-roles.test.ts +21 -1
- package/src/character-mention-slug.ts +27 -9
- package/src/character-variant-assets.ts +61 -0
- package/src/entity-asset-types.ts +3 -0
- package/src/entity-image-handle.ts +22 -0
- package/src/index.ts +3 -0
- package/src/location-mention-slug.ts +17 -8
- package/src/pipeline-state-types.ts +1 -1
- package/src/reduce-strategy-registry.ts +2 -2
- package/src/reference-roles.ts +6 -2
- package/src/scene-node-types.ts +1 -1
- package/src/types.ts +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,14 +39,13 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"fast-json-patch": "^3.1.1",
|
|
42
|
-
"zod": "^
|
|
42
|
+
"zod": "^4.4.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.2",
|
|
46
46
|
"tsup": "^8.5.0",
|
|
47
47
|
"typescript": "^5.7.3",
|
|
48
|
-
"vitest": "^4.1.2"
|
|
49
|
-
"zod-to-json-schema": "^3.25.2"
|
|
48
|
+
"vitest": "^4.1.2"
|
|
50
49
|
},
|
|
51
50
|
"engines": {
|
|
52
51
|
"node": ">=22"
|
|
@@ -136,12 +136,53 @@ describe("parseCharacterMentionToken", () => {
|
|
|
136
136
|
})
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
// Variant + Role Separation: the 4th segment is no longer mode-only. A
|
|
140
|
+
// non-mode slug parses as a per-mention ROLE (curated or custom) that
|
|
141
|
+
// COEXISTS with the variant — the variant picks the image, the role picks
|
|
142
|
+
// the phrase. The `role` key is OMITTED (not null) for every other shape so
|
|
143
|
+
// pre-existing parses stay byte-identical.
|
|
144
|
+
it("4-part: @kira:1:walking:clothes — variant + non-mode ROLE coexist", () => {
|
|
145
|
+
expect(parseCharacterMentionToken("@kira:1:walking:clothes")).toEqual({
|
|
146
|
+
characterSlug: "kira",
|
|
147
|
+
imageIndex: 1,
|
|
148
|
+
variantSlug: "walking",
|
|
149
|
+
usageMode: null,
|
|
150
|
+
role: "clothes",
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("4-part: @kira:1:smile:invalid — a custom 4th-segment slug parses as a role (was rejected)", () => {
|
|
155
|
+
expect(parseCharacterMentionToken("@kira:1:smile:invalid")).toEqual({
|
|
156
|
+
characterSlug: "kira",
|
|
157
|
+
imageIndex: 1,
|
|
158
|
+
variantSlug: "smile",
|
|
159
|
+
usageMode: null,
|
|
160
|
+
role: "invalid",
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("4-part role + lock sentinel compose: @kira:1:walking:clothes~lock", () => {
|
|
165
|
+
expect(parseCharacterMentionToken("@kira:1:walking:clothes~lock")).toEqual({
|
|
166
|
+
characterSlug: "kira",
|
|
167
|
+
imageIndex: 1,
|
|
168
|
+
variantSlug: "walking",
|
|
169
|
+
usageMode: null,
|
|
170
|
+
role: "clothes",
|
|
171
|
+
lock: true,
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("4-part mode still routes to usageMode, NOT role (back-compat)", () => {
|
|
176
|
+
const parsed = parseCharacterMentionToken("@kira:1:smile:face")
|
|
177
|
+
expect(parsed?.usageMode).toBe("face")
|
|
178
|
+
expect(parsed && "role" in parsed).toBe(false)
|
|
141
179
|
})
|
|
142
180
|
|
|
143
|
-
it("
|
|
144
|
-
|
|
181
|
+
it("findCharacterMentionTokens surfaces the 4-part role token", () => {
|
|
182
|
+
const tokens = findCharacterMentionTokens("a shot of @kira:1:walking:clothes at dawn", ["kira"])
|
|
183
|
+
expect(tokens).toHaveLength(1)
|
|
184
|
+
expect(tokens[0].variantSlug).toBe("walking")
|
|
185
|
+
expect(tokens[0].role).toBe("clothes")
|
|
145
186
|
})
|
|
146
187
|
|
|
147
188
|
it("rejects 5-part tokens", () => {
|
|
@@ -191,9 +232,13 @@ describe("findCharacterMentionTokens", () => {
|
|
|
191
232
|
it("does not parse a dash-form as a variant token", () => {
|
|
192
233
|
expect(findCharacterMentionTokens("@kira-smile waves", ["kira"])).toEqual([])
|
|
193
234
|
})
|
|
194
|
-
it("4-part with
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
235
|
+
it("4-part with a non-mode 4th segment parses as variant + ROLE (Variant + Role Separation)", () => {
|
|
236
|
+
// Previously this was rejected outright; now the non-mode segment is a
|
|
237
|
+
// per-mention role coexisting with the variant.
|
|
238
|
+
const tokens = findCharacterMentionTokens("@kira:1:smile:bogus waves", ["kira"])
|
|
239
|
+
expect(tokens).toHaveLength(1)
|
|
240
|
+
expect(tokens[0].variantSlug).toBe("smile")
|
|
241
|
+
expect(tokens[0].role).toBe("bogus")
|
|
242
|
+
expect(tokens[0].usageMode).toBeNull()
|
|
198
243
|
})
|
|
199
244
|
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
CHARACTER_PICKER_DISPLAY_ORDER,
|
|
4
|
+
characterBucketDisplayRank,
|
|
5
|
+
characterMentionableAssetArrays,
|
|
6
|
+
sortCharacterEntriesForDisplay,
|
|
7
|
+
} from "../character-variant-assets"
|
|
8
|
+
import { CHARACTER_ATTACH_COLUMNS } from "../entity-asset-types"
|
|
9
|
+
|
|
10
|
+
describe("CHARACTER_PICKER_DISPLAY_ORDER", () => {
|
|
11
|
+
it("covers exactly the keys of characterMentionableAssetArrays (drift guard)", () => {
|
|
12
|
+
// If a new bucket is added to the data record without a display rank (or
|
|
13
|
+
// vice versa), pickers would silently drop/misplace it. Fail loudly here.
|
|
14
|
+
const dataKeys = Object.keys(characterMentionableAssetArrays({})).sort()
|
|
15
|
+
const displayKeys = [...CHARACTER_PICKER_DISPLAY_ORDER].sort()
|
|
16
|
+
expect(displayKeys).toEqual(dataKeys)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("pins boards first, sheets second", () => {
|
|
20
|
+
expect(CHARACTER_PICKER_DISPLAY_ORDER[0]).toBe("boards")
|
|
21
|
+
expect(CHARACTER_PICKER_DISPLAY_ORDER[1]).toBe("sheets")
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("characterBucketDisplayRank", () => {
|
|
26
|
+
it("ranks canonical (no bucket) before boards, boards before variant buckets", () => {
|
|
27
|
+
expect(characterBucketDisplayRank(undefined)).toBeLessThan(characterBucketDisplayRank("boards"))
|
|
28
|
+
expect(characterBucketDisplayRank("boards")).toBeLessThan(characterBucketDisplayRank("expressions"))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("puts unknown buckets last", () => {
|
|
32
|
+
expect(characterBucketDisplayRank("someFutureBucket")).toBeGreaterThan(
|
|
33
|
+
characterBucketDisplayRank("detailCloseups"),
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("sortCharacterEntriesForDisplay", () => {
|
|
39
|
+
it("reorders within a character run (boards first) and keeps non-character items in place", () => {
|
|
40
|
+
const items = [
|
|
41
|
+
{ characterSlug: undefined, url: "upload1" },
|
|
42
|
+
{ characterSlug: "kira", bucket: undefined, url: "canonical" },
|
|
43
|
+
{ characterSlug: "kira", bucket: "expressions", url: "smile" },
|
|
44
|
+
{ characterSlug: "kira", bucket: "boards", url: "board1" },
|
|
45
|
+
{ characterSlug: undefined, url: "upload2" },
|
|
46
|
+
{ characterSlug: "bob", bucket: "poses", url: "bobpose" },
|
|
47
|
+
{ characterSlug: "bob", bucket: "boards", url: "bobboard" },
|
|
48
|
+
]
|
|
49
|
+
const sorted = sortCharacterEntriesForDisplay(items)
|
|
50
|
+
expect(sorted.map((i) => i.url)).toEqual([
|
|
51
|
+
"upload1", "canonical", "board1", "smile", "upload2", "bobboard", "bobpose",
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("is stable within the same bucket", () => {
|
|
56
|
+
const items = [
|
|
57
|
+
{ characterSlug: "kira", bucket: "boards", url: "b1" },
|
|
58
|
+
{ characterSlug: "kira", bucket: "boards", url: "b2" },
|
|
59
|
+
]
|
|
60
|
+
expect(sortCharacterEntriesForDisplay(items).map((i) => i.url)).toEqual(["b1", "b2"])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("CHARACTER_ATTACH_COLUMNS", () => {
|
|
65
|
+
it("includes boards (worker auto-attach whitelist, mirrors migration 250)", () => {
|
|
66
|
+
expect(CHARACTER_ATTACH_COLUMNS).toContain("boards")
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES } from "../entity-image-handle.js"
|
|
2
|
+
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, sourceRefKey } from "../entity-image-handle.js"
|
|
3
3
|
|
|
4
4
|
const ENTITY_REF_HANDLE: Record<string, string> = {
|
|
5
5
|
character: "characterRef",
|
|
@@ -41,3 +41,24 @@ describe("resolveEffectiveSourceType (entity image handle → upload-image)", ()
|
|
|
41
41
|
)
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
describe("sourceRefKey (handle-scoped ref key — prevents node-id collision)", () => {
|
|
46
|
+
it("scopes an entity's image handle to `${id}::image`", () => {
|
|
47
|
+
for (const entity of ENTITY_IMAGE_HANDLE_TYPES) {
|
|
48
|
+
expect(sourceRefKey("n1", "image", entity)).toBe("n1::image")
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("keeps the bare node id for the identity handle so the two refs stay distinct", () => {
|
|
53
|
+
expect(sourceRefKey("n1", "characterRef", "character")).toBe("n1")
|
|
54
|
+
// Identity vs image handle of the SAME node → distinct keys (the fix).
|
|
55
|
+
expect(sourceRefKey("n1", "characterRef", "character"))
|
|
56
|
+
.not.toBe(sourceRefKey("n1", "image", "character"))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("leaves non-entity image producers and the no-handle case on the bare node id", () => {
|
|
60
|
+
expect(sourceRefKey("n1", "image", "generate-image")).toBe("n1")
|
|
61
|
+
expect(sourceRefKey("n1", "image", "upload-image")).toBe("n1")
|
|
62
|
+
expect(sourceRefKey("n1", undefined, "character")).toBe("n1")
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -89,10 +89,39 @@ describe("parseLocationMentionToken", () => {
|
|
|
89
89
|
expect(parseLocationMentionToken("@oldlibrary:1:weather:style")).toBeNull()
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
it("
|
|
92
|
+
it("4-part with a non-mode 4th segment parses as bucket/variant + ROLE (Variant + Role Separation)", () => {
|
|
93
|
+
// Previously rejected; now the non-mode segment is a per-mention role
|
|
94
|
+
// that coexists with the variant — the variant picks the image, the
|
|
95
|
+
// role picks the phrase.
|
|
93
96
|
expect(
|
|
94
|
-
parseLocationMentionToken("@oldlibrary:1:weather/rain:
|
|
95
|
-
).
|
|
97
|
+
parseLocationMentionToken("@oldlibrary:1:weather/rain:lighting"),
|
|
98
|
+
).toEqual({
|
|
99
|
+
locationSlug: "oldlibrary",
|
|
100
|
+
imageIndex: 1,
|
|
101
|
+
bucket: "weather",
|
|
102
|
+
variant: "rain",
|
|
103
|
+
usageMode: null,
|
|
104
|
+
role: "lighting",
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("4-part custom role slug coexists with the variant", () => {
|
|
109
|
+
expect(
|
|
110
|
+
parseLocationMentionToken("@oldlibrary:1:weather/rain:cozy-corner"),
|
|
111
|
+
).toEqual({
|
|
112
|
+
locationSlug: "oldlibrary",
|
|
113
|
+
imageIndex: 1,
|
|
114
|
+
bucket: "weather",
|
|
115
|
+
variant: "rain",
|
|
116
|
+
usageMode: null,
|
|
117
|
+
role: "cozy-corner",
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("4-part MODE still routes to usageMode, not role (back-compat)", () => {
|
|
122
|
+
const parsed = parseLocationMentionToken("@oldlibrary:1:weather/rain:style")
|
|
123
|
+
expect(parsed?.usageMode).toBe("style")
|
|
124
|
+
expect(parsed && "role" in parsed).toBe(false)
|
|
96
125
|
})
|
|
97
126
|
|
|
98
127
|
it("3-part with a bare non-mode slug parses as a custom ROLE (parser gate removed, F2)", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import { z } from "zod"
|
|
3
3
|
import { ChatTurnResponseSchema, STAGE_PATCH_SCHEMA } from "../pipeline-chat.js"
|
|
4
4
|
import { PIPELINE_STAGE_NAMES } from "../pipeline-events.js"
|
|
5
5
|
|
|
@@ -61,7 +61,7 @@ describe("ChatTurnResponseSchema", () => {
|
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it("produces JSON Schema with root type:object (Anthropic-valid)", () => {
|
|
64
|
-
const js =
|
|
64
|
+
const js = z.toJSONSchema(ChatTurnResponseSchema, { target: "draft-7", unrepresentable: "any", io: "input" })
|
|
65
65
|
expect((js as { type?: string }).type).toBe("object")
|
|
66
66
|
})
|
|
67
67
|
|
|
@@ -8,10 +8,19 @@ describe("reference-roles registry", () => {
|
|
|
8
8
|
for (const source of Object.keys(DEFAULT_LABEL_BY_SOURCE) as Array<keyof typeof DEFAULT_LABEL_BY_SOURCE>) {
|
|
9
9
|
const presets = REFERENCE_ROLE_PRESETS[source]
|
|
10
10
|
expect(presets, source).toBeTruthy()
|
|
11
|
-
|
|
11
|
+
const def = defaultRoleForSource(source)
|
|
12
|
+
// Media sources (manual / wired-image) default to ref-only = the empty
|
|
13
|
+
// label (a bare {image:N} token). Empty is not a preset role.
|
|
14
|
+
if (def === "") continue
|
|
15
|
+
expect(presets, source).toContain(def)
|
|
12
16
|
}
|
|
13
17
|
})
|
|
14
18
|
|
|
19
|
+
it("media sources default to ref-only (empty label)", () => {
|
|
20
|
+
expect(DEFAULT_LABEL_BY_SOURCE["manual"]).toBe("")
|
|
21
|
+
expect(DEFAULT_LABEL_BY_SOURCE["wired-image"]).toBe("")
|
|
22
|
+
})
|
|
23
|
+
|
|
15
24
|
it("defaultRoleForSource mirrors DEFAULT_LABEL_BY_SOURCE", () => {
|
|
16
25
|
expect(defaultRoleForSource("wired-character")).toBe("person")
|
|
17
26
|
expect(defaultRoleForSource("wired-location")).toBe("background")
|
|
@@ -34,3 +43,14 @@ describe("reference-roles registry", () => {
|
|
|
34
43
|
expect(roleToPhrase("hoodie", "reference image B")).toBe("the hoodie from reference image B")
|
|
35
44
|
})
|
|
36
45
|
})
|
|
46
|
+
|
|
47
|
+
describe("ref-only role", () => {
|
|
48
|
+
it("renders as the bare binding (no descriptive phrase)", () => {
|
|
49
|
+
expect(roleToPhrase("ref-only", "reference image A")).toBe("reference image A")
|
|
50
|
+
expect(roleToPhrase("ref-only", "@image_3")).toBe("@image_3")
|
|
51
|
+
})
|
|
52
|
+
it("is a curated preset for character and location (honored by the preset-gated resolvers)", () => {
|
|
53
|
+
expect(REFERENCE_ROLE_PRESETS["wired-character"]).toContain("ref-only")
|
|
54
|
+
expect(REFERENCE_ROLE_PRESETS["wired-location"]).toContain("ref-only")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -31,6 +31,16 @@ export interface CharacterMentionTokenInfo {
|
|
|
31
31
|
* other 4-part token.
|
|
32
32
|
*/
|
|
33
33
|
readonly usageMode: UsageMode | null
|
|
34
|
+
/**
|
|
35
|
+
* Per-mention ROLE from a NON-MODE 4th segment (Variant + Role Separation) —
|
|
36
|
+
* coexists with a real variant: the variant picks the IMAGE, the role picks
|
|
37
|
+
* the PHRASE (`@kira:1:walking:clothes` → walking image, "the clothes from
|
|
38
|
+
* …"). Curated or custom slug, stored verbatim. OMITTED (undefined, not
|
|
39
|
+
* null) for every other shape — 2/3-part tokens and mode-bearing 4-part
|
|
40
|
+
* tokens keep their pre-existing parse byte-identically. Mirrors
|
|
41
|
+
* `LocationMentionTokenInfo.role`.
|
|
42
|
+
*/
|
|
43
|
+
readonly role?: string
|
|
34
44
|
/**
|
|
35
45
|
* Additive per-mention identity-lock sentinel (Unified Reference Roles,
|
|
36
46
|
* Task 4 + F4). Tri-state: `true` (trailing `~lock`, force lock ON) |
|
|
@@ -55,9 +65,11 @@ export interface CharacterMentionTokenInfo {
|
|
|
55
65
|
* - `@kira:1:smile` → variant smile, default mode
|
|
56
66
|
* - `@kira:1:face` → canonical, mode "face" (mode is detected by
|
|
57
67
|
* checking the 3rd segment against `USAGE_MODES`)
|
|
58
|
-
* - `@kira:1:smile:face`
|
|
59
|
-
* - `@kira:1:
|
|
60
|
-
*
|
|
68
|
+
* - `@kira:1:smile:face` → variant smile, mode "face"
|
|
69
|
+
* - `@kira:1:walking:clothes` → variant walking, ROLE "clothes" (Variant +
|
|
70
|
+
* Role Separation: a non-mode 4th segment is a
|
|
71
|
+
* per-mention role — curated or custom — that
|
|
72
|
+
* coexists with the variant)
|
|
61
73
|
*
|
|
62
74
|
* Format change: as of the index-in-slug update, the index segment is required.
|
|
63
75
|
* Bare `@kira` is not a mention; the autocomplete always inserts at least
|
|
@@ -71,7 +83,7 @@ export interface CharacterMentionTokenInfo {
|
|
|
71
83
|
export function parseCharacterMentionToken(
|
|
72
84
|
text: string,
|
|
73
85
|
_knownCharacterSlugs?: readonly string[],
|
|
74
|
-
): { characterSlug: string; imageIndex: number; variantSlug: string | null; usageMode: UsageMode | null; lock?: boolean } | null {
|
|
86
|
+
): { characterSlug: string; imageIndex: number; variantSlug: string | null; usageMode: UsageMode | null; role?: string; lock?: boolean } | null {
|
|
75
87
|
if (!text.startsWith("@")) return null
|
|
76
88
|
let rest = text.slice(1)
|
|
77
89
|
if (rest.length === 0 || !/^[a-z]/.test(rest)) return null
|
|
@@ -123,13 +135,19 @@ export function parseCharacterMentionToken(
|
|
|
123
135
|
return { characterSlug, imageIndex, variantSlug: third, usageMode: null, ...lockField }
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
// 4-part: kira:1:
|
|
127
|
-
//
|
|
128
|
-
//
|
|
138
|
+
// 4-part: kira:1:variant:X — variant + a 4th segment that is EITHER a
|
|
139
|
+
// usage-mode override (today's shape, routed to `usageMode` byte-identically)
|
|
140
|
+
// OR — Variant + Role Separation — any other slug, parsed as a per-mention
|
|
141
|
+
// ROLE that coexists with the variant (the variant picks the image, the role
|
|
142
|
+
// picks the phrase). The `role` key is emitted ONLY on the non-mode branch so
|
|
143
|
+
// mode-bearing tokens keep their exact pre-existing shape.
|
|
129
144
|
if (parts.length === 4) {
|
|
130
145
|
if (!/^[a-z][a-z0-9-]*$/.test(third)) return null
|
|
131
|
-
if (
|
|
132
|
-
|
|
146
|
+
if (!/^[a-z][a-z0-9-]*$/.test(fourth)) return null
|
|
147
|
+
if (isUsageMode(fourth)) {
|
|
148
|
+
return { characterSlug, imageIndex, variantSlug: third, usageMode: fourth, ...lockField }
|
|
149
|
+
}
|
|
150
|
+
return { characterSlug, imageIndex, variantSlug: third, usageMode: null, role: fourth, ...lockField }
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
return null
|
|
@@ -119,3 +119,64 @@ export function characterMentionableAssetArrays(
|
|
|
119
119
|
boards: characterBoardItems(data),
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* DISPLAY order for character asset groups in picker menus (swap-picker,
|
|
125
|
+
* `@` autocomplete) — boards first (the highest-value identity reference),
|
|
126
|
+
* then sheets, then the variant buckets in data order.
|
|
127
|
+
*
|
|
128
|
+
* DISPLAY ONLY. The data-record key order of
|
|
129
|
+
* {@link characterMentionableAssetArrays} is payload-numbering-bearing
|
|
130
|
+
* (`{image:N}` / `@name:N` are positional) and MUST NOT change; menus sort
|
|
131
|
+
* their rendered rows by this rank instead. The guard test asserts this
|
|
132
|
+
* list's key set stays identical to the data record's.
|
|
133
|
+
*/
|
|
134
|
+
export const CHARACTER_PICKER_DISPLAY_ORDER: readonly string[] = [
|
|
135
|
+
"boards",
|
|
136
|
+
"sheets",
|
|
137
|
+
...CHARACTER_VARIANT_ASSET_BUCKETS,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Display rank for a character entry's asset bucket. Canonical entries
|
|
142
|
+
* (no bucket — the portrait row) lead, then buckets per
|
|
143
|
+
* {@link CHARACTER_PICKER_DISPLAY_ORDER}; unknown buckets sink to the end.
|
|
144
|
+
*/
|
|
145
|
+
export function characterBucketDisplayRank(bucket: string | undefined): number {
|
|
146
|
+
if (bucket === undefined) return -1
|
|
147
|
+
const i = CHARACTER_PICKER_DISPLAY_ORDER.indexOf(bucket)
|
|
148
|
+
return i === -1 ? CHARACTER_PICKER_DISPLAY_ORDER.length : i
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Sort picker entries for display: within each CONTIGUOUS run of entries
|
|
153
|
+
* sharing a `characterSlug`, order by {@link characterBucketDisplayRank}
|
|
154
|
+
* (stable within a rank); entries without a slug — and run boundaries —
|
|
155
|
+
* stay exactly where they are. Runs (not a global sort) because the flat
|
|
156
|
+
* reference list interleaves characters with uploads/wired images whose
|
|
157
|
+
* positions are meaningful.
|
|
158
|
+
*/
|
|
159
|
+
export function sortCharacterEntriesForDisplay<
|
|
160
|
+
T extends { readonly characterSlug?: string; readonly bucket?: string },
|
|
161
|
+
>(items: readonly T[]): T[] {
|
|
162
|
+
const out: T[] = []
|
|
163
|
+
let i = 0
|
|
164
|
+
while (i < items.length) {
|
|
165
|
+
const slug = items[i].characterSlug
|
|
166
|
+
if (!slug) {
|
|
167
|
+
out.push(items[i])
|
|
168
|
+
i++
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
let j = i
|
|
172
|
+
while (j < items.length && items[j].characterSlug === slug) j++
|
|
173
|
+
const run = items.slice(i, j)
|
|
174
|
+
const ranked = run
|
|
175
|
+
.map((it, k) => ({ it, k, r: characterBucketDisplayRank(it.bucket) }))
|
|
176
|
+
.sort((a, b) => a.r - b.r || a.k - b.k)
|
|
177
|
+
.map((x) => x.it)
|
|
178
|
+
out.push(...ranked)
|
|
179
|
+
i = j
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
@@ -68,6 +68,9 @@ export const CHARACTER_ATTACH_COLUMNS = [
|
|
|
68
68
|
"sheets",
|
|
69
69
|
"detail_closeups",
|
|
70
70
|
"outfit_variations",
|
|
71
|
+
// Named composite reference boards (migration 250) — identity-collage
|
|
72
|
+
// sheets auto-attached by the ffmpeg image-collage worker.
|
|
73
|
+
"boards",
|
|
71
74
|
] as const
|
|
72
75
|
export type CharacterAttachColumn = (typeof CHARACTER_ATTACH_COLUMNS)[number]
|
|
73
76
|
|
|
@@ -34,3 +34,25 @@ export function resolveEffectiveSourceType(
|
|
|
34
34
|
}
|
|
35
35
|
return rawSourceType ?? ""
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The ref-assembly map key (and `ConnectedReference.id`) for a wired source.
|
|
40
|
+
*
|
|
41
|
+
* An entity's `image` handle emits a plain image that is DISTINCT from the
|
|
42
|
+
* entity's identity `*Ref` handle, yet both edges carry the SAME source node
|
|
43
|
+
* id. Every ref-assembly map that keyed by the bare node id therefore had the
|
|
44
|
+
* two edges collide — silently dropping one (identity → a literal `@abi:N`
|
|
45
|
+
* token + lost character, or the plain image → missing from the picker),
|
|
46
|
+
* non-deterministically by edge order. Scoping the entity-`image`-handle ref to
|
|
47
|
+
* `${nodeId}::image` keeps the two refs distinct so BOTH survive. Every other
|
|
48
|
+
* (type, handle) keeps the bare node id, so nothing else changes.
|
|
49
|
+
*/
|
|
50
|
+
export function sourceRefKey(
|
|
51
|
+
nodeId: string,
|
|
52
|
+
sourceHandleId: string | undefined | null,
|
|
53
|
+
rawSourceType: string | undefined | null,
|
|
54
|
+
): string {
|
|
55
|
+
return sourceHandleId === "image" && ENTITY_IMAGE_HANDLE_TYPES.has(rawSourceType ?? "")
|
|
56
|
+
? `${nodeId}::image`
|
|
57
|
+
: nodeId
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -706,6 +706,9 @@ export {
|
|
|
706
706
|
characterSheetRefItems,
|
|
707
707
|
characterBoardItems,
|
|
708
708
|
characterMentionableAssetArrays,
|
|
709
|
+
CHARACTER_PICKER_DISPLAY_ORDER,
|
|
710
|
+
characterBucketDisplayRank,
|
|
711
|
+
sortCharacterEntriesForDisplay,
|
|
709
712
|
} from "./character-variant-assets.js"
|
|
710
713
|
export type {
|
|
711
714
|
CharacterVariantAssetBucket,
|
|
@@ -107,11 +107,13 @@ export interface LocationMentionTokenInfo {
|
|
|
107
107
|
readonly usageMode: LocationUsageMode | null
|
|
108
108
|
/** Bare-slug ROLE (Unified Reference Roles, Phase D + F2) — any slash-less,
|
|
109
109
|
* non-mode 3rd segment (`background`, `empty-background`, `as-is`, … or a
|
|
110
|
-
* CUSTOM slug), stored verbatim in slug form
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* canonical/variant/mode tokens
|
|
114
|
-
*
|
|
110
|
+
* CUSTOM slug), stored verbatim in slug form — OR (Variant + Role
|
|
111
|
+
* Separation) a non-mode 4th segment coexisting with a bucket/variant
|
|
112
|
+
* (`@lib:1:weather/rain:lighting` → rain image, lighting phrase). Absent
|
|
113
|
+
* (undefined) for canonical / bare-variant / mode tokens. The hybrid
|
|
114
|
+
* resolver + role pill map it back to the phrase key via `normalizeRoleSlug`
|
|
115
|
+
* (custom slugs pass through). Optional so those tokens stay
|
|
116
|
+
* shape-identical to the pre-Phase-D parser output. */
|
|
115
117
|
readonly role?: string
|
|
116
118
|
/**
|
|
117
119
|
* Additive per-mention identity-lock sentinel (Unified Reference Roles,
|
|
@@ -235,7 +237,11 @@ export function parseLocationMentionToken(
|
|
|
235
237
|
}
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
// 4-part: @oldlibrary:1:bucket/variant:mode
|
|
240
|
+
// 4-part: @oldlibrary:1:bucket/variant:X — X is EITHER a mode override
|
|
241
|
+
// (today's shape, byte-identical) OR — Variant + Role Separation — any other
|
|
242
|
+
// slug, parsed as a per-mention ROLE coexisting with the bucket/variant (the
|
|
243
|
+
// variant picks the image, the role picks the phrase). The `role` key is
|
|
244
|
+
// emitted only on the non-mode branch, mirroring the character parser.
|
|
239
245
|
if (parts.length === 4) {
|
|
240
246
|
if (!third.includes("/")) return null
|
|
241
247
|
const slashAt = third.indexOf("/")
|
|
@@ -243,8 +249,11 @@ export function parseLocationMentionToken(
|
|
|
243
249
|
const variant = third.slice(slashAt + 1)
|
|
244
250
|
if (!/^[a-z][a-z0-9-]*$/.test(bucket)) return null
|
|
245
251
|
if (!/^[a-z][a-z0-9-]*$/.test(variant)) return null
|
|
246
|
-
if (
|
|
247
|
-
|
|
252
|
+
if (!/^[a-z][a-z0-9-]*$/.test(fourth)) return null
|
|
253
|
+
if (isLocationUsageMode(fourth)) {
|
|
254
|
+
return { locationSlug, imageIndex, bucket, variant, usageMode: fourth, ...lockField }
|
|
255
|
+
}
|
|
256
|
+
return { locationSlug, imageIndex, bucket, variant, usageMode: null, role: fourth, ...lockField }
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
return null
|
|
@@ -148,7 +148,7 @@ export const StageAwaitingSubGateEventSchema = z.object({
|
|
|
148
148
|
pipelineId: z.string().uuid(),
|
|
149
149
|
stageName: z.enum(["animate_audio_edit", "scene_images"]),
|
|
150
150
|
subGate: SubGateNameSchema,
|
|
151
|
-
payload: z.record(z.unknown()).optional(),
|
|
151
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
152
152
|
})
|
|
153
153
|
export type StageAwaitingSubGateEvent = z.infer<typeof StageAwaitingSubGateEventSchema>
|
|
154
154
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z, type ZodType
|
|
1
|
+
import { z, type ZodType } from "zod"
|
|
2
2
|
// Reuse the canonical `OutputType` from presentation-utils so a single union
|
|
3
3
|
// is the source of truth across the package. Re-exporting it here would
|
|
4
4
|
// collide with the explicit named re-export in `index.ts`.
|
|
@@ -13,7 +13,7 @@ export type ReduceStrategy<TConfig = unknown> = {
|
|
|
13
13
|
readonly label: string
|
|
14
14
|
readonly description: string
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
-
readonly configSchema: ZodType<TConfig,
|
|
16
|
+
readonly configSchema: ZodType<TConfig, any>
|
|
17
17
|
readonly defaultConfig: TConfig
|
|
18
18
|
readonly outputType: OutputType
|
|
19
19
|
readonly creditCostKey: string
|
package/src/reference-roles.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { DEFAULT_LABEL_BY_SOURCE, type ReferenceSource } from "./types.js"
|
|
|
9
9
|
* value (guarded by reference-roles.test.ts).
|
|
10
10
|
*/
|
|
11
11
|
export const REFERENCE_ROLE_PRESETS: Record<ReferenceSource, readonly string[]> = {
|
|
12
|
-
"wired-character": ["person", "face", "clothes", "hair", "pose", "expression", "style"],
|
|
12
|
+
"wired-character": ["ref-only", "person", "face", "clothes", "hair", "pose", "expression", "style"],
|
|
13
13
|
"wired-face": ["face", "person", "expression", "style"],
|
|
14
|
-
"wired-location": ["background", "atmosphere", "as-is", "empty background", "layout", "lighting", "style"],
|
|
14
|
+
"wired-location": ["ref-only", "background", "atmosphere", "as-is", "empty background", "layout", "lighting", "style"],
|
|
15
15
|
"wired-object": ["object", "shape", "material", "color", "texture", "style"],
|
|
16
16
|
"wired-creature": ["creature", "anatomy", "markings", "pose", "color", "style"],
|
|
17
17
|
"wired-image": ["object", "person", "face", "clothes", "background", "style", "pose", "texture"],
|
|
@@ -35,6 +35,10 @@ export function roleToPhrase(role: string, binding: string): string {
|
|
|
35
35
|
const r = role.trim()
|
|
36
36
|
if (!r) return binding
|
|
37
37
|
switch (r) {
|
|
38
|
+
case "ref-only":
|
|
39
|
+
// Bare reference pointer — no descriptive phrase. The label-less/default
|
|
40
|
+
// state for media refs; an explicit pick for character/location assets.
|
|
41
|
+
return binding
|
|
38
42
|
case "as-is":
|
|
39
43
|
return `${binding}, used as-is`
|
|
40
44
|
case "empty background":
|
package/src/scene-node-types.ts
CHANGED
|
@@ -89,7 +89,7 @@ export const ShotSpecSchema = z.object({
|
|
|
89
89
|
camera_path_directive: z
|
|
90
90
|
.object({
|
|
91
91
|
path_kind: z.enum(["orbit", "dolly", "crane", "arc", "reveal"]),
|
|
92
|
-
parameters: z.record(z.unknown()).optional(),
|
|
92
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
93
93
|
})
|
|
94
94
|
.optional(),
|
|
95
95
|
|
package/src/types.ts
CHANGED
|
@@ -82,6 +82,13 @@ export interface ConnectedReference {
|
|
|
82
82
|
readonly characterSlug?: string
|
|
83
83
|
/** Variant slug (e.g. "smile"). undefined = canonical/default. */
|
|
84
84
|
readonly variantSlug?: string
|
|
85
|
+
/**
|
|
86
|
+
* Which character asset bucket this entry came from ("boards", "sheets",
|
|
87
|
+
* "expressions", …); undefined = the canonical portrait entry. DISPLAY
|
|
88
|
+
* metadata only — menus use it to rank rows (boards first) via
|
|
89
|
+
* `sortCharacterEntriesForDisplay`; it never affects payload numbering.
|
|
90
|
+
*/
|
|
91
|
+
readonly bucket?: string
|
|
85
92
|
/** Character's canonical_description. Set on every entry from the same character (used for dedup). */
|
|
86
93
|
readonly characterCanonicalDescription?: string | null
|
|
87
94
|
/**
|
|
@@ -205,8 +212,11 @@ export interface ConnectedReference {
|
|
|
205
212
|
|
|
206
213
|
/** Default label per source — used by `@` autocomplete and inventory fallback. */
|
|
207
214
|
export const DEFAULT_LABEL_BY_SOURCE: Record<ReferenceSource, string> = {
|
|
208
|
-
"
|
|
209
|
-
"
|
|
215
|
+
// Media references default to "ref-only" — the empty label, i.e. a bare
|
|
216
|
+
// {image:N} token that injects only "reference image A" (image) / "@image_N"
|
|
217
|
+
// (video) with no descriptive role phrase. Pick a role on the pill to change.
|
|
218
|
+
"manual": "",
|
|
219
|
+
"wired-image": "",
|
|
210
220
|
"wired-character": "person",
|
|
211
221
|
"wired-face": "face",
|
|
212
222
|
"wired-object": "object",
|