@nodaro/shared 1.5.0 → 1.7.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 +101 -27
- 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 +98 -28
- 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/model-catalog.ts +18 -13
- package/src/model-constants.ts +25 -0
- 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.7.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
|
package/src/model-catalog.ts
CHANGED
|
@@ -187,6 +187,10 @@ const VIDEO_RATIOS_HVS345 = ["16:9", "9:16", "1:1", "4:3", "3:4"] as const
|
|
|
187
187
|
// (docs.kie.ai/market/bytedance/seedance-2). Kept separate from HVS so the
|
|
188
188
|
// wider set can't leak to models that don't support it.
|
|
189
189
|
const VIDEO_RATIOS_SEEDANCE_2 = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "adaptive"] as const
|
|
190
|
+
// HappyHorse 1.1 (t2v + ref2v) — the model's full 9-ratio set incl. 4:5/5:4
|
|
191
|
+
// portrait-social and 21:9/9:21 cinematic (docs.kie.ai/market/happyhorse-1-1).
|
|
192
|
+
// Kept separate so the wider set can't leak to models that don't support it.
|
|
193
|
+
const VIDEO_RATIOS_HAPPYHORSE_11 = ["16:9", "9:16", "1:1", "4:3", "3:4", "4:5", "5:4", "21:9", "9:21"] as const
|
|
190
194
|
|
|
191
195
|
// =============================================================================
|
|
192
196
|
// IMAGE MODELS
|
|
@@ -1377,21 +1381,22 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1377
1381
|
{ identifier: "wan-2.7-t2v", credits: 19, note: "5s 720p default" },
|
|
1378
1382
|
],
|
|
1379
1383
|
},
|
|
1380
|
-
// ── HappyHorse
|
|
1384
|
+
// ── HappyHorse (1.1 — ids kept version-less; repointed in place when KIE
|
|
1385
|
+
// delisted 1.0, same param surface, so saved workflows keep working) ──
|
|
1381
1386
|
"happyhorse": {
|
|
1382
1387
|
id: "happyhorse",
|
|
1383
1388
|
kind: "video",
|
|
1384
1389
|
modes: ["t2v"] as const,
|
|
1385
1390
|
family: "HappyHorse",
|
|
1386
|
-
label: "HappyHorse",
|
|
1391
|
+
label: "HappyHorse 1.1",
|
|
1387
1392
|
series: "HappyHorse",
|
|
1388
|
-
description: "HappyHorse text-to-video — 3–15s at 720p/1080p.",
|
|
1393
|
+
description: "HappyHorse 1.1 text-to-video — 3–15s at 720p/1080p, 9 aspect ratios incl. 21:9/9:21, per-second pricing.",
|
|
1389
1394
|
useCases: ["motion", "creative"],
|
|
1390
|
-
aspectRatios:
|
|
1395
|
+
aspectRatios: VIDEO_RATIOS_HAPPYHORSE_11,
|
|
1391
1396
|
durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1392
1397
|
resolutions: ["720p", "1080p"],
|
|
1393
1398
|
pricing: [
|
|
1394
|
-
{ identifier: "happyhorse", credits:
|
|
1399
|
+
{ identifier: "happyhorse", credits: 29, note: "5s 720p default — per-second: ~5.7 cr/s @720p, ~7.3 cr/s @1080p" },
|
|
1395
1400
|
],
|
|
1396
1401
|
},
|
|
1397
1402
|
"happyhorse-i2v": {
|
|
@@ -1399,14 +1404,14 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1399
1404
|
kind: "video",
|
|
1400
1405
|
modes: ["i2v"] as const,
|
|
1401
1406
|
family: "HappyHorse",
|
|
1402
|
-
label: "HappyHorse I2V",
|
|
1407
|
+
label: "HappyHorse 1.1 I2V",
|
|
1403
1408
|
series: "HappyHorse",
|
|
1404
|
-
description: "HappyHorse image-to-video — 3–15s at 720p/1080p, aspect ratio inferred from input image.",
|
|
1409
|
+
description: "HappyHorse 1.1 image-to-video — 3–15s at 720p/1080p, aspect ratio inferred from input image, per-second pricing.",
|
|
1405
1410
|
useCases: ["motion", "creative"],
|
|
1406
1411
|
durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1407
1412
|
resolutions: ["720p", "1080p"],
|
|
1408
1413
|
pricing: [
|
|
1409
|
-
{ identifier: "happyhorse-i2v", credits:
|
|
1414
|
+
{ identifier: "happyhorse-i2v", credits: 29, note: "5s 720p default — per-second: ~5.7 cr/s @720p, ~7.3 cr/s @1080p" },
|
|
1410
1415
|
],
|
|
1411
1416
|
},
|
|
1412
1417
|
"happyhorse-ref2v": {
|
|
@@ -1414,16 +1419,16 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1414
1419
|
kind: "video",
|
|
1415
1420
|
modes: ["i2v"] as const,
|
|
1416
1421
|
family: "HappyHorse",
|
|
1417
|
-
label: "HappyHorse Ref2V",
|
|
1422
|
+
label: "HappyHorse 1.1 Ref2V",
|
|
1418
1423
|
series: "HappyHorse",
|
|
1419
|
-
description: "HappyHorse reference-to-video — 1–9 reference images, 3–15s at 720p/1080p.",
|
|
1424
|
+
description: "HappyHorse 1.1 reference-to-video — 1–9 reference images, 3–15s at 720p/1080p, per-second pricing.",
|
|
1420
1425
|
useCases: ["motion", "reference"],
|
|
1421
|
-
aspectRatios:
|
|
1426
|
+
aspectRatios: VIDEO_RATIOS_HAPPYHORSE_11,
|
|
1422
1427
|
durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1423
1428
|
resolutions: ["720p", "1080p"],
|
|
1424
1429
|
features: ["reference-image"],
|
|
1425
1430
|
pricing: [
|
|
1426
|
-
{ identifier: "happyhorse-ref2v", credits:
|
|
1431
|
+
{ identifier: "happyhorse-ref2v", credits: 29, note: "5s 720p default — per-second: ~5.7 cr/s @720p, ~7.3 cr/s @1080p" },
|
|
1427
1432
|
],
|
|
1428
1433
|
},
|
|
1429
1434
|
"happyhorse-edit": {
|
|
@@ -1437,7 +1442,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1437
1442
|
useCases: ["v2v", "restyle"],
|
|
1438
1443
|
resolutions: ["720p", "1080p"],
|
|
1439
1444
|
pricing: [
|
|
1440
|
-
{ identifier: "happyhorse-edit", credits:
|
|
1445
|
+
{ identifier: "happyhorse-edit", credits: 35, note: "720p default (5s-equivalent flat rate)" },
|
|
1441
1446
|
],
|
|
1442
1447
|
},
|
|
1443
1448
|
// ── Bytedance video lite/pro ──
|