@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "1.5.0",
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": "^3.24.2"
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
- it("4-part: @kira:1:smile:invalid invalid mode rejects the entire token", () => {
140
- expect(parseCharacterMentionToken("@kira:1:smile:invalid")).toBeNull()
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("4-part: @kira:1:smile:smile variant-shaped 4th segment still rejects (must be a mode)", () => {
144
- expect(parseCharacterMentionToken("@kira:1:smile:smile")).toBeNull()
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 invalid mode is dropped (matches regex but parser rejects)", () => {
195
- // The regex matches structurally but `parseCharacterMentionToken` rejects
196
- // the 4th segment as an invalid mode, so the token is filtered out.
197
- expect(findCharacterMentionTokens("@kira:1:smile:bogus waves", ["kira"])).toEqual([])
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("returns null for 4-part with unknown 4th-segment mode", () => {
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:face"),
95
- ).toBeNull()
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 { default as zodToJsonSchema } from "zod-to-json-schema"
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 = zodToJsonSchema(ChatTurnResponseSchema, { target: "jsonSchema7" })
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
- expect(presets, source).toContain(defaultRoleForSource(source))
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` → variant smile, mode "face"
59
- * - `@kira:1:smile:bogus` null (4-part rejected when last segment is not
60
- * a valid usage mode keeps the format strict)
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:smile:mode — variant + mode override. Both segments must
127
- // satisfy their respective shape; an unknown mode kills the token entirely
128
- // (callers fall back to literal text, matching unknown-character behavior).
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 (!isUsageMode(fourth)) return null
132
- return { characterSlug, imageIndex, variantSlug: third, usageMode: fourth, ...lockField }
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. Absent (undefined) for canonical /
111
- * bucket-variant / mode tokens. The hybrid resolver + role pill map it back to
112
- * the phrase key via `normalizeRoleSlug` (custom slugs pass through). Optional so
113
- * canonical/variant/mode tokens stay shape-identical to the pre-Phase-D parser
114
- * output. */
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 (!isLocationUsageMode(fourth)) return null
247
- return { locationSlug, imageIndex, bucket, variant, usageMode: fourth, ...lockField }
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
@@ -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: VIDEO_RATIOS_HVS345,
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: 13, note: "5s 720p default" },
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: 13, note: "5s 720p default" },
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: VIDEO_RATIOS_HVS345,
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: 15, note: "5s 720p default" },
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: 20, note: "720p default" },
1445
+ { identifier: "happyhorse-edit", credits: 35, note: "720p default (5s-equivalent flat rate)" },
1441
1446
  ],
1442
1447
  },
1443
1448
  // ── Bytedance video lite/pro ──