@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,31 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ getCategoriesForNodeType,
4
+ isWizardSupported,
5
+ MUSIC_WIZARD_CATEGORIES,
6
+ } from "../prompt-wizard-categories.js"
7
+
8
+ describe("suno-generate:style wizard target", () => {
9
+ it("reuses MUSIC categories and is supported", () => {
10
+ // The composite `:style` target drives the SAME music wizard form as the
11
+ // base `suno-generate` node — it only changes the OUTPUT shape (tags vs
12
+ // prose), handled in the backend system-prompt builder.
13
+ expect(getCategoriesForNodeType("suno-generate:style")).toEqual(MUSIC_WIZARD_CATEGORIES)
14
+ expect(isWizardSupported("suno-generate:style")).toBe(true)
15
+ })
16
+ })
17
+
18
+ describe("suno-generate:negativeStyle + :lyrics wizard targets", () => {
19
+ // Task 5 extends the Phase-D composite-key pattern to two more Suno fields.
20
+ // BOTH reuse the SAME music wizard FORM as `:style`/`suno-generate`; only the
21
+ // backend OUTPUT framing differs (avoid-tags / sectioned lyrics).
22
+ it("negativeStyle reuses MUSIC categories and is supported", () => {
23
+ expect(getCategoriesForNodeType("suno-generate:negativeStyle")).toEqual(MUSIC_WIZARD_CATEGORIES)
24
+ expect(isWizardSupported("suno-generate:negativeStyle")).toBe(true)
25
+ })
26
+
27
+ it("lyrics reuses MUSIC categories and is supported", () => {
28
+ expect(getCategoriesForNodeType("suno-generate:lyrics")).toEqual(MUSIC_WIZARD_CATEGORIES)
29
+ expect(isWizardSupported("suno-generate:lyrics")).toBe(true)
30
+ })
31
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ PROVIDER_PROMPT_DOCTRINES,
4
+ getPromptDoctrine,
5
+ getPromptTips,
6
+ } from "../provider-prompt-doctrine.js"
7
+ import { MODEL_CATALOG } from "@nodaro/shared"
8
+
9
+ describe("PROVIDER_PROMPT_DOCTRINES", () => {
10
+ it("every doctrine maps to real catalog providers and has tips + body", () => {
11
+ expect(PROVIDER_PROMPT_DOCTRINES.length).toBeGreaterThan(0)
12
+ for (const d of PROVIDER_PROMPT_DOCTRINES) {
13
+ expect(d.providers.length).toBeGreaterThan(0)
14
+ for (const p of d.providers) expect(MODEL_CATALOG[p]).toBeDefined()
15
+ expect(d.tips.length).toBeGreaterThanOrEqual(3)
16
+ expect(d.tips.length).toBeLessThanOrEqual(6)
17
+ for (const t of d.tips) expect(t.length).toBeLessThanOrEqual(220)
18
+ expect(d.doctrine.length).toBeGreaterThan(500)
19
+ expect(d.heading.length).toBeGreaterThan(0)
20
+ }
21
+ })
22
+
23
+ it("resolves by provider id, returns undefined/[] for providers without doctrine", () => {
24
+ expect(getPromptDoctrine("seedance-2")).toBeDefined()
25
+ expect(getPromptDoctrine("seedance-2-fast")).toBeDefined()
26
+ expect(getPromptDoctrine("veo3.1")).toBeUndefined()
27
+ expect(getPromptTips("seedance-2").length).toBeGreaterThan(0)
28
+ expect(getPromptTips("veo3.1")).toEqual([])
29
+ })
30
+
31
+ it("seedance doctrine encodes the official rules and bans the unstable patterns", () => {
32
+ const d = getPromptDoctrine("seedance-2")!
33
+ // untimed Shot N storyboards, never "(0-3s)" timestamps
34
+ expect(d.doctrine).toContain("Shot 1")
35
+ expect(d.doctrine).toMatch(/timestamp/i)
36
+ expect(d.doctrine).not.toMatch(/Shot \d+\s*\(\d+\s*[-–]\s*\d+s\)/)
37
+ // headshot + full-body identity rule (NOT multi-view)
38
+ expect(d.doctrine).toMatch(/headshot/i)
39
+ expect(d.doctrine).toMatch(/multi-view|three-view/i)
40
+ // audio symbol semantics + Nodaro brace caveat
41
+ expect(d.doctrine).toContain("(")
42
+ expect(d.doctrine).toContain("<")
43
+ expect(d.doctrine).toMatch(/quoted|quotes/i)
44
+ // edit/extend "reference" keyword trap
45
+ expect(d.doctrine).toMatch(/reference Video/i)
46
+ // no native negative-prompt param
47
+ expect(d.doctrine).toMatch(/negative/i)
48
+ })
49
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolvePrompt, computeNodePrompt, composeNegative } from "../resolve-prompt.js"
3
+
4
+ // `appendWired` (generate-image / generate-video only): the connected (wired)
5
+ // prompt is APPENDED to the typed prompt instead of being a fallback. Every
6
+ // other node type keeps the legacy precedence (override > typed > wired).
7
+ const NO_REFS = new Map<string, string>()
8
+
9
+ describe("composeNegative", () => {
10
+ it("joins typed + wired with '. '", () => {
11
+ expect(composeNegative("blurry", "low-res")).toBe("blurry. low-res")
12
+ })
13
+ it("typed alone when no wired; wired alone when no typed", () => {
14
+ expect(composeNegative("blurry", undefined)).toBe("blurry")
15
+ expect(composeNegative(undefined, "low-res")).toBe("low-res")
16
+ })
17
+ it("drops empty / whitespace parts → ''", () => {
18
+ expect(composeNegative(" ", "")).toBe("")
19
+ expect(composeNegative(undefined, undefined)).toBe("")
20
+ })
21
+ })
22
+
23
+ describe("resolvePrompt — appendWired", () => {
24
+ it("appends wired to the typed base, joined '. '", () => {
25
+ expect(resolvePrompt({ typed: ["a cat"], wired: "shot on film", refMap: NO_REFS, appendWired: true }))
26
+ .toBe("a cat. shot on film")
27
+ })
28
+
29
+ it("typed only when wired is absent/blank", () => {
30
+ expect(resolvePrompt({ typed: ["a cat"], wired: " ", refMap: NO_REFS, appendWired: true })).toBe("a cat")
31
+ expect(resolvePrompt({ typed: ["a cat"], refMap: NO_REFS, appendWired: true })).toBe("a cat")
32
+ })
33
+
34
+ it("wired only when typed is empty", () => {
35
+ expect(resolvePrompt({ typed: [], wired: "shot on film", refMap: NO_REFS, appendWired: true })).toBe("shot on film")
36
+ })
37
+
38
+ it("an override (list fan-out item) fully replaces — wired does NOT append onto it", () => {
39
+ expect(resolvePrompt({ override: "OVR", typed: ["a cat"], wired: "film", refMap: NO_REFS, appendWired: true }))
40
+ .toBe("OVR")
41
+ })
42
+
43
+ it("WITHOUT appendWired: legacy precedence is byte-unchanged (typed wins, wired = fallback)", () => {
44
+ expect(resolvePrompt({ typed: ["a cat"], wired: "film", refMap: NO_REFS })).toBe("a cat")
45
+ expect(resolvePrompt({ typed: [], wired: "film", refMap: NO_REFS })).toBe("film")
46
+ expect(resolvePrompt({ override: "OVR", typed: ["a cat"], wired: "film", refMap: NO_REFS })).toBe("OVR")
47
+ })
48
+
49
+ it("computeNodePrompt threads appendWired for generate-image; other types unchanged", () => {
50
+ expect(
51
+ computeNodePrompt("generate-image", { prompt: "a cat" }, { wired: "film", refMap: NO_REFS, appendWired: true }),
52
+ ).toBe("a cat. film")
53
+ // video-to-video does NOT pass appendWired → wired stays a fallback
54
+ expect(
55
+ computeNodePrompt("video-to-video", { prompt: "a cat" }, { wired: "film", refMap: NO_REFS }),
56
+ ).toBe("a cat")
57
+ })
58
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolvePrompt, computeNodePrompt, computeLlmChatFields } from "../resolve-prompt.js"
3
+ const M = new Map<string, string>()
4
+ const R = new Map([["Hero", "a knight"]])
5
+ describe("resolvePrompt", () => {
6
+ it("override wins", () => expect(resolvePrompt({ override: "o", typed: ["t"], wired: "w", refMap: M })).toBe("o"))
7
+ it("first present typed candidate wins over wired", () =>
8
+ expect(resolvePrompt({ typed: [undefined, " ", "t2"], wired: "w", refMap: M })).toBe("t2"))
9
+ it("falls through empty typed to wired", () =>
10
+ expect(resolvePrompt({ typed: ["", " "], wired: "w", refMap: M })).toBe("w"))
11
+ it("nothing -> empty string", () => expect(resolvePrompt({ typed: [], refMap: M })).toBe(""))
12
+ it("resolves {Label} refs on the chosen branch", () =>
13
+ expect(resolvePrompt({ typed: ["x {Hero} y"], refMap: R })).toBe("x a knight y"))
14
+ })
15
+ describe("computeNodePrompt", () => {
16
+ const M = new Map<string, string>()
17
+ it("generate-image: typed prompt wins over wired", () =>
18
+ expect(computeNodePrompt("generate-image", { prompt: "typed" }, { wired: "wire", refMap: M })).toBe("typed"))
19
+ it("image-to-video: falls back to motionPrompt when prompt empty", () =>
20
+ expect(computeNodePrompt("image-to-video", { prompt: "", motionPrompt: "mp" }, { wired: "wire", refMap: M })).toBe("mp"))
21
+ it("text-to-audio: falls back to data.text", () =>
22
+ expect(computeNodePrompt("text-to-audio", { prompt: "", text: "t" }, { wired: "wire", refMap: M })).toBe("t"))
23
+ it("text-to-speech direct: uses directText", () =>
24
+ expect(computeNodePrompt("text-to-speech", { textSource: "direct", directText: "d" }, { wired: "wire", refMap: M })).toBe("d"))
25
+ it("text-to-speech connected: wire only (no typed)", () =>
26
+ expect(computeNodePrompt("text-to-speech", { textSource: "connected", directText: "ignored" }, { wired: "wire", refMap: M })).toBe("wire"))
27
+ it("video-retake: does not crash (not in NODE_MAPPABLE_FIELDS)", () =>
28
+ expect(computeNodePrompt("video-retake", { prompt: "vr" }, { wired: "w", refMap: M })).toBe("vr"))
29
+ it("social-publish: uses caption", () =>
30
+ expect(computeNodePrompt("social-publish", { caption: "c" }, { wired: "w", refMap: M })).toBe("c"))
31
+ it("override wins for any node", () =>
32
+ expect(computeNodePrompt("generate-image", { prompt: "typed" }, { wired: "w", override: "o", refMap: M })).toBe("o"))
33
+ it("unknown node defaults to ['prompt']", () =>
34
+ expect(computeNodePrompt("whatever", { prompt: "p" }, { refMap: M })).toBe("p"))
35
+ })
36
+ describe("computeLlmChatFields", () => {
37
+ const M = new Map<string, string>()
38
+ it("typed fields win; override beats typed userInput", () => {
39
+ expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s" },
40
+ { wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M })).toEqual({ userInput: "u", systemPrompt: "s" })
41
+ expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s" },
42
+ { override: "o", wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M }).userInput).toBe("o")
43
+ })
44
+ })
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unified Reference Roles — Phase D Task 1 (shared role-picker support).
3
+ *
4
+ * Two shared changes that back the editor role-picker (Tasks 2-3):
5
+ *
6
+ * (A) Custom-role VERBATIM relaxation in the HYBRID resolvers — a free-form
7
+ * role typed in a mention's variant/role slot (e.g. `@kira:1:earrings`)
8
+ * that doesn't resolve to a real matched variant now survives verbatim
9
+ * ("the earrings from …") instead of collapsing to the source default
10
+ * ("the person from …"). Applies to character (image + video) and location.
11
+ *
12
+ * (B) Location bare-slug ROLE parsing — a slash-less, non-mode 3rd segment now
13
+ * parses as a role (previously null → literal text). The F2 follow-up
14
+ * REMOVED the preset gate, so a CUSTOM slug (`@old-library:1:foobar`) parses
15
+ * as `role: "foobar"` too, mirroring the character parser. Legacy stays
16
+ * byte-identical NOT via the parser but via the resolver's `if (t.role)
17
+ * continue` guard — a role token (preset OR custom) is skipped in the legacy
18
+ * path, so it stays literal text with no bullet / phrase promotion.
19
+ * `normalizeRoleSlug` maps the slug back to the phrase key so the non-noun
20
+ * specials (`empty-background` → `empty background`) key `roleToPhrase`
21
+ * correctly; a custom slug passes through verbatim.
22
+ *
23
+ * Existing `@-mention` / variant / mode parsing + the legacy resolvers stay
24
+ * byte-identical — those suites are the guard.
25
+ */
26
+ import { describe, it, expect } from "vitest"
27
+ import { buildImagePrompt } from "../prompt-builder.js"
28
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
29
+ import { parseLocationMentionToken } from "@nodaro/shared"
30
+ import { normalizeRoleSlug } from "@nodaro/shared"
31
+ import type { ConnectedReference } from "@nodaro/shared"
32
+
33
+ const kira: ConnectedReference = {
34
+ id: "k", defaultName: "Kira", source: "wired-character",
35
+ url: "https://cdn/kira.png", characterSlug: "kira",
36
+ }
37
+ const library: ConnectedReference = {
38
+ id: "l", defaultName: "Old Library", source: "wired-location",
39
+ url: "https://cdn/library.png", locationSlug: "old-library",
40
+ }
41
+
42
+ describe("(A) character custom-role verbatim relaxation — image hybrid", () => {
43
+ it("PRESET role-only @kira:1:clothes → 'the clothes from reference image A' (pin)", () => {
44
+ const out = buildImagePrompt({
45
+ provider: "nano-banana-pro",
46
+ prompt: "@kira:1:clothes on a runway",
47
+ connectedReferences: [kira],
48
+ referenceFormat: "hybrid",
49
+ })
50
+ expect(out.prompt).toContain("the clothes from reference image A on a runway")
51
+ expect(out.prompt).not.toContain("Use these characters:")
52
+ })
53
+
54
+ it("CUSTOM role @kira:1:earrings → 'the earrings from reference image A' (NOT 'person')", () => {
55
+ const out = buildImagePrompt({
56
+ provider: "nano-banana-pro",
57
+ prompt: "@kira:1:earrings on a runway",
58
+ connectedReferences: [kira],
59
+ referenceFormat: "hybrid",
60
+ })
61
+ expect(out.prompt).toContain("the earrings from reference image A on a runway")
62
+ expect(out.prompt).not.toContain("the person from reference image A")
63
+ expect(out.referenceImageUrls).toContain("https://cdn/kira.png")
64
+ })
65
+ })
66
+
67
+ describe("(A) character custom-role verbatim relaxation — video hybrid", () => {
68
+ it("CUSTOM role @kira:1:earrings → 'the earrings from @image_1' (NOT 'person')", () => {
69
+ const out = resolveVideoReferenceCore({
70
+ prompt: "@kira:1:earrings spins", wiredCharRefs: [kira], hybridRoles: true,
71
+ })
72
+ expect(out.prompt).toContain("the earrings from @image_1 spins")
73
+ expect(out.prompt).not.toContain("the person from @image_1")
74
+ expect(out.additionalUrls).toContain("https://cdn/kira.png")
75
+ })
76
+
77
+ it("PRESET role @kira:1:face → 'the face from @image_1' (pin, unchanged)", () => {
78
+ const out = resolveVideoReferenceCore({
79
+ prompt: "@kira:1:face runs", wiredCharRefs: [kira], hybridRoles: true,
80
+ })
81
+ expect(out.prompt).toContain("the face from @image_1 runs")
82
+ })
83
+ })
84
+
85
+ describe("(B) location bare-slug role parsing — image hybrid", () => {
86
+ it("@old-library:1:background → 'the background from reference image A', token replaced inline", () => {
87
+ const out = buildImagePrompt({
88
+ provider: "nano-banana-pro",
89
+ prompt: "@old-library:1:background a chase scene",
90
+ connectedReferences: [library],
91
+ referenceFormat: "hybrid",
92
+ })
93
+ expect(out.prompt).toContain("the background from reference image A")
94
+ // Token resolved → no literal slug left behind, no legacy block.
95
+ expect(out.prompt).not.toContain("@old-library:1:background")
96
+ expect(out.prompt).not.toContain("Use these locations:")
97
+ expect(out.referenceImageUrls).toContain("https://cdn/library.png")
98
+ })
99
+
100
+ it("@old-library:1:empty-background → 'the background … (without its foreground objects)'", () => {
101
+ const out = buildImagePrompt({
102
+ provider: "nano-banana-pro",
103
+ prompt: "@old-library:1:empty-background a chase scene",
104
+ connectedReferences: [library],
105
+ referenceFormat: "hybrid",
106
+ })
107
+ expect(out.prompt).toContain(
108
+ "the background from reference image A (without its foreground objects)",
109
+ )
110
+ expect(out.prompt).not.toContain("@old-library:1:empty-background")
111
+ })
112
+
113
+ it("CUSTOM role @old-library:1:foobar → 'the foobar from reference image A' (verbatim, F2)", () => {
114
+ const out = buildImagePrompt({
115
+ provider: "nano-banana-pro",
116
+ prompt: "@old-library:1:foobar a chase scene",
117
+ connectedReferences: [library],
118
+ referenceFormat: "hybrid",
119
+ })
120
+ expect(out.prompt).toContain("the foobar from reference image A")
121
+ expect(out.prompt).not.toContain("@old-library:1:foobar")
122
+ expect(out.prompt).not.toContain("Use these locations:")
123
+ expect(out.referenceImageUrls).toContain("https://cdn/library.png")
124
+ })
125
+ })
126
+
127
+ describe("(B) location parser additive change — direct", () => {
128
+ it("@old-library:1:background parses as a role (slug stored verbatim)", () => {
129
+ expect(parseLocationMentionToken("@old-library:1:background")).toEqual({
130
+ locationSlug: "old-library", imageIndex: 1,
131
+ bucket: null, variant: null, usageMode: null, role: "background",
132
+ })
133
+ })
134
+
135
+ it("@old-library:1:empty-background parses as a role (slug form)", () => {
136
+ expect(parseLocationMentionToken("@old-library:1:empty-background")).toEqual({
137
+ locationSlug: "old-library", imageIndex: 1,
138
+ bucket: null, variant: null, usageMode: null, role: "empty-background",
139
+ })
140
+ })
141
+
142
+ it("real variant @old-library:1:weather/rain still parses as a variant (unchanged, no role)", () => {
143
+ expect(parseLocationMentionToken("@old-library:1:weather/rain")).toEqual({
144
+ locationSlug: "old-library", imageIndex: 1,
145
+ bucket: "weather", variant: "rain", usageMode: null,
146
+ })
147
+ })
148
+
149
+ it("CUSTOM bare slug @old-library:1:foobar now parses as a role (parser gate removed, F2)", () => {
150
+ expect(parseLocationMentionToken("@old-library:1:foobar")).toEqual({
151
+ locationSlug: "old-library", imageIndex: 1,
152
+ bucket: null, variant: null, usageMode: null, role: "foobar",
153
+ })
154
+ })
155
+
156
+ it("known mode @old-library:1:layout still parses as a usage mode (unchanged)", () => {
157
+ expect(parseLocationMentionToken("@old-library:1:layout")).toEqual({
158
+ locationSlug: "old-library", imageIndex: 1,
159
+ bucket: null, variant: null, usageMode: "layout",
160
+ })
161
+ })
162
+ })
163
+
164
+ describe("normalizeRoleSlug (slug ↔ phrase key)", () => {
165
+ it("maps the multi-word location preset slug back to its phrase key", () => {
166
+ expect(normalizeRoleSlug("empty-background")).toBe("empty background")
167
+ })
168
+ it("passes single-token + hyphenated-key roles through unchanged", () => {
169
+ expect(normalizeRoleSlug("background")).toBe("background")
170
+ expect(normalizeRoleSlug("as-is")).toBe("as-is")
171
+ })
172
+ it("passes free-form custom slugs through unchanged", () => {
173
+ expect(normalizeRoleSlug("rooftop-view")).toBe("rooftop-view")
174
+ })
175
+ })
176
+
177
+ describe("legacy (no referenceFormat) is NOT affected by the relaxation", () => {
178
+ it("legacy never emits the hybrid role phrase for char or location mentions", () => {
179
+ const charOut = buildImagePrompt({
180
+ provider: "nano-banana-pro",
181
+ prompt: "@kira:1:earrings poses",
182
+ connectedReferences: [kira],
183
+ })
184
+ expect(charOut.prompt).not.toContain("the earrings from reference image")
185
+
186
+ const locOut = buildImagePrompt({
187
+ provider: "nano-banana-pro",
188
+ prompt: "@old-library:1:background scene",
189
+ connectedReferences: [library],
190
+ })
191
+ expect(locOut.prompt).not.toContain("the background from reference image")
192
+ })
193
+
194
+ it("CUSTOM location role @old-library:1:foobar stays LITERAL in legacy (byte-identical, F2)", () => {
195
+ // The parser now accepts `foobar` as a role, but the legacy resolver's
196
+ // `if (t.role) continue` guard skips ANY role token (preset or custom): no
197
+ // inline replacement, no bullet, no `Use these locations:` block — the raw
198
+ // token survives verbatim exactly as it did pre-Phase-D (parser → null).
199
+ const out = buildImagePrompt({
200
+ provider: "nano-banana-pro",
201
+ prompt: "@old-library:1:foobar a chase scene",
202
+ connectedReferences: [library],
203
+ })
204
+ expect(out.prompt).toContain("@old-library:1:foobar")
205
+ expect(out.prompt).not.toContain("Use these locations:")
206
+ expect(out.prompt).not.toContain("the foobar from reference image")
207
+ })
208
+ })
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolveSeedance2Inputs } from "../seedance-2-inputs.js"
3
+
4
+ describe("resolveSeedance2Inputs", () => {
5
+ it("first frame only, no references → strict first-frame, no suffix", () => {
6
+ const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/first.png" })
7
+ expect(r.mode).toBe("first-frame")
8
+ expect(r.firstFrameUrl).toBe("https://a/first.png")
9
+ expect(r.lastFrameUrl).toBeUndefined()
10
+ expect(r.referenceImageUrls).toEqual([])
11
+ expect(r.promptSuffix).toBe("")
12
+ })
13
+
14
+ it("first + last frame, no references → strict first-last-frame", () => {
15
+ const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png" })
16
+ expect(r.mode).toBe("first-last-frame")
17
+ expect(r.firstFrameUrl).toBe("https://a/f.png")
18
+ expect(r.lastFrameUrl).toBe("https://a/l.png")
19
+ expect(r.promptSuffix).toBe("")
20
+ })
21
+
22
+ it("nothing connected → first-frame with no urls, no suffix", () => {
23
+ const r = resolveSeedance2Inputs({})
24
+ expect(r.mode).toBe("first-frame")
25
+ expect(r.firstFrameUrl).toBeUndefined()
26
+ expect(r.referenceImageUrls).toEqual([])
27
+ expect(r.promptSuffix).toBe("")
28
+ })
29
+
30
+ it("first frame + one reference image → reference mode, frame appended last, ordinal 2", () => {
31
+ const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/first.png", refImageUrls: ["https://a/ref.png"] })
32
+ expect(r.mode).toBe("reference")
33
+ expect(r.firstFrameUrl).toBeUndefined()
34
+ expect(r.lastFrameUrl).toBeUndefined()
35
+ expect(r.referenceImageUrls).toEqual(["https://a/ref.png", "https://a/first.png"])
36
+ expect(r.promptSuffix).toBe("Use @image_2 as the opening (first) frame of the video.")
37
+ })
38
+
39
+ it("first + last frame + two reference images → reference, frames at 3 and 4", () => {
40
+ const r = resolveSeedance2Inputs({
41
+ firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png",
42
+ refImageUrls: ["https://a/r1.png", "https://a/r2.png"],
43
+ })
44
+ expect(r.referenceImageUrls).toEqual(["https://a/r1.png", "https://a/r2.png", "https://a/f.png", "https://a/l.png"])
45
+ expect(r.promptSuffix).toBe("Use @image_3 as the opening (first) frame and @image_4 as the closing (last) frame of the video.")
46
+ })
47
+
48
+ it("reference audio + first frame (no images/videos) → reference mode, frame as Image 1, audio passed through", () => {
49
+ const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", refAudioUrls: ["https://a/voice.mp3"] })
50
+ expect(r.mode).toBe("reference")
51
+ expect(r.referenceImageUrls).toEqual(["https://a/f.png"])
52
+ expect(r.referenceAudioUrls).toEqual(["https://a/voice.mp3"])
53
+ expect(r.promptSuffix).toBe("Use @image_1 as the opening (first) frame of the video.")
54
+ })
55
+
56
+ it("last frame only, no first frame, no references → reference, closing-frame hint", () => {
57
+ const r = resolveSeedance2Inputs({ lastFrameUrl: "https://a/l.png" })
58
+ expect(r.mode).toBe("reference")
59
+ expect(r.referenceImageUrls).toEqual(["https://a/l.png"])
60
+ expect(r.promptSuffix).toBe("Use @image_1 as the closing (last) frame of the video.")
61
+ })
62
+
63
+ it("9 user images + first + last → frames kept, 2 user images dropped, ordinals 8 and 9", () => {
64
+ const refs = Array.from({ length: 9 }, (_, i) => `https://a/r${i}.png`)
65
+ const r = resolveSeedance2Inputs({ firstFrameUrl: "https://a/f.png", lastFrameUrl: "https://a/l.png", refImageUrls: refs })
66
+ expect(r.referenceImageUrls).toHaveLength(9)
67
+ expect(r.referenceImageUrls[7]).toBe("https://a/f.png")
68
+ expect(r.referenceImageUrls[8]).toBe("https://a/l.png")
69
+ expect(r.droppedRefImages).toBe(2)
70
+ expect(r.promptSuffix).toBe("Use @image_8 as the opening (first) frame and @image_9 as the closing (last) frame of the video.")
71
+ })
72
+
73
+ it("blank/whitespace urls are ignored", () => {
74
+ const r = resolveSeedance2Inputs({ firstFrameUrl: " ", refImageUrls: ["", " ", "https://a/r.png"] })
75
+ expect(r.mode).toBe("reference")
76
+ expect(r.referenceImageUrls).toEqual(["https://a/r.png"])
77
+ expect(r.promptSuffix).toBe("")
78
+ })
79
+
80
+ it("caps reference videos at 3 and audio at 3", () => {
81
+ const r = resolveSeedance2Inputs({
82
+ refImageUrls: ["https://a/r.png"],
83
+ refVideoUrls: ["v1", "v2", "v3", "v4"], refAudioUrls: ["a1", "a2", "a3", "a4"],
84
+ })
85
+ expect(r.referenceVideoUrls).toEqual(["v1", "v2", "v3"])
86
+ expect(r.referenceAudioUrls).toEqual(["a1", "a2", "a3"])
87
+ })
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Frame-ordinal ↔ array-position INVARIANT (drift guard).
91
+ //
92
+ // The frame `@image_N` numbers the resolver writes into `promptSuffix` must be
93
+ // the literal 1-based positions of `firstFrameUrl` / `lastFrameUrl` in the
94
+ // returned `referenceImageUrls`, AND must be strictly greater than the count
95
+ // of user reference images (frames always come AFTER user refs, never in a
96
+ // user-ref slot). This locks the resolver's internal numbering so a future
97
+ // refactor of the append order or suffix wording can't silently desync the
98
+ // ordinal it tells the model from where the frame actually sits in the array.
99
+ //
100
+ // NB: this guards the resolver in ISOLATION. The combined-case interaction
101
+ // (a @-mentioned character that also fills the i2v start-frame slot while
102
+ // other refs are present) is covered at the payload-builder + provider seam —
103
+ // see backend `seedance2-multimodal.test.ts` "combined case" tests.
104
+ // ---------------------------------------------------------------------------
105
+ describe("frame-ordinal ↔ array-position invariant", () => {
106
+ // Parse every `@image_N` ordinal out of the suffix in document order. The
107
+ // regex tolerates both the `@image_N` form (current) and a legacy `Image N`
108
+ // form so the invariant holds across the REF_BINDING swap-point flip.
109
+ const ordinalsIn = (suffix: string): number[] =>
110
+ Array.from(suffix.matchAll(/@?image[_ ](\d+)/gi)).map((m) => parseInt(m[1], 10))
111
+
112
+ const refShapes: Array<{ name: string; refs: string[] }> = [
113
+ { name: "0 user refs", refs: [] },
114
+ { name: "1 user ref", refs: ["https://a/u1.png"] },
115
+ { name: "2 user refs", refs: ["https://a/u1.png", "https://a/u2.png"] },
116
+ ]
117
+ const frameShapes: Array<{ name: string; first?: string; last?: string }> = [
118
+ { name: "first-only", first: "https://a/first.png" },
119
+ { name: "first+last", first: "https://a/first.png", last: "https://a/last.png" },
120
+ { name: "last-only", last: "https://a/last.png" },
121
+ ]
122
+
123
+ for (const rs of refShapes) {
124
+ for (const fs of frameShapes) {
125
+ it(`${rs.name} × ${fs.name}: suffix ordinals == array positions, all > user-ref count`, () => {
126
+ const r = resolveSeedance2Inputs({
127
+ firstFrameUrl: fs.first,
128
+ lastFrameUrl: fs.last,
129
+ refImageUrls: rs.refs,
130
+ })
131
+
132
+ // Strict first/last-frame mode (a first frame with NO references) does
133
+ // not number anything: frames ride `firstFrameUrl`/`lastFrameUrl` and
134
+ // the suffix is empty. The frame-ordinal invariant is reference-mode
135
+ // only, so assert the strict-mode contract and stop here.
136
+ if (r.mode !== "reference") {
137
+ expect(rs.refs.length).toBe(0)
138
+ expect(r.referenceImageUrls).toEqual([])
139
+ expect(r.promptSuffix).toBe("")
140
+ expect(r.firstFrameUrl).toBe(fs.first)
141
+ return
142
+ }
143
+
144
+ const userRefCount = rs.refs.length
145
+ const arr = r.referenceImageUrls
146
+
147
+ // 1-based position of each frame URL in the final array (0 = absent).
148
+ const firstPos = fs.first ? arr.indexOf(fs.first) + 1 : 0
149
+ const lastPos = fs.last ? arr.indexOf(fs.last) + 1 : 0
150
+
151
+ // Every frame URL present must actually be in the array.
152
+ if (fs.first) expect(firstPos).toBeGreaterThan(0)
153
+ if (fs.last) expect(lastPos).toBeGreaterThan(0)
154
+
155
+ // Frames sit strictly AFTER every user-ref slot (no collision).
156
+ if (firstPos) expect(firstPos).toBeGreaterThan(userRefCount)
157
+ if (lastPos) expect(lastPos).toBeGreaterThan(userRefCount)
158
+
159
+ // The suffix's `@image_N` ordinals are EXACTLY the array positions, in
160
+ // the order the suffix names them (first frame before last frame).
161
+ const expectedOrdinals: number[] = []
162
+ if (firstPos) expectedOrdinals.push(firstPos)
163
+ if (lastPos) expectedOrdinals.push(lastPos)
164
+ expect(ordinalsIn(r.promptSuffix)).toEqual(expectedOrdinals)
165
+
166
+ // User refs occupy the first `userRefCount` slots verbatim — frames
167
+ // never displace them.
168
+ expect(arr.slice(0, userRefCount)).toEqual(rs.refs)
169
+ })
170
+ }
171
+ }
172
+ })
173
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PROVIDER_CAPABILITIES } from "../index.js"
3
+ import { EXTEND_VIDEO_PROVIDERS, MODEL_CATALOG, SEEDANCE_2_EXTEND_STITCH, buildVideoCreditModelIdentifier } from "@nodaro/shared"
4
+
5
+ /**
6
+ * seedance-2-extend: extend ANY video by URL — bare-template continuation
7
+ * generation + trim-stitch (spike-validated).
8
+ */
9
+ describe("seedance-2-extend shared wiring", () => {
10
+ it("is a registered extend provider", () => {
11
+ expect(EXTEND_VIDEO_PROVIDERS).toContain("seedance-2-extend")
12
+ })
13
+
14
+ it("catalog entry drives the levers (extend mode, 4-15s, three resolutions)", () => {
15
+ const m = MODEL_CATALOG["seedance-2-extend"]!
16
+ expect(m.modes).toEqual(["extend"])
17
+ expect(m.family).toBe("Bytedance")
18
+ expect(m.durations).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
19
+ expect(m.resolutions).toEqual(["480p", "720p", "1080p"])
20
+ expect(m.pricing.length).toBeGreaterThanOrEqual(4)
21
+ expect(m.pricing[0]!.identifier).toBe("seedance-2-extend")
22
+ })
23
+
24
+ it("stitch constants match the spike-validated recipe (4 tail / 3 head / 0.15s fades)", () => {
25
+ expect(SEEDANCE_2_EXTEND_STITCH).toEqual({
26
+ trimTailFrames: 4,
27
+ trimHeadFrames: 3,
28
+ audioFadeSec: 0.15,
29
+ })
30
+ })
31
+
32
+ it("is advertised to the wizard's extend-video capabilities", () => {
33
+ expect(PROVIDER_CAPABILITIES["extend-video"]!["seedance-2-extend"]).toMatch(/ANY video/i)
34
+ })
35
+
36
+ it("credit identifiers snap to duration tiers with a :res suffix (no -ref dimension)", () => {
37
+ expect(buildVideoCreditModelIdentifier("seedance-2-extend", 6, undefined, undefined, undefined, "480p"))
38
+ .toBe("seedance-2-extend:8s:480p") // 6s snaps into the 8s tier
39
+ expect(buildVideoCreditModelIdentifier("seedance-2-extend", 4, undefined, undefined, undefined, "720p"))
40
+ .toBe("seedance-2-extend:4s:720p")
41
+ expect(buildVideoCreditModelIdentifier("seedance-2-extend", 15, undefined, undefined, undefined, "1080p"))
42
+ .toBe("seedance-2-extend:15s:1080p")
43
+ })
44
+ })