@nodaro/prompts 1.8.0 → 1.9.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/prompts",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "*"
23
+ "@nodaro/shared": "^2.13.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -3,8 +3,24 @@ import { FACTORY_PRESETS, getFactoryPresets, groupFactoryPresets } from "../fact
3
3
  import { extractPresetData, PRESET_APPLY_CLEAR_KEYS } from "@nodaro/shared"
4
4
  import { COMPOSER_PLAN_MAP, COMPOSER_PLAN_FIELDS } from "@nodaro/shared"
5
5
  import { STYLE_IDS } from "../index.js"
6
+ import { joinPromptParts } from "../resolve-prompt.js"
7
+ import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
6
8
  import { IMAGE_GEN_PROVIDERS, MODIFY_IMAGE_PROVIDERS, VIDEO_GEN_PROVIDERS, VIDEO_TO_VIDEO_PROVIDERS, MUSIC_PROVIDERS, SUNO_MODELS, TTS_PROVIDERS, TEXT_TO_AUDIO_PROVIDERS, ALL_CAPTION_STYLES, COMBINE_TRANSITION_IDS, AUDIO_CROSSFADE_CURVE_IDS, aspectRatioOptionsByKind, durationsByMode, IMAGE_PROMPT_MAX, MODEL_CATALOG, NATIVE_NEGATIVE_VIDEO_PROVIDERS } from "@nodaro/shared"
7
9
 
10
+ /**
11
+ * The text a preset ACTUALLY sends: a preset may ship its doctrine as pre/post
12
+ * text (`promptPrefix` / `promptSuffix`) with the `prompt` field left free for
13
+ * the user — see docs/prompt-pre-post-text.md. Every content guard below reads
14
+ * the ASSEMBLED prompt through this helper (same join rule the run uses) so a
15
+ * prompt→affix move can never silently drop a clause or blow a length cap.
16
+ */
17
+ const effectivePrompt = (d: Record<string, unknown>) =>
18
+ joinPromptParts([
19
+ d.promptPrefix as string | undefined,
20
+ d.prompt as string | undefined,
21
+ d.promptSuffix as string | undefined,
22
+ ])
23
+
8
24
  describe("FACTORY_PRESETS", () => {
9
25
  it("has presets for generate-image", () => {
10
26
  expect(getFactoryPresets("generate-image").length).toBeGreaterThan(0)
@@ -74,7 +90,7 @@ describe("Seedance Director factory presets", () => {
74
90
  for (const p of pack) {
75
91
  const prompt = p.data.prompt as string
76
92
  expect(prompt).not.toMatch(/\(\s*\d+\s*[-–]\s*\d+\s*s\s*\)/i) // "(0-3s)" style
77
- expect(prompt.length).toBeLessThanOrEqual(2000)
93
+ expect(effectivePrompt(p.data).length).toBeLessThanOrEqual(2000)
78
94
  expect(prompt).toContain("subtitle-free")
79
95
  }
80
96
  })
@@ -157,7 +173,7 @@ describe("generate-image factory preset data validity", () => {
157
173
 
158
174
  it("respects prompt (IMAGE_PROMPT_MAX) and negativePrompt (5000) length caps", () => {
159
175
  for (const p of presets) {
160
- const prompt = (p.data.prompt as string | undefined) ?? ""
176
+ const prompt = effectivePrompt(p.data)
161
177
  const neg = (p.data.negativePrompt as string | undefined) ?? ""
162
178
  expect(prompt.length, `${p.id}: prompt exceeds cap`).toBeLessThanOrEqual(IMAGE_PROMPT_MAX)
163
179
  expect(neg.length, `${p.id}: negativePrompt exceeds 5000 chars`).toBeLessThanOrEqual(5000)
@@ -211,7 +227,7 @@ describe("generate-image factory preset data validity", () => {
211
227
  const boards = presets.filter((p) => p.group === "Reference Sheet")
212
228
  expect(boards.length).toBeGreaterThanOrEqual(11)
213
229
  for (const b of boards) {
214
- expect(b.data.prompt as string, `${b.id}: missing the never-merge-panels clause`).toContain(
230
+ expect(effectivePrompt(b.data), `${b.id}: missing the never-merge-panels clause`).toContain(
215
231
  "never merging or omitting a panel",
216
232
  )
217
233
  }
@@ -239,7 +255,7 @@ describe("generate-image factory preset data validity", () => {
239
255
  const handmade = presets.filter((p) => p.group === "Handmade & Stop-Motion")
240
256
  expect(handmade.length).toBeGreaterThanOrEqual(6)
241
257
  for (const p of handmade) {
242
- expect(p.data.prompt as string, `${p.id}: missing the NOT digital CG clause`).toContain("NOT digital CG")
258
+ expect(effectivePrompt(p.data), `${p.id}: missing the NOT digital CG clause`).toContain("NOT digital CG")
243
259
  }
244
260
  })
245
261
 
@@ -264,7 +280,7 @@ describe("generate-image factory preset data validity", () => {
264
280
  it("non-style-pinned generate-image prompts are substantive (no thin one-liners)", () => {
265
281
  for (const p of presets) {
266
282
  if (p.data.style !== undefined) continue // style field carries the look; short prompt is fine
267
- const prompt = (p.data.prompt as string | undefined) ?? ""
283
+ const prompt = effectivePrompt(p.data)
268
284
  expect(prompt.trim().length, `${p.id}: prompt too thin (${prompt.length} chars)`).toBeGreaterThanOrEqual(40)
269
285
  }
270
286
  })
@@ -296,7 +312,7 @@ describe("modify-image factory preset data validity", () => {
296
312
 
297
313
  it("respects the prompt 2000-char cap", () => {
298
314
  for (const p of presets) {
299
- const prompt = (p.data.prompt as string | undefined) ?? ""
315
+ const prompt = effectivePrompt(p.data)
300
316
  expect(prompt.length, `${p.id}: prompt exceeds 2000 chars`).toBeLessThanOrEqual(2000)
301
317
  }
302
318
  })
@@ -349,7 +365,7 @@ describe("generate-video factory preset data validity", () => {
349
365
 
350
366
  it("respects prompt/negativePrompt length caps (2500)", () => {
351
367
  for (const p of presets) {
352
- expect(((p.data.prompt as string) ?? "").length, `${p.id}: prompt too long`).toBeLessThanOrEqual(2500)
368
+ expect(effectivePrompt(p.data).length, `${p.id}: prompt too long`).toBeLessThanOrEqual(2500)
353
369
  expect(((p.data.negativePrompt as string) ?? "").length, `${p.id}: negativePrompt too long`).toBeLessThanOrEqual(2500)
354
370
  }
355
371
  })
@@ -737,7 +753,7 @@ describe("video-to-video factory preset data validity", () => {
737
753
 
738
754
  it("respects prompt (5000) and negativePrompt (500) caps", () => {
739
755
  for (const p of presets) {
740
- expect(((p.data.prompt as string) ?? "").length, `${p.id}: prompt > 5000`).toBeLessThanOrEqual(5000)
756
+ expect(effectivePrompt(p.data).length, `${p.id}: prompt > 5000`).toBeLessThanOrEqual(5000)
741
757
  expect(((p.data.negativePrompt as string) ?? "").length, `${p.id}: negativePrompt > 500`).toBeLessThanOrEqual(500)
742
758
  }
743
759
  })
@@ -1006,13 +1022,19 @@ describe("presets don't bake config-field values (framing) into the prompt", ()
1006
1022
  const ratioRe = new RegExp(`(^|[^0-9.])(${RATIO_TOKENS.map((t) => t.replace(/\./g, "\\.")).join("|")})([^0-9]|$)`)
1007
1023
 
1008
1024
  it("no factory preset prompt names an aspect ratio", () => {
1025
+ // Checks all three prompt-bearing fields: a preset may ship its doctrine as
1026
+ // pre/post text, and a ratio smuggled into an affix reaches the model just
1027
+ // the same. The message names the offending field.
1009
1028
  const offenders: string[] = []
1010
1029
  for (const [, presets] of Object.entries(FACTORY_PRESETS)) {
1011
1030
  for (const p of presets) {
1012
- const prompt = (p.data as Record<string, unknown>).prompt
1013
- if (typeof prompt !== "string") continue
1014
- const m = prompt.match(ratioRe)
1015
- if (m) offenders.push(`${p.id} → "${m[2]}"`)
1031
+ const d = p.data as Record<string, unknown>
1032
+ for (const field of ["promptPrefix", "prompt", "promptSuffix"] as const) {
1033
+ const text = d[field]
1034
+ if (typeof text !== "string") continue
1035
+ const m = text.match(ratioRe)
1036
+ if (m) offenders.push(`${p.id}.${field} → "${m[2]}"`)
1037
+ }
1016
1038
  }
1017
1039
  }
1018
1040
  expect(
@@ -1022,6 +1044,34 @@ describe("presets don't bake config-field values (framing) into the prompt", ()
1022
1044
  })
1023
1045
  })
1024
1046
 
1047
+ describe("factory preset pre/post text (promptPrefix / promptSuffix)", () => {
1048
+ // A preset may ship its doctrine as pre/post text so the prompt field stays
1049
+ // free for the user (docs/prompt-pre-post-text.md). Two invariants: the node
1050
+ // type must actually support affixes — otherwise the text is dead data the
1051
+ // run never wraps — and a shipped affix must carry real content, since a
1052
+ // blank one is dropped by the join and silently loses the doctrine.
1053
+ it("every affix-carrying factory preset is affix-capable and ships no empty affix", () => {
1054
+ for (const [nodeType, presets] of Object.entries(FACTORY_PRESETS)) {
1055
+ for (const p of presets) {
1056
+ const d = p.data as Record<string, unknown>
1057
+ const affixes = (["promptPrefix", "promptSuffix"] as const).filter(
1058
+ (k) => d[k] !== undefined,
1059
+ )
1060
+ if (affixes.length === 0) continue
1061
+ expect(
1062
+ nodeSupportsPromptAffixes(nodeType),
1063
+ `${p.id}: ships ${affixes.join("/")} but "${nodeType}" does not support pre/post text`,
1064
+ ).toBe(true)
1065
+ for (const k of affixes) {
1066
+ const v = d[k]
1067
+ expect(typeof v, `${p.id}: ${k} must be a string`).toBe("string")
1068
+ expect((v as string).trim().length, `${p.id}: ${k} is blank`).toBeGreaterThan(0)
1069
+ }
1070
+ }
1071
+ }
1072
+ })
1073
+ })
1074
+
1025
1075
  describe("factory-presets split integrity", () => {
1026
1076
  // The exact node-type keys, in the exact insertion order the single-file
1027
1077
  // catalog declared them. The split's index.ts must reproduce this order.
@@ -1182,7 +1182,7 @@
1182
1182
  }
1183
1183
  },
1184
1184
  "ctx": null,
1185
- "expected": "macro lens, extreme close-up revealing fine detail with shallow depth of field"
1185
+ "expected": "macro lens, capable of extreme close focus and life-size magnification with a very shallow depth of field"
1186
1186
  },
1187
1187
  {
1188
1188
  "key": "lens/id:petzval",
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { NODE_PROMPT_FIELDS, nodeSupportsPromptAffixes, PROMPT_AFFIX_NODE_TYPES, getPromptFields, promptAffixCoreField, promptFieldCarriesAffixes, PROMPT_AFFIX_CORE_FIELD_OVERRIDES } from "../node-prompt-fields.js"
3
+ import { NODE_PROMPT_CANDIDATE_FIELDS } from "../resolve-prompt.js"
4
+
5
+ describe("nodeSupportsPromptAffixes", () => {
6
+ it("is true for every registered prompt node except explicit opt-outs", () => {
7
+ for (const [type, spec] of Object.entries(NODE_PROMPT_FIELDS)) {
8
+ expect(nodeSupportsPromptAffixes(type)).toBe(spec.affixes !== false)
9
+ }
10
+ })
11
+ it("text-prompt (plain Text input) opts out", () => expect(nodeSupportsPromptAffixes("text-prompt")).toBe(false))
12
+ it("generate-image / llm-chat / video-analysis support affixes", () => {
13
+ for (const t of ["generate-image", "llm-chat", "video-analysis"]) expect(nodeSupportsPromptAffixes(t)).toBe(true)
14
+ })
15
+ it("unknown / undefined → false", () => {
16
+ expect(nodeSupportsPromptAffixes("not-a-node")).toBe(false)
17
+ expect(nodeSupportsPromptAffixes(undefined)).toBe(false)
18
+ })
19
+ it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (37 types)", () => {
20
+ const expected = Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t)
21
+ expect([...PROMPT_AFFIX_NODE_TYPES].sort()).toEqual(expected.sort())
22
+ expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(37)
23
+ })
24
+ it("getPromptFields still resolves", () => expect(getPromptFields("generate-image")?.prompt).toBe("prompt"))
25
+ })
26
+
27
+ describe("promptAffixCoreField — the data key the RUN wraps with pre/post text", () => {
28
+ it("most nodes: the editor's primary prompt field", () => {
29
+ expect(promptAffixCoreField("generate-image")).toBe("prompt")
30
+ expect(promptAffixCoreField("llm-chat")).toBe("userInput")
31
+ expect(promptAffixCoreField("text-to-speech")).toBe("directText")
32
+ })
33
+ it("generate-script: the run wraps the topic `prompt`, NOT the editor's styleGuide (spec §7)", () => {
34
+ expect(getPromptFields("generate-script")?.prompt).toBe("styleGuide")
35
+ expect(promptAffixCoreField("generate-script")).toBe("prompt")
36
+ })
37
+ it("nodes with no affixes have no core field", () => {
38
+ expect(promptAffixCoreField("text-prompt")).toBeUndefined()
39
+ expect(promptAffixCoreField("not-a-node")).toBeUndefined()
40
+ expect(promptAffixCoreField(undefined)).toBeUndefined()
41
+ })
42
+ it("every affix-capable type resolves to a core field", () => {
43
+ for (const t of PROMPT_AFFIX_NODE_TYPES) expect(promptAffixCoreField(t)).toBeTruthy()
44
+ })
45
+ it("every override key is an affix-capable node type", () => {
46
+ for (const t of Object.keys(PROMPT_AFFIX_CORE_FIELD_OVERRIDES)) {
47
+ expect(PROMPT_AFFIX_NODE_TYPES.has(t)).toBe(true)
48
+ }
49
+ })
50
+ })
51
+
52
+ describe("promptFieldCarriesAffixes — does previewing this key show the run's affixes?", () => {
53
+ it("the core field carries them; a sibling field does not", () => {
54
+ expect(promptFieldCarriesAffixes("llm-chat", "userInput")).toBe(true)
55
+ expect(promptFieldCarriesAffixes("llm-chat", "systemPrompt")).toBe(false)
56
+ expect(promptFieldCarriesAffixes("generate-script", "prompt")).toBe(true)
57
+ expect(promptFieldCarriesAffixes("generate-script", "styleGuide")).toBe(false)
58
+ })
59
+ it("a run-time FALLBACK candidate carries them too (i2v's legacy motionPrompt)", () => {
60
+ // computeNodePrompt picks the first present of ["prompt", "motionPrompt"]
61
+ // and wraps THAT with the affixes — so previewing motionPrompt must wrap.
62
+ expect(promptFieldCarriesAffixes("image-to-video", "motionPrompt")).toBe(true)
63
+ expect(promptFieldCarriesAffixes("generate-video", "motionPrompt")).toBe(true)
64
+ expect(promptFieldCarriesAffixes("text-to-audio", "text")).toBe(true)
65
+ })
66
+ it("an affix-less node never carries them, whatever the field", () => {
67
+ expect(promptFieldCarriesAffixes("text-prompt", "text")).toBe(false)
68
+ expect(promptFieldCarriesAffixes("not-a-node", "prompt")).toBe(false)
69
+ })
70
+ it("omitting the field falls back to the node's primary prompt field", () => {
71
+ expect(promptFieldCarriesAffixes("generate-image", undefined)).toBe(true)
72
+ // generate-script's primary (styleGuide) is NOT the core → no wrap.
73
+ expect(promptFieldCarriesAffixes("generate-script", undefined)).toBe(false)
74
+ expect(promptFieldCarriesAffixes("text-prompt", undefined)).toBe(false)
75
+ })
76
+ it("no override type also declares run-time candidates (the two rules never collide)", () => {
77
+ for (const t of Object.keys(PROMPT_AFFIX_CORE_FIELD_OVERRIDES)) {
78
+ expect(NODE_PROMPT_CANDIDATE_FIELDS[t]).toBeUndefined()
79
+ }
80
+ })
81
+ })
@@ -134,7 +134,10 @@ const SAMPLE_DATA_BY_TYPE: Record<string, Record<string, unknown>> = {
134
134
  "held-prop": { heldProp: firstId(HELD_PROPS) },
135
135
  "exposure-settings": { aperture: firstId(EXPOSURE_SETTINGS) },
136
136
  "render-quality": { renderQuality: firstId(RENDER_QUALITIES) },
137
- "composition-effects": { compositionEffect: firstId(COMPOSITION_EFFECTS) },
137
+ // Not firstId: the head of the catalog is the neutral "none" entry, whose
138
+ // promptHint is deliberately empty (same reason transition/character-fx below
139
+ // name a real id instead of their "auto" head).
140
+ "composition-effects": { compositionEffect: "bursting-through-frame" },
138
141
  "post-process-effects": { postProcess: firstId(POST_PROCESS_EFFECTS) },
139
142
  "action-fx": { actionFx: firstId(ACTION_FX) },
140
143
  // TRANSITIONS[0] is "auto" with an intentionally empty promptHint — use a
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ promptPartSeparator,
4
+ joinPromptParts,
5
+ applyPromptAffixes,
6
+ resolvePrompt,
7
+ computeNodePrompt,
8
+ computeLlmChatFields,
9
+ } from "../resolve-prompt.js"
10
+
11
+ const M = new Map<string, string>()
12
+ const R = new Map([["Character", "Mira"], ["Hero", "a knight"]])
13
+
14
+ describe("promptPartSeparator", () => {
15
+ it("inserts a space between two words", () => expect(promptPartSeparator("a", "b")).toBe(" "))
16
+ it("no space when left ends with whitespace", () => expect(promptPartSeparator("a ", "b")).toBe(""))
17
+ it("no space when left ends with a newline", () => expect(promptPartSeparator("a\n\n", "b")).toBe(""))
18
+ it("no space when right starts with whitespace", () => expect(promptPartSeparator("a", " b")).toBe(""))
19
+ it.each([",", ".", ";", ":", "!", "?", ")"])("no space when right starts with %s", (p) =>
20
+ expect(promptPartSeparator("a", `${p} b`)).toBe(""))
21
+ })
22
+
23
+ describe("joinPromptParts (spec §4.2)", () => {
24
+ it("prefix + core + comma-suffix", () =>
25
+ expect(joinPromptParts(["Cinematic 35mm still of", "a woman in Tokyo", ", golden hour"]))
26
+ .toBe("Cinematic 35mm still of a woman in Tokyo, golden hour"))
27
+ it("keeps an author's trailing newlines", () =>
28
+ expect(joinPromptParts(["RULES:\n- no text\n\n", "a red shoe", undefined])).toBe("RULES:\n- no text\n\na red shoe"))
29
+ it("core + sentence suffix", () =>
30
+ expect(joinPromptParts([undefined, "a red shoe", "Avoid clutter."])).toBe("a red shoe Avoid clutter."))
31
+ it("drops blank parts", () => expect(joinPromptParts([" ", "x", ""])).toBe("x"))
32
+ it("all blank → empty string", () => expect(joinPromptParts(["", undefined, " "])).toBe(""))
33
+ })
34
+
35
+ describe("applyPromptAffixes", () => {
36
+ it("no affixes → returns the SAME core reference", () => {
37
+ const core = "a red shoe"
38
+ expect(applyPromptAffixes(core, undefined, M)).toBe(core)
39
+ expect(applyPromptAffixes(core, {}, M)).toBe(core)
40
+ expect(applyPromptAffixes(undefined, {}, M)).toBeUndefined()
41
+ })
42
+ it("whitespace-only affixes are a no-op", () => {
43
+ const core = "x"
44
+ expect(applyPromptAffixes(core, { prefix: " ", suffix: "" }, M)).toBe(core)
45
+ })
46
+ it("wraps with the join rule", () =>
47
+ expect(applyPromptAffixes("a woman in Tokyo", { prefix: "Cinematic 35mm still of", suffix: ", golden hour" }, M))
48
+ .toBe("Cinematic 35mm still of a woman in Tokyo, golden hour"))
49
+ it("resolves {Label} refs inside the affixes", () =>
50
+ expect(applyPromptAffixes("", { prefix: "Portrait of {Character}" }, R)).toBe("Portrait of Mira"))
51
+ it("empty core + affixes → the affixes alone", () =>
52
+ expect(applyPromptAffixes(undefined, { prefix: "PRE", suffix: "POST" }, M)).toBe("PRE POST"))
53
+ it("does not touch the core (no ref resolution, no trim)", () =>
54
+ expect(applyPromptAffixes(" {Hero} ", { suffix: "S" }, R)).toBe(" {Hero} S"))
55
+ })
56
+
57
+ describe("resolvePrompt × affixes", () => {
58
+ const A = { prefix: "PRE", suffix: "POST" }
59
+ it("wraps the override", () =>
60
+ expect(resolvePrompt({ override: "o", typed: ["t"], wired: "w", refMap: M, affixes: A })).toBe("PRE o POST"))
61
+ it("wraps the typed winner", () =>
62
+ expect(resolvePrompt({ typed: ["t"], wired: "w", refMap: M, affixes: A })).toBe("PRE t POST"))
63
+ it("wraps the wired fallback", () =>
64
+ expect(resolvePrompt({ typed: [""], wired: "w", refMap: M, affixes: A })).toBe("PRE w POST"))
65
+ it("appendWired: wraps the COMBINED typed. wired core", () =>
66
+ expect(resolvePrompt({ typed: ["t"], wired: "w", refMap: M, appendWired: true, affixes: A })).toBe("PRE t. w POST"))
67
+ it("nothing + affixes → affixes alone", () =>
68
+ expect(resolvePrompt({ typed: [], refMap: M, affixes: A })).toBe("PRE POST"))
69
+ it("without affixes is byte-identical to before", () =>
70
+ expect(resolvePrompt({ typed: ["x {Hero} y"], refMap: R })).toBe("x a knight y"))
71
+ })
72
+
73
+ describe("computeNodePrompt / computeLlmChatFields read affixes off data", () => {
74
+ it("generate-image", () =>
75
+ expect(computeNodePrompt("generate-image", { prompt: "typed", promptPrefix: "PRE", promptSuffix: "POST" }, { wired: "wire", refMap: M }))
76
+ .toBe("PRE typed POST"))
77
+ it("text-to-speech direct", () =>
78
+ expect(computeNodePrompt("text-to-speech", { textSource: "direct", directText: "d", promptSuffix: "POST" }, { refMap: M }))
79
+ .toBe("d POST"))
80
+ it("llm-chat: userInput wrapped, systemPrompt never", () =>
81
+ expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s", promptPrefix: "PRE" }, { refMap: M }))
82
+ .toEqual({ userInput: "PRE u", systemPrompt: "s" }))
83
+ })
@@ -48,4 +48,22 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
48
48
  // no native negative-prompt param
49
49
  expect(d.doctrine).toMatch(/negative/i)
50
50
  })
51
+
52
+ it("splits the timestamp + multi-view rules by version: 2.0 SKUs no, seedance-2-5 yes (official 2.5 guide)", () => {
53
+ // Official "Dreamina Seedance 2.5 prompt guide" → Differences from Seedance 2.0:
54
+ // 2.0 does not respond to timestamps (shot numbers only) and multi-view
55
+ // subject images are not recommended; 2.5 supports integer-second
56
+ // timestamps and multi-view references. The doctrine is one group for the
57
+ // whole family, so BOTH halves must be stated — a rewrite that drops
58
+ // either side re-applies a 2.0 rule to 2.5 (or vice versa).
59
+ const d = getPromptDoctrine("seedance-2-5")!
60
+ expect(d.providers).toContain("seedance-2")
61
+ expect(d.doctrine).toMatch(/2\.0[^.]*(ignore|do not respond to|respond to shot numbers only)[^.]*timestamps|timestamps[^.]*2\.0/i)
62
+ expect(d.doctrine).toMatch(/seedance-2-5[^.]*integer-second timestamps|integer-second timestamps[^.]*2\.5/i)
63
+ expect(d.doctrine).toMatch(/At the 5-second mark/)
64
+ expect(d.doctrine).toMatch(/2\.5 accepts multi-view|supported on 2\.5/i)
65
+ const tips = getPromptTips("seedance-2-5").join(" ")
66
+ expect(tips).toMatch(/2\.0 SKUs ignore timestamps/)
67
+ expect(tips).toMatch(/seedance-2-5 honours integer-second timestamps/)
68
+ })
51
69
  })
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The transition node's three timing parameters (position / duration /
3
+ * intensity) are enumerable catalogs, not free values, so a consumer that can
4
+ * only send ids — Studio, the SDK, MCP — can offer them without composing any
5
+ * prompt text of its own.
6
+ *
7
+ * Two properties matter, and both are easy to lose silently:
8
+ *
9
+ * 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
10
+ * the same string. They are derived from one array in `transitions.ts`, and
11
+ * these tests fail the moment someone re-introduces a second copy.
12
+ * 2. The dimensions survive the wire projection. `projectPickerCatalog` used
13
+ * to return `options` only for a `kind: "single"` catalog, which silently
14
+ * dropped them — green unit tests on the in-memory catalog would not have
15
+ * caught it.
16
+ */
17
+ import { describe, it, expect } from "vitest"
18
+ import {
19
+ TRANSITION_POSITIONS,
20
+ TRANSITION_DURATIONS,
21
+ TRANSITION_INTENSITIES,
22
+ composeTransitionHintFromConnections,
23
+ } from "../transitions.js"
24
+ import { PICKER_CATALOGS, projectPickerCatalog } from "../picker-catalogs.js"
25
+
26
+ const DIMENSIONS = [
27
+ ["position", TRANSITION_POSITIONS],
28
+ ["duration", TRANSITION_DURATIONS],
29
+ ["intensity", TRANSITION_INTENSITIES],
30
+ ] as const
31
+
32
+ const transitionCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "transition")!
33
+
34
+ describe("transition timing catalogs", () => {
35
+ // The `TransitionPosition` / `Duration` / `Intensity` unions are DERIVED from
36
+ // these arrays, so nothing at the type level can pin the values themselves.
37
+ // These are the values the editor dropdowns, the node data, and every stored
38
+ // workflow already use: changing one is a data migration, not a rename.
39
+ it("spells exactly the ids every stored workflow already uses", () => {
40
+ expect(TRANSITION_POSITIONS.map((o) => o.id)).toEqual([
41
+ "auto", "start", "middle", "end", "full",
42
+ ])
43
+ expect(TRANSITION_DURATIONS.map((o) => o.id)).toEqual([
44
+ "auto", "instant", "short", "medium", "long",
45
+ ])
46
+ expect(TRANSITION_INTENSITIES.map((o) => o.id)).toEqual([
47
+ "auto", "subtle", "natural", "dynamic", "crazy",
48
+ ])
49
+ })
50
+
51
+ it("carries the timing precision users read off the dropdown", () => {
52
+ // The editor used to hand-write these labels and had already drifted from
53
+ // the catalog on all three rows. It now renders straight from here, so this
54
+ // is the one place the numbers live.
55
+ expect(TRANSITION_DURATIONS.map((o) => o.label)).toEqual([
56
+ "Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
57
+ ])
58
+ })
59
+
60
+ it("gives every injecting step a compact term, so compact mode never drops it", () => {
61
+ // `catalog-terms.test.ts` enforces this globally; pinned here too because
62
+ // the platform composes timing from `promptHint` in BOTH hint modes, so a
63
+ // broken `term` would only ever surface on an external compact client.
64
+ for (const [field, options] of DIMENSIONS) {
65
+ for (const o of options.slice(1)) {
66
+ expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
67
+ }
68
+ expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
69
+ }
70
+ })
71
+
72
+ it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
73
+ // The regression this guards: a step present in the catalog but missing
74
+ // from the clause table made `parts.push(undefined)` render as a dangling
75
+ // ", " on a prompt that shipped to the provider.
76
+ for (const [field, options] of DIMENSIONS) {
77
+ for (const o of options.slice(1)) {
78
+ const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], {
79
+ [field]: o.id,
80
+ } as never)
81
+ expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
82
+ expect(composed, `${field}/${o.id}`).not.toContain("undefined")
83
+ }
84
+ }
85
+ })
86
+
87
+ it("every scale leads with a no-op `auto` that injects nothing", () => {
88
+ for (const [field, options] of DIMENSIONS) {
89
+ expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
90
+ expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
91
+ // Every other step must actually carry a clause.
92
+ for (const o of options.slice(1)) {
93
+ expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
94
+ }
95
+ }
96
+ })
97
+
98
+ it("the composed clause is the catalog's own promptHint, verbatim", () => {
99
+ // One source of truth: if the composer ever grows a private copy of these
100
+ // strings, the composed sentence stops containing the advertised hint.
101
+ for (const o of TRANSITION_POSITIONS.slice(1)) {
102
+ const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: o.id as never })
103
+ expect(composed, `position/${o.id}`).toContain(o.promptHint)
104
+ }
105
+ for (const o of TRANSITION_DURATIONS.slice(1)) {
106
+ const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { duration: o.id as never })
107
+ expect(composed, `duration/${o.id}`).toContain(o.promptHint)
108
+ }
109
+ for (const o of TRANSITION_INTENSITIES.slice(1)) {
110
+ const composed = composeTransitionHintFromConnections("cross-dissolve", [], [], { intensity: o.id as never })
111
+ expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
112
+ }
113
+ })
114
+
115
+ it("`auto` adds nothing to the composed sentence", () => {
116
+ const bare = composeTransitionHintFromConnections("cross-dissolve", [], [])
117
+ const allAuto = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: "auto", duration: "auto", intensity: "auto" })
118
+ expect(allAuto).toBe(bare)
119
+ })
120
+
121
+ it("exposes the three dimensions on the transition catalog", () => {
122
+ expect(transitionCatalog.kind).toBe("single")
123
+ expect(transitionCatalog.dimensions?.map((d) => d.field)).toEqual([
124
+ "position",
125
+ "duration",
126
+ "intensity",
127
+ ])
128
+ })
129
+
130
+ it("keeps the dimensions through the wire projection", () => {
131
+ const wire = projectPickerCatalog(transitionCatalog, { detail: "full" })
132
+
133
+ // The 82-option transition list is untouched by the addition.
134
+ expect(wire.options?.length).toBe(transitionCatalog.options?.length)
135
+
136
+ expect(wire.dimensions).toHaveLength(3)
137
+ for (const [field, options] of DIMENSIONS) {
138
+ const dim = wire.dimensions?.find((d) => d.field === field)
139
+ expect(dim, `${field} must reach the wire`).toBeDefined()
140
+ expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
141
+ expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
142
+ }
143
+ })
144
+
145
+ it("leaves every other single-dim catalog without dimensions", () => {
146
+ const singlesWithDims = PICKER_CATALOGS.filter(
147
+ (c) => c.kind === "single" && c.dimensions,
148
+ ).map((c) => c.nodeType)
149
+ expect(singlesWithDims).toEqual(["transition"])
150
+ })
151
+ })
@@ -32,6 +32,12 @@ export interface CompositionEffect {
32
32
  }
33
33
 
34
34
  export const COMPOSITION_EFFECTS: ReadonlyArray<CompositionEffect> = [
35
+ // The neutral default. Every other entry is a dramatic subject transform, so
36
+ // an unconfigured node must inject nothing — the same no-op-entry convention
37
+ // `transitions`/`character-fx` use for their "auto" default, which keeps the
38
+ // default a real catalog id (see the `defaultValue` contract in
39
+ // picker-catalogs.ts) instead of a value absent from the option list.
40
+ { id: "none", label: "None", description: "No composition effect", promptHint: "", term: "" },
35
41
  { id: "bursting-through-frame", label: "Bursting Through Frame", description: "3D paper-tear breaking the frame", promptHint: "the subject bursting forward through a torn paper frame in a 3D paper-tear effect, head and shoulders breaking the plane of the canvas with ragged paper edges curling outward", term: "3d pop-out through a torn paper frame" },
36
42
  { id: "breaking-out-of-frame", label: "Breaking Out of Frame", description: "Limb extends past canvas border", promptHint: "the subject's hand or limb extending past the canvas border, breaking the fourth wall and reaching beyond the frame's edge into the viewer's space", term: "out-of-bounds effect, limb past the frame" },
37
43
  { id: "pixel-disintegration", label: "Pixel Disintegration", description: "Subject dissolving into particles", promptHint: "the subject pixelating and dissolving into floating geometric particles drifting outward, ordered chaos with crisp pixel-cube fragments scattering away from the silhouette" },
@@ -45,7 +51,6 @@ export const COMPOSITION_EFFECTS: ReadonlyArray<CompositionEffect> = [
45
51
  { id: "floating-fragments", label: "Floating Fragments", description: "Body partially drifting away", promptHint: "the subject's body partially floating away in fragments, broken pieces of the figure detaching and drifting upward like windborne paper, leaving a partially dissolved silhouette", term: "body fragmenting into drifting pieces" },
46
52
  { id: "silhouette-outline", label: "Silhouette Outline", description: "Clean black silhouette on flat BG", promptHint: "the subject reduced to a clean black silhouette outline against a flat single-color background, no internal detail, pure shape language", term: "flat black silhouette on a solid background" },
47
53
  { id: "exploding-particles", label: "Exploding Particles", description: "Outline scattering into particles", promptHint: "the subject's outline exploding outward into a cloud of fine particles, dust and motes scattering radially around a partially intact core figure" },
48
- { id: "3x3-grid-collage", label: "3x3 Grid Collage", description: "Contact-sheet 9-pose montage", promptHint: "the subject shown in a 3x3 grid of varied poses and expressions arranged as a contact-sheet collage, nine clean panels with consistent lighting and slight pose variation in each cell" },
49
54
  { id: "matte-painting", label: "Matte Painting", description: "Composite matte-painted background blended with live action, classic VFX", promptHint: "the subject composited against a matte-painted background blended seamlessly with live action, classic VFX matte-painting integration with hand-painted environment extension behind the figure" },
50
55
  { id: "double-exposure", label: "Double Exposure", description: "Two layered photographic exposures fused into one image", promptHint: "two layered photographic exposures fused into one image, the subject's silhouette filled with a secondary scene and translucent overlapping forms in classic darkroom double-exposure technique" },
51
56
  { id: "multiple-exposure", label: "Multiple Exposure", description: "Three or more exposures stacked, kaleidoscopic layering", promptHint: "three or more photographic exposures stacked into a single frame, kaleidoscopic layering of repeated subject positions with translucent overlapping silhouettes echoing across the canvas" },