@nodaro/prompts 1.0.1

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.
Files changed (150) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +27 -0
  3. package/dist/index.cjs +21240 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +3599 -0
  6. package/dist/index.d.ts +3599 -0
  7. package/dist/index.js +20854 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
  11. package/src/__tests__/action-fx.test.ts +155 -0
  12. package/src/__tests__/apply-picker-json.test.ts +50 -0
  13. package/src/__tests__/assemble-image-input.test.ts +248 -0
  14. package/src/__tests__/assemble-suno-input.test.ts +283 -0
  15. package/src/__tests__/brand-tokens.test.ts +81 -0
  16. package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
  17. package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
  18. package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
  19. package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
  20. package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
  21. package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
  22. package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
  23. package/src/__tests__/catalog-gapfill.test.ts +53 -0
  24. package/src/__tests__/character-convergence-image.test.ts +217 -0
  25. package/src/__tests__/character-default-role-image.test.ts +167 -0
  26. package/src/__tests__/character-default-role-video.test.ts +168 -0
  27. package/src/__tests__/character-default-role.test.ts +70 -0
  28. package/src/__tests__/character-fx.test.ts +200 -0
  29. package/src/__tests__/entity-prompts-location.test.ts +97 -0
  30. package/src/__tests__/entity-prompts.test.ts +240 -0
  31. package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
  32. package/src/__tests__/factory-presets.test.ts +1045 -0
  33. package/src/__tests__/factory-snippets.test.ts +68 -0
  34. package/src/__tests__/framing-multi.test.ts +71 -0
  35. package/src/__tests__/framing-vantage.test.ts +39 -0
  36. package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
  37. package/src/__tests__/identity-lock.test.ts +46 -0
  38. package/src/__tests__/instrumentation.test.ts +68 -0
  39. package/src/__tests__/lighting-multi.test.ts +68 -0
  40. package/src/__tests__/location-convergence-image.test.ts +124 -0
  41. package/src/__tests__/mention-lock-flag.test.ts +499 -0
  42. package/src/__tests__/multi-picker-spec.test.ts +71 -0
  43. package/src/__tests__/music-genre.test.ts +103 -0
  44. package/src/__tests__/music-mood.test.ts +87 -0
  45. package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
  46. package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
  47. package/src/__tests__/parameter-registry-sync.test.ts +243 -0
  48. package/src/__tests__/person-age.test.ts +80 -0
  49. package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
  50. package/src/__tests__/person-body-axes.test.ts +67 -0
  51. package/src/__tests__/person-facial-geometry.test.ts +156 -0
  52. package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
  53. package/src/__tests__/person-sections.test.ts +18 -0
  54. package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
  55. package/src/__tests__/picker-catalogs-project.test.ts +85 -0
  56. package/src/__tests__/picker-catalogs.test.ts +53 -0
  57. package/src/__tests__/picker-limits.test.ts +20 -0
  58. package/src/__tests__/prompt-builder-segments.test.ts +183 -0
  59. package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
  60. package/src/__tests__/prompt-builder.test.ts +1773 -0
  61. package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
  62. package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
  63. package/src/__tests__/resolve-prompt-append.test.ts +58 -0
  64. package/src/__tests__/resolve-prompt.test.ts +44 -0
  65. package/src/__tests__/role-picker-shared.test.ts +208 -0
  66. package/src/__tests__/seedance-2-inputs.test.ts +173 -0
  67. package/src/__tests__/seedance-extend.test.ts +44 -0
  68. package/src/__tests__/sound-aggregator.test.ts +350 -0
  69. package/src/__tests__/style-presets.test.ts +34 -0
  70. package/src/__tests__/temporal-multi.test.ts +74 -0
  71. package/src/__tests__/transitions.test.ts +213 -0
  72. package/src/__tests__/video-reference-features.test.ts +41 -0
  73. package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
  74. package/src/__tests__/video-reference-resolver.test.ts +233 -0
  75. package/src/__tests__/video-reference-roles.test.ts +96 -0
  76. package/src/__tests__/voice-character.test.ts +48 -0
  77. package/src/__tests__/voice-delivery.test.ts +41 -0
  78. package/src/__tests__/wardrobe.test.ts +25 -0
  79. package/src/action-fx.ts +255 -0
  80. package/src/aesthetic.ts +435 -0
  81. package/src/assemble-image-input.ts +236 -0
  82. package/src/assemble-suno-input.ts +147 -0
  83. package/src/atmosphere.ts +104 -0
  84. package/src/backdrop.ts +131 -0
  85. package/src/brand-tokens.ts +154 -0
  86. package/src/camera-format.ts +76 -0
  87. package/src/camera-motions.ts +615 -0
  88. package/src/character-fx.ts +274 -0
  89. package/src/color-look.ts +103 -0
  90. package/src/composition-effects.ts +67 -0
  91. package/src/entity-prompts.ts +231 -0
  92. package/src/era.ts +292 -0
  93. package/src/exposure-settings.ts +142 -0
  94. package/src/factory-presets/generate-image.ts +1644 -0
  95. package/src/factory-presets/generate-video.ts +1116 -0
  96. package/src/factory-presets/index.ts +46 -0
  97. package/src/factory-presets/lottie-overlay.ts +166 -0
  98. package/src/factory-presets/motion-graphics.ts +350 -0
  99. package/src/factory-presets/music.ts +734 -0
  100. package/src/factory-presets/sfx.ts +136 -0
  101. package/src/factory-presets/shared-image.ts +207 -0
  102. package/src/factory-presets/switchx.ts +65 -0
  103. package/src/factory-presets/text.ts +292 -0
  104. package/src/factory-presets/types.ts +51 -0
  105. package/src/factory-presets/video-edit.ts +136 -0
  106. package/src/factory-presets/voice.ts +172 -0
  107. package/src/factory-presets.ts +2 -0
  108. package/src/factory-snippets/catalog.ts +105 -0
  109. package/src/factory-snippets/index.ts +16 -0
  110. package/src/factory-snippets/types.ts +33 -0
  111. package/src/framing.ts +634 -0
  112. package/src/held-prop.ts +187 -0
  113. package/src/identity-lock.ts +213 -0
  114. package/src/index.ts +66 -0
  115. package/src/instrumentation.ts +337 -0
  116. package/src/lens.ts +59 -0
  117. package/src/lighting.ts +229 -0
  118. package/src/loop-subject.ts +276 -0
  119. package/src/materials.ts +184 -0
  120. package/src/mood.ts +186 -0
  121. package/src/music-genre.ts +662 -0
  122. package/src/music-mood.ts +137 -0
  123. package/src/object-asset-presets.ts +81 -0
  124. package/src/parameter-prompt-hint.ts +281 -0
  125. package/src/person.ts +1368 -0
  126. package/src/photo-genre.ts +151 -0
  127. package/src/photographer.ts +612 -0
  128. package/src/picker-analyzer-registry.ts +374 -0
  129. package/src/picker-catalogs.ts +858 -0
  130. package/src/pose.ts +246 -0
  131. package/src/post-process-effects.ts +94 -0
  132. package/src/prompt-builder-structured-fields.ts +116 -0
  133. package/src/prompt-builder.ts +2964 -0
  134. package/src/prompt-templates.ts +50 -0
  135. package/src/prompt-wizard-categories.ts +334 -0
  136. package/src/provider-prompt-doctrine.ts +85 -0
  137. package/src/render-quality.ts +89 -0
  138. package/src/resolve-prompt.ts +120 -0
  139. package/src/seedance-2-inputs.ts +100 -0
  140. package/src/setting.ts +130 -0
  141. package/src/sound-aggregator.ts +241 -0
  142. package/src/style-presets.ts +162 -0
  143. package/src/style.ts +99 -0
  144. package/src/styling.ts +585 -0
  145. package/src/temporal.ts +151 -0
  146. package/src/transitions.ts +333 -0
  147. package/src/video-reference-resolver.ts +823 -0
  148. package/src/voice-character.ts +237 -0
  149. package/src/voice-delivery.ts +142 -0
  150. package/src/wardrobe.ts +179 -0
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { modelsWithFeature } from "@nodaro/shared"
3
+
4
+ // Guards the catalog `reference-image` feature against the actual provider
5
+ // transport. A video model that advertises `reference-image` lets the editor
6
+ // attach reference images AND suppresses the "references will be ignored"
7
+ // warning — so advertising it without a working backend path silently drops
8
+ // the user's references (the grok-imagine-video-1.5 bug, 2026-06-28 audit).
9
+ describe("video reference-image capability (catalog)", () => {
10
+ const refModels = modelsWithFeature("reference-image")
11
+
12
+ // grok-imagine-video-1.5 takes a single image_url (the i2v start frame) at
13
+ // KIE — there is no reference transport (no `maxRefImages`, no special path),
14
+ // so it must NOT advertise the feature.
15
+ it("excludes grok-imagine-video-1.5 (no reference transport)", () => {
16
+ expect(refModels).not.toContain("grok-imagine-video-1.5")
17
+ })
18
+
19
+ // The video models that genuinely forward references — verified against the
20
+ // backend provider paths in the 2026-06-28 audit (Seedance resolver,
21
+ // gemini-omni image_urls merge, VEO REFERENCE_2_VIDEO endpoint, kling-3-omni
22
+ // Replicate reference_images, and the `maxRefImages` merge for grok-i2v /
23
+ // happyhorse-ref2v) — must keep advertising it. Subset check, so wiring a
24
+ // NEW ref-capable model doesn't fail here; only breaking a wired one does.
25
+ it("includes the wired reference-capable video models", () => {
26
+ for (const id of [
27
+ "veo3",
28
+ "veo3.1",
29
+ "veo3_lite",
30
+ "gemini-omni-video",
31
+ "kling-3-omni",
32
+ "grok-i2v",
33
+ "happyhorse-ref2v",
34
+ "seedance-2",
35
+ "seedance-2-fast",
36
+ "seedance-2-mini",
37
+ ]) {
38
+ expect(refModels).toContain(id)
39
+ }
40
+ })
41
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * D5 unified-numbering invariant for `resolveVideoReferenceCore`'s `leadingRefUrls`
3
+ * (unified-asset-references spec, 2026-06-29). Leading plain image-refs occupy
4
+ * `@image_1 … @image_offset`; every asset URL the core attaches (canonical /
5
+ * extras) is numbered AFTER them, prepended to `additionalUrls` so the worker
6
+ * payload position == the `@image_N` ordinal == the `{image:N}` token the editor
7
+ * offers. Frames are tail-appended elsewhere and out of scope here.
8
+ *
9
+ * The opt-in contract: WITHOUT `leadingRefUrls` the core behaves exactly as
10
+ * before (covered by the existing suite); these cases exercise the WITH path.
11
+ */
12
+ import { describe, it, expect } from "vitest"
13
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
14
+
15
+ const A = "https://cdn/a.png"
16
+ const B = "https://cdn/b.png"
17
+ const E1 = "https://cdn/e1.png"
18
+ const E2 = "https://cdn/e2.png"
19
+
20
+ describe("resolveVideoReferenceCore — leadingRefUrls (D5)", () => {
21
+ it("leading refs only: tokens number 1..offset, URLs returned in order", () => {
22
+ const r = resolveVideoReferenceCore({
23
+ prompt: "start {image:1:hero} mid {image:2:car} end",
24
+ wiredCharRefs: [],
25
+ leadingRefUrls: [A, B],
26
+ })
27
+ expect(r.additionalUrls).toEqual([A, B])
28
+ expect(r.prompt).toContain("the hero from @image_1")
29
+ expect(r.prompt).toContain("the car from @image_2")
30
+ })
31
+
32
+ it("leading + extra asset: extra is numbered AFTER the leading refs (offset)", () => {
33
+ const r = resolveVideoReferenceCore({
34
+ prompt: "a scene of {image:2:object}",
35
+ wiredCharRefs: [],
36
+ // one plain ref leads → offset 1 → the extra is @image_2
37
+ leadingRefUrls: [A],
38
+ extraRefs: [{ url: E1, description: "object" }],
39
+ })
40
+ // worker payload: leading first, then the asset URL
41
+ expect(r.additionalUrls).toEqual([A, E1])
42
+ // the extra's directive bullet is numbered @image_2 (offset+1), NOT @image_1
43
+ expect(r.prompt).toContain("@image_2")
44
+ expect(r.prompt).not.toMatch(/@image_1\b/) // @image_1 is the (un-bulleted) leading ref
45
+ // the {image:2} body token resolves against the unified count (2)
46
+ expect(r.prompt).toContain("the object from @image_2")
47
+ })
48
+
49
+ it("out-of-range token (N > leading+assets) drops to the bare label", () => {
50
+ const r = resolveVideoReferenceCore({
51
+ prompt: "x {image:5:ghost} y",
52
+ wiredCharRefs: [],
53
+ leadingRefUrls: [A],
54
+ extraRefs: [{ url: E1, description: "obj" }],
55
+ })
56
+ // count is 2 → {image:5} is out of range → bare label, never raw token
57
+ expect(r.prompt).toContain("x ghost y")
58
+ expect(r.prompt).not.toContain("{image:5")
59
+ expect(r.prompt).not.toContain("@image_5")
60
+ })
61
+
62
+ it("leading + 2 extras + referenceOrder: leading stays @image_1, assets renumber from offset+1", () => {
63
+ // assets e1,e2 are @image_2,@image_3; reorder swaps them → e2 first.
64
+ const r = resolveVideoReferenceCore({
65
+ prompt: "scene {image:2:first} and {image:3:second}",
66
+ wiredCharRefs: [],
67
+ leadingRefUrls: [A],
68
+ extraRefs: [
69
+ { url: E1, description: "first" },
70
+ { url: E2, description: "second" },
71
+ ],
72
+ referenceOrder: [`wired:${E2}`, `wired:${E1}`], // manual-extra tile-id = `wired:<id>`
73
+ })
74
+ // leading ref stays first; the two assets are swapped by the reorder
75
+ expect(r.additionalUrls[0]).toBe(A)
76
+ expect(r.additionalUrls.slice(1)).toEqual([E2, E1])
77
+ // leading ordinal 1 is never remapped; the asset ordinals stay within 2..3
78
+ expect(r.prompt).not.toMatch(/@image_(0|4|5)\b/)
79
+ })
80
+
81
+ it("no leadingRefUrls → unchanged legacy numbering (asset from @image_1)", () => {
82
+ const r = resolveVideoReferenceCore({
83
+ prompt: "a {image:1:object}",
84
+ wiredCharRefs: [],
85
+ extraRefs: [{ url: E1, description: "object" }],
86
+ })
87
+ expect(r.additionalUrls).toEqual([E1])
88
+ expect(r.prompt).toContain("the object from @image_1")
89
+ })
90
+ })
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolveVideoReferenceCore, REF_BINDING, resolveReferenceTokens } from "../video-reference-resolver.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ const charRef = (over: Partial<ConnectedReference>): ConnectedReference => ({
6
+ id: "n1", defaultName: "Kira", source: "wired-character", url: "https://r2/kira.png",
7
+ characterSlug: "kira", variantSlug: undefined, characterCanonicalDescription: null,
8
+ variantDescription: null, variantDisplayName: "canonical", ...over,
9
+ })
10
+
11
+ describe("resolveVideoReferenceCore — character canonical fallback", () => {
12
+ // NOTE: the brief's draft asserted `toContain("Image 1")` here, but that is
13
+ // wrong for the DEFAULT "identical" usage mode: the canonical fallback emits a
14
+ // name-based bullet (`- Kira. <directive>`), never `Image N` numbering (only
15
+ // "name"/"none" modes or extras are positionally numbered). This lift-and-shift
16
+ // is behavior-preserving, so the assertion is corrected to the directive the
17
+ // resolver actually emits — identical to the backend mirror test
18
+ // (`payload-builder-video-mentions.test.ts`: "falls back to canonical URL when
19
+ // character is wired but not @-mentioned"). The `Image N` numbering path is
20
+ // still covered by the manual-extras test below.
21
+ it("emits a 'Use these characters' block with the identity directive for an unmentioned wired character", () => {
22
+ const out = resolveVideoReferenceCore({
23
+ prompt: "a woman dancing",
24
+ wiredCharRefs: [charRef({})],
25
+ })
26
+ expect(out.prompt).toContain("Use these characters:")
27
+ expect(out.prompt).toContain("The subject must remain exactly the same person")
28
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
29
+ })
30
+ })
31
+
32
+ describe("resolveVideoReferenceCore — manual extras", () => {
33
+ it("numbers a manual extra after the characters and emits its bullet", () => {
34
+ const out = resolveVideoReferenceCore({
35
+ prompt: "scene",
36
+ wiredCharRefs: [charRef({})],
37
+ extraRefs: [{ url: "https://r2/x.png", description: "a red car" }],
38
+ })
39
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png", "https://r2/x.png"])
40
+ expect(out.prompt).toContain("@image_2 (reference): a red car")
41
+ })
42
+ })
43
+
44
+ // FE/BE PARITY GOLDEN CASES (Phase 1, Task 1.4).
45
+ //
46
+ // The frontend (`video-prompt-assembly.ts`) and backend (`payload-builder.ts`)
47
+ // video-reference resolvers now BOTH delegate to `resolveVideoReferenceCore`,
48
+ // so they cannot diverge by construction. These three cases pin the behaviors
49
+ // that previously lived duplicated across both layers: same-character pair-back,
50
+ // `referenceOrder` reorder + `@image_N` renumber, and the `usageMode: "none"`
51
+ // "URL attached, no bullet" contract. Each expected string was derived by
52
+ // reading the core's loops (and the `applyReferenceOrderToVideo` →
53
+ // `buildTileIdForUrl` tile-id scheme in prompt-builder.ts) and cross-checked
54
+ // against the backend mirror suite (`payload-builder-video-mentions.test.ts`).
55
+ describe("resolveVideoReferenceCore — FE/BE parity golden cases", () => {
56
+ // Case 1 — pair-back. A wired character (canonical Kira at Image 1, default
57
+ // "identical" mode) plus a SAME-character extra (`characterSlug: "kira"`).
58
+ // The extras loop sees `positionsByChar.get("kira") === 1`, so instead of a
59
+ // fresh identity directive it emits the pair-back bullet "@image_2 is the same
60
+ // subject as @image_1." (no per-ref description → no trailing ", <desc>").
61
+ // Mirrors the backend's "pairs character-sourced extras back…" test, which
62
+ // asserts the byte-identical wording.
63
+ it("pairs a same-character extra back to the canonical character (@image_2 is the same subject as @image_1)", () => {
64
+ const out = resolveVideoReferenceCore({
65
+ prompt: "scene",
66
+ wiredCharRefs: [charRef({})],
67
+ extraRefs: [{ url: "https://r2/kira-extra.png", characterSlug: "kira" }],
68
+ })
69
+ // Canonical Kira first, then the paired-back extra — both URLs attached.
70
+ expect(out.additionalUrls).toEqual([
71
+ "https://r2/kira.png",
72
+ "https://r2/kira-extra.png",
73
+ ])
74
+ // The extra's bullet is the same-subject pair-back, not a fresh directive.
75
+ expect(out.prompt).toContain("- @image_2 is the same subject as @image_1.")
76
+ })
77
+
78
+ // Case 2 — referenceOrder reorder + `@image_N` renumber. Two DISTINCT wired
79
+ // characters in "name" mode (the canonical-fallback branch that emits
80
+ // positional `- @image_N (Name)` bullets; the default "identical" mode emits
81
+ // a name-only bullet with NO ordinal, so it can't exercise renumbering).
82
+ // `referenceOrder` keys on the stable tile ids from `buildTileIdForUrl`: an
83
+ // unmentioned wired-character canonical URL → `char-canonical:<slug>`.
84
+ // Reversing the two ids must reverse `additionalUrls` AND renumber the
85
+ // bullets so each `@image_N` tracks its new slot.
86
+ it("applies referenceOrder: reverses additionalUrls AND renumbers the @image_N bullets to follow the new order", () => {
87
+ const kira = charRef({ defaultUsageMode: "name" }) // → "- @image_1 (Kira)", char-canonical:kira
88
+ const mira = charRef({
89
+ id: "n2",
90
+ defaultName: "Mira",
91
+ url: "https://r2/mira.png",
92
+ characterSlug: "mira",
93
+ defaultUsageMode: "name", // → "- @image_2 (Mira)", char-canonical:mira
94
+ })
95
+ const out = resolveVideoReferenceCore({
96
+ prompt: "two people talking",
97
+ wiredCharRefs: [kira, mira],
98
+ // Reverse: Mira's tile first, Kira's second.
99
+ referenceOrder: ["char-canonical:mira", "char-canonical:kira"],
100
+ })
101
+ // URL list reversed to match referenceOrder.
102
+ expect(out.additionalUrls).toEqual([
103
+ "https://r2/mira.png",
104
+ "https://r2/kira.png",
105
+ ])
106
+ // Bullets renumbered to follow the new order: Mira → @image_1, Kira → @image_2.
107
+ expect(out.prompt).toContain("- @image_1 (Mira)")
108
+ expect(out.prompt).toContain("- @image_2 (Kira)")
109
+ // The pre-reorder numbering is gone — proves the renumber pass actually ran
110
+ // (not just an unchanged prompt riding along with reordered URLs).
111
+ expect(out.prompt).not.toContain("- @image_1 (Kira)")
112
+ expect(out.prompt).not.toContain("- @image_2 (Mira)")
113
+ })
114
+
115
+ // Case 3 — usageMode "none" extra. A wired Kira (so the slug is known and
116
+ // canonically attached at @image_1) plus a same-character extra with
117
+ // `usageMode: "none"`. The extra's URL IS attached, but the pair-back branch's
118
+ // `effectiveMode !== "none"` guard suppresses any bullet for it — the
119
+ // minimal-intervention contract (image speaks for itself, no textual bias).
120
+ it("attaches a usageMode:'none' extra's URL but emits NO bullet for it", () => {
121
+ const out = resolveVideoReferenceCore({
122
+ prompt: "scene",
123
+ wiredCharRefs: [charRef({})],
124
+ extraRefs: [
125
+ { url: "https://r2/kira-extra.png", characterSlug: "kira", usageMode: "none" },
126
+ ],
127
+ })
128
+ // The extra's URL is still attached to the worker payload.
129
+ expect(out.additionalUrls).toEqual([
130
+ "https://r2/kira.png",
131
+ "https://r2/kira-extra.png",
132
+ ])
133
+ // Exactly one bullet (Kira's canonical) — the "none" extra adds none.
134
+ const bulletLines = (out.prompt ?? "").split("\n").filter((l) => l.startsWith("- "))
135
+ expect(bulletLines).toHaveLength(1)
136
+ // Neither a pair-back ("same subject") nor the extra's positional marker.
137
+ expect(out.prompt).not.toContain("same subject")
138
+ expect(out.prompt).not.toContain("@image_2")
139
+ })
140
+ })
141
+
142
+ // REF_BINDING — the single swap-point for the binding surface-string (D1/D7).
143
+ // This is purely additive in Task 2.1: defined + exported + tested here, but NOT
144
+ // yet wired into the core's logic (that is Task 2.2). The default form is
145
+ // `@image_N`; flipping to the legacy `Image N` form is editing the five arrows
146
+ // in `video-reference-resolver.ts` only.
147
+ describe("REF_BINDING", () => {
148
+ it("formats subject + ordinal + frame in @image_N form by default", () => {
149
+ expect(REF_BINDING.image("person", 2)).toBe("the person from @image_2")
150
+ expect(REF_BINDING.ordinal(1)).toBe("@image_1")
151
+ expect(REF_BINDING.frame(3, "opening")).toBe("Use @image_3 as the opening (first) frame of the video.")
152
+ })
153
+ })
154
+
155
+ // resolveReferenceTokens — rewrites editor `{image|video|audio:N:label}` tokens
156
+ // into `@image_N`-style subject bindings (Task 2.3). Positional, 1-based against
157
+ // the corresponding count. Out-of-range / missing count → drop to the bare label
158
+ // (legacy `stripVideoImageTokens` strip behavior). Label-less in-range token →
159
+ // "the subject in @kind_N" so the binding still lands. Purely additive in 2.3 —
160
+ // defined + exported + tested here, NOT yet called by the core (that is Task 2.4).
161
+ describe("resolveReferenceTokens", () => {
162
+ const counts = { image: 4, video: 0, audio: 0 }
163
+ it("resolves repeated labels distinctly by slot", () => {
164
+ expect(resolveReferenceTokens(
165
+ "{image:1:person} wearing {image:2:clothes} and {image:3:person} wearing {image:4:clothes}", counts))
166
+ .toBe("the person from @image_1 wearing the clothes from @image_2 and the person from @image_3 wearing the clothes from @image_4")
167
+ })
168
+ it("drops out-of-range tokens to bare label", () => {
169
+ expect(resolveReferenceTokens("a {image:9:ghost} here", counts)).toBe("a ghost here")
170
+ })
171
+ it("handles a label-less token", () => {
172
+ expect(resolveReferenceTokens("{image:1}", counts)).toBe("the subject in @image_1")
173
+ })
174
+ it("resolves video + audio tokens against their own counts", () => {
175
+ expect(resolveReferenceTokens("dance like {video:1:clip} to {audio:1:song}", { image: 0, video: 1, audio: 1 }))
176
+ .toBe("dance like the clip from @video_1 to the song from @audio_1")
177
+ })
178
+ // The docstring's stated intent is "runs of 2+ SPACES" — horizontal cleanup
179
+ // after a dropped label-less token. The collapse must NOT eat newline block
180
+ // separators (`\n\n` between the "Use these characters:" block and the body),
181
+ // which is exactly the surface this function is applied to once Task 2.4 wires
182
+ // it into the FULLY-ASSEMBLED core prompt. Guards against a regression back to
183
+ // `\s{2,}` (which also matches `\n`).
184
+ it("preserves \\n\\n block separators (collapses horizontal whitespace only)", () => {
185
+ expect(resolveReferenceTokens("Use these characters:\n- Kira.\n\n{image:1:person} runs", { image: 1, video: 0, audio: 0 }))
186
+ .toBe("Use these characters:\n- Kira.\n\nthe person from @image_1 runs")
187
+ })
188
+ it("still collapses the horizontal gap left by a dropped label-less token", () => {
189
+ expect(resolveReferenceTokens("walk past {image:9} slowly", { image: 1, video: 0, audio: 0 }))
190
+ .toBe("walk past slowly")
191
+ })
192
+ })
193
+
194
+ // Task 2.4 — the core resolves the editor's curly `{image:N}` / `{video:N}` /
195
+ // `{audio:N}` body tokens into `@image_N`-style subject bindings via
196
+ // resolveReferenceTokens, applied LAST (after mention/canonical/extras assembly
197
+ // AND after the optional `referenceOrder` reorder, so the reorder's `@image_N`
198
+ // renumber never touches a freshly-resolved body token). The positional counts
199
+ // come from the caller-declared reference-handle totals (`imageRefCount` /
200
+ // `videoRefCount` / `audioRefCount`); image falls back to the core's own merged
201
+ // URL count when the caller hasn't wired it through yet (pre-Tasks 3.2/4.1).
202
+ describe("resolveVideoReferenceCore — {image:N} body-token resolution (Task 2.4)", () => {
203
+ it("resolves {image:N} subject tokens in the body against the reference count", () => {
204
+ const out = resolveVideoReferenceCore({
205
+ prompt: "camera circles {image:1:person} for a 360 spin",
206
+ wiredCharRefs: [charRef({})],
207
+ imageRefCount: 1,
208
+ })
209
+ expect(out.prompt).toContain("camera circles the person from @image_1 for a 360 spin")
210
+ })
211
+
212
+ it("resolves {image:N} on the early-return path (no wired chars, no extras) using imageRefCount", () => {
213
+ const out = resolveVideoReferenceCore({
214
+ prompt: "a slow dolly toward {image:1:object} on the table",
215
+ wiredCharRefs: [],
216
+ imageRefCount: 1,
217
+ })
218
+ // Early return (no chars, no extras) — the whole prompt is the resolved body.
219
+ expect(out.prompt).toBe("a slow dolly toward the object from @image_1 on the table")
220
+ expect(out.additionalUrls).toEqual([])
221
+ })
222
+
223
+ it("preserves the canonical block's \\n\\n separator while resolving the body token (no \\s-collapse regression)", () => {
224
+ const out = resolveVideoReferenceCore({
225
+ prompt: "circle {image:1:person}",
226
+ wiredCharRefs: [charRef({})],
227
+ imageRefCount: 1,
228
+ })
229
+ // The block + body stay separated by the blank line; only the curly token
230
+ // changed. Proves the assembled `\n\n` survives the token-resolution pass.
231
+ expect(out.prompt).toContain("\n\ncircle the person from @image_1")
232
+ })
233
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ const kira = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
6
+ id: "k", defaultName: "Kira", source: "wired-character", url: "u-kira", characterSlug: "kira", ...over,
7
+ })
8
+
9
+ describe("video character convergence (hybridRoles)", () => {
10
+ it("hybridRoles: @-mention → 'the face from @image_1', no legacy block", () => {
11
+ const out = resolveVideoReferenceCore({
12
+ prompt: "@kira:1:face runs", wiredCharRefs: [kira()], hybridRoles: true,
13
+ })
14
+ expect(out.prompt).toContain("the face from @image_1 runs")
15
+ expect(out.prompt).not.toContain("Use these characters:")
16
+ expect(out.additionalUrls).toContain("u-kira")
17
+ })
18
+
19
+ it("hybridRoles: unmentioned character → canonical 'the person from @image_1', no block, no auto-lock", () => {
20
+ const out = resolveVideoReferenceCore({
21
+ prompt: "a slow dolly", wiredCharRefs: [kira()], hybridRoles: true,
22
+ })
23
+ expect(out.prompt).toContain("the person from @image_1")
24
+ expect(out.prompt).not.toContain("Lock the exact identity")
25
+ expect(out.prompt).not.toContain("Use these characters:")
26
+ })
27
+
28
+ it("opt-in lock appears when enabled", () => {
29
+ const out = resolveVideoReferenceCore({
30
+ prompt: "a portrait", wiredCharRefs: [kira({ identityLock: { enabled: true } })], hybridRoles: true,
31
+ })
32
+ expect(out.prompt).toContain("Lock the exact identity of the person in @image_1")
33
+ })
34
+
35
+ it("elementInjection preserved (unmentioned/canonical path)", () => {
36
+ const out = resolveVideoReferenceCore({
37
+ prompt: "a cafe", wiredCharRefs: [kira({ elementInjection: "holding a smartphone" })], hybridRoles: true,
38
+ })
39
+ expect(out.prompt?.toLowerCase()).toContain("holding a smartphone")
40
+ })
41
+
42
+ it("elementInjection preserved on the MENTIONED path (with role phrase)", () => {
43
+ const out = resolveVideoReferenceCore({
44
+ prompt: "@kira:1 walks in", wiredCharRefs: [kira({ elementInjection: "wearing a red scarf" })], hybridRoles: true,
45
+ })
46
+ expect(out.prompt?.toLowerCase()).toContain("red scarf")
47
+ expect(out.prompt).toContain("the person from @image_1")
48
+ expect(out.prompt).not.toContain("Use these characters:")
49
+ })
50
+
51
+ it("hybridRoles: manual extra surfaces description tied to @image_N, URL attached, no block, no legacy bullet", () => {
52
+ const out = resolveVideoReferenceCore({
53
+ prompt: "a still life",
54
+ wiredCharRefs: [],
55
+ extraRefs: [{ url: "u-lantern", description: "a brass lantern" }],
56
+ hybridRoles: true,
57
+ })
58
+ expect(out.prompt?.toLowerCase()).toContain("a brass lantern")
59
+ expect(out.prompt).toContain("@image_1")
60
+ expect(out.prompt).not.toContain("Use these characters:")
61
+ expect(out.prompt).not.toContain("(reference):") // legacy manual shape gone
62
+ expect(out.additionalUrls).toContain("u-lantern")
63
+ })
64
+
65
+ it("hybridRoles: same-character extra pairs back to the earlier @image_M", () => {
66
+ const out = resolveVideoReferenceCore({
67
+ prompt: "a duet",
68
+ wiredCharRefs: [kira()], // canonical → @image_1
69
+ extraRefs: [{ url: "u-kira-2", characterSlug: "kira", description: "mid-action" }],
70
+ lookupCharacterBySlug: () => ({ characterName: "Kira" }),
71
+ hybridRoles: true,
72
+ })
73
+ expect(out.prompt).toContain("@image_2 is the same subject as @image_1")
74
+ expect(out.prompt?.toLowerCase()).toContain("mid-action")
75
+ expect(out.additionalUrls).toEqual(["u-kira", "u-kira-2"])
76
+ })
77
+
78
+ it("hybridRoles: first-sight character extra → mapped role phrase + elementInjection, no block", () => {
79
+ const out = resolveVideoReferenceCore({
80
+ prompt: "a reveal",
81
+ wiredCharRefs: [],
82
+ extraRefs: [{ url: "u-vex", characterSlug: "vex", usageMode: "face", elementInjection: "wearing a red scarf" }],
83
+ lookupCharacterBySlug: () => ({ characterName: "Vex" }),
84
+ hybridRoles: true,
85
+ })
86
+ expect(out.prompt).toContain("the face from @image_1")
87
+ expect(out.prompt?.toLowerCase()).toContain("red scarf")
88
+ expect(out.prompt).not.toContain("Use these characters:")
89
+ expect(out.additionalUrls).toContain("u-vex")
90
+ })
91
+
92
+ it("LEGACY (hybridRoles false/default) unchanged → still the block", () => {
93
+ const out = resolveVideoReferenceCore({ prompt: "@kira:1 runs", wiredCharRefs: [kira()] })
94
+ expect(out.prompt).toContain("Use these characters:")
95
+ })
96
+ })
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ VOICE_AGES,
4
+ VOICE_GENDERS,
5
+ VOICE_ACCENTS,
6
+ VOICE_TIMBRES,
7
+ buildVoiceCharacterHints,
8
+ VOICE_CHARACTER_DEFAULT_DATA,
9
+ } from "../voice-character.js"
10
+
11
+ describe("voice-character catalogs", () => {
12
+ it("all four catalogs are non-empty with unique ids", () => {
13
+ for (const list of [VOICE_AGES, VOICE_GENDERS, VOICE_ACCENTS, VOICE_TIMBRES]) {
14
+ expect(list.length).toBeGreaterThan(0)
15
+ const ids = new Set(list.map((x) => x.id))
16
+ expect(ids.size).toBe(list.length)
17
+ }
18
+ })
19
+ })
20
+
21
+ describe("buildVoiceCharacterHints", () => {
22
+ it("returns empty for empty data", () => {
23
+ expect(buildVoiceCharacterHints({})).toBe("")
24
+ })
25
+
26
+ it("returns single field hint as bare phrase", () => {
27
+ const t = VOICE_TIMBRES[0]
28
+ expect(buildVoiceCharacterHints({ timbre: t.id })).toContain(t.promptHint)
29
+ })
30
+
31
+ it("composes [age] [gender] voice with [timbre] timbre and [accent] accent", () => {
32
+ const age = VOICE_AGES[0]
33
+ const gender = VOICE_GENDERS[0]
34
+ const accent = VOICE_ACCENTS[0]
35
+ const timbre = VOICE_TIMBRES[0]
36
+ const out = buildVoiceCharacterHints({
37
+ age: age.id, gender: gender.id, accent: accent.id, timbre: timbre.id,
38
+ })
39
+ expect(out).toContain(age.promptHint)
40
+ expect(out).toContain(gender.promptHint)
41
+ expect(out).toContain(accent.promptHint)
42
+ expect(out).toContain(timbre.promptHint)
43
+ })
44
+
45
+ it("falls back gracefully on unknown ids", () => {
46
+ expect(buildVoiceCharacterHints({ age: "not-real" })).toBe("")
47
+ })
48
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ VOICE_PACES,
4
+ VOICE_EMOTIONS,
5
+ VOICE_ARCHETYPES,
6
+ buildVoiceDeliveryHints,
7
+ VOICE_DELIVERY_DEFAULT_DATA,
8
+ } from "../voice-delivery.js"
9
+
10
+ describe("voice-delivery catalogs", () => {
11
+ it("all three catalogs are non-empty with unique ids", () => {
12
+ for (const list of [VOICE_PACES, VOICE_EMOTIONS, VOICE_ARCHETYPES]) {
13
+ expect(list.length).toBeGreaterThan(0)
14
+ const ids = new Set(list.map((x) => x.id))
15
+ expect(ids.size).toBe(list.length)
16
+ }
17
+ })
18
+ })
19
+
20
+ describe("buildVoiceDeliveryHints", () => {
21
+ it("returns empty for empty data", () => {
22
+ expect(buildVoiceDeliveryHints({})).toBe("")
23
+ })
24
+
25
+ it("composes [pace] [archetype]-style delivery, [emotion] tone", () => {
26
+ const pace = VOICE_PACES[0]
27
+ const emotion = VOICE_EMOTIONS[0]
28
+ const archetype = VOICE_ARCHETYPES[0]
29
+ const out = buildVoiceDeliveryHints({
30
+ pace: pace.id, emotion: emotion.id, archetype: archetype.id,
31
+ })
32
+ expect(out).toContain(pace.promptHint)
33
+ expect(out).toContain(emotion.promptHint)
34
+ expect(out).toContain(archetype.promptHint)
35
+ })
36
+
37
+ it("handles partial fields gracefully", () => {
38
+ const e = VOICE_EMOTIONS[0]
39
+ expect(buildVoiceDeliveryHints({ emotion: e.id })).toContain(e.promptHint)
40
+ })
41
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildWardrobeHints, getWardrobePromptHint, WARDROBE_DIMENSION_ORDER } from "../wardrobe.js"
3
+
4
+ describe("buildWardrobeHints", () => {
5
+ it("returns [] for empty value", () => {
6
+ expect(buildWardrobeHints({})).toEqual([])
7
+ })
8
+ it("emits single-pick hints in dimension order", () => {
9
+ const hints = buildWardrobeHints({ archetype: "wd-formal", top: "wd-blouse", colorPalette: "wd-all-black" })
10
+ expect(hints[0]).toBe(getWardrobePromptHint("wd-formal"))
11
+ expect(hints).toContain(getWardrobePromptHint("wd-blouse"))
12
+ expect(hints).toContain(getWardrobePromptHint("wd-all-black"))
13
+ })
14
+ it("suppresses the 'none' outerwear sentinel", () => {
15
+ expect(buildWardrobeHints({ outerwear: "wd-outer-none" })).toEqual([])
16
+ })
17
+ it("emits multi-pick accessories independently", () => {
18
+ const hints = buildWardrobeHints({ accessories: ["wd-glasses", "wd-scarf"] })
19
+ expect(hints).toContain(getWardrobePromptHint("wd-glasses"))
20
+ expect(hints).toContain(getWardrobePromptHint("wd-scarf"))
21
+ })
22
+ it("WARDROBE_DIMENSION_ORDER has no duplicates", () => {
23
+ expect(new Set(WARDROBE_DIMENSION_ORDER).size).toBe(WARDROBE_DIMENSION_ORDER.length)
24
+ })
25
+ })