@nodaro/prompts 1.21.0 → 1.25.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.
Files changed (33) hide show
  1. package/dist/index.cjs +191 -45
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +362 -207
  4. package/dist/index.d.ts +362 -207
  5. package/dist/index.js +183 -46
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/assemble-video-input.test.ts +20 -12
  9. package/src/__tests__/camera-motion-approved.test.ts +46 -0
  10. package/src/__tests__/camera-motions-from-connections.test.ts +3 -2
  11. package/src/__tests__/catalog-packs.test.ts +13 -0
  12. package/src/__tests__/character-fx-timing-catalogs.test.ts +3 -2
  13. package/src/__tests__/factory-presets.test.ts +28 -3
  14. package/src/__tests__/fixtures/camera-motion-approved.json +330 -0
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +32 -16
  16. package/src/__tests__/hint-join.test.ts +3 -2
  17. package/src/__tests__/node-prompt-fields.test.ts +2 -2
  18. package/src/__tests__/parameter-hint-mode.test.ts +17 -5
  19. package/src/__tests__/prompt-style-section.test.ts +2 -2
  20. package/src/__tests__/transitions-instant.test.ts +242 -0
  21. package/src/__tests__/transitions-scope.test.ts +160 -0
  22. package/src/__tests__/transitions.test.ts +9 -9
  23. package/src/ad-creative-analysis.ts +99 -0
  24. package/src/camera-motions.ts +21 -21
  25. package/src/catalog-packs.ts +13 -2
  26. package/src/direction-registry.ts +2 -2
  27. package/src/factory-presets/generate-video.ts +22 -0
  28. package/src/index.ts +3 -0
  29. package/src/node-prompt-fields.ts +4 -0
  30. package/src/picker-catalogs.ts +13 -0
  31. package/src/ref-binding.ts +19 -0
  32. package/src/transitions.ts +204 -27
  33. package/src/video-reference-resolver.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.21.0",
3
+ "version": "1.25.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": "^3.11.0"
23
+ "@nodaro/shared": "^3.12.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -7,7 +7,7 @@ import {
7
7
  renderDirectionHints,
8
8
  } from "../direction-registry.js"
9
9
  import { getStylePromptHint, getStyleTerm } from "../style.js"
10
- import { getTransitionPromptHint, getTransitionTerm } from "../transitions.js"
10
+ import { renderTransitionBases } from "../transitions.js"
11
11
  import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
12
12
  import { getFramingPromptHint } from "../framing.js"
13
13
  import { getLightingPromptHint } from "../lighting.js"
@@ -78,28 +78,36 @@ describe("composeVideoPromptText — the verbosity policy", () => {
78
78
  })
79
79
 
80
80
  it("renders a MOTION dimension as its compact term, not its full hint", () => {
81
- const out = composeVideoPromptText("a knight", { transition: TRANSITION })
82
- expect(out).toBe(`a knight. ${getTransitionTerm(TRANSITION)}`)
83
- expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
81
+ const out = composeVideoPromptText("a knight", { cameraMotion: CAMERA_MOTION })
82
+ expect(out).toBe(`a knight. ${getCameraMotionTerm(CAMERA_MOTION)}`)
83
+ expect(out).not.toContain(getCameraMotionPromptHint(CAMERA_MOTION))
84
+ })
85
+
86
+ it("renders a TRANSITION as `term (hint)` in every mode — the bare term did not steer the model", () => {
87
+ const expected = `a knight. ${renderTransitionBases([TRANSITION])[0]}`
88
+ expect(composeVideoPromptText("a knight", { transition: TRANSITION })).toBe(expected)
89
+ for (const hintMode of ["full", "compact"] as const) {
90
+ expect(composeVideoPromptText("a knight", { transition: TRANSITION }, undefined, { hintMode })).toBe(expected)
91
+ }
84
92
  })
85
93
 
86
94
  it("applies both halves of the split policy in ONE fold", () => {
87
95
  const out = composeVideoPromptText("a knight", {
88
96
  style: STYLE,
89
- transition: TRANSITION,
97
+ cameraMotion: CAMERA_MOTION,
90
98
  })
91
99
  expect(out).toContain(getStylePromptHint(STYLE))
92
- expect(out).toContain(getTransitionTerm(TRANSITION))
93
- expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
100
+ expect(out).toContain(getCameraMotionTerm(CAMERA_MOTION))
101
+ expect(out).not.toContain(getCameraMotionPromptHint(CAMERA_MOTION))
94
102
  })
95
103
 
96
104
  it("honors a whole-fold `hintMode` override in both directions", () => {
97
105
  // "full" promotes the motion family to its full clause…
98
106
  expect(
99
- composeVideoPromptText("a knight", { transition: TRANSITION }, undefined, {
107
+ composeVideoPromptText("a knight", { cameraMotion: CAMERA_MOTION }, undefined, {
100
108
  hintMode: "full",
101
109
  }),
102
- ).toBe(`a knight. ${getTransitionPromptHint(TRANSITION)}`)
110
+ ).toBe(`a knight. ${getCameraMotionPromptHint(CAMERA_MOTION)}`)
103
111
  // …and "compact" demotes the look family to its term. Verbosity does not
104
112
  // move a clause: the look term still reads in the section.
105
113
  expect(
@@ -124,7 +132,7 @@ describe("composeVideoPromptText — what stays in the body", () => {
124
132
  transition: TRANSITION,
125
133
  })
126
134
  expect(out).toBe(
127
- `a knight. ${getCameraMotionTerm(CAMERA_MOTION)}. ${getTransitionTerm(TRANSITION)}`,
135
+ `a knight. ${getCameraMotionTerm(CAMERA_MOTION)}. ${renderTransitionBases([TRANSITION])[0]}`,
128
136
  )
129
137
  expect(out).not.toContain("[style]")
130
138
  })
@@ -248,13 +256,13 @@ describe("composeVideoPromptText — tolerance", () => {
248
256
  describe("composeVideoPromptText — an empty or absent body", () => {
249
257
  it("returns the hints alone for an empty prompt (never a leading '. ')", () => {
250
258
  expect(composeVideoPromptText("", { transition: TRANSITION })).toBe(
251
- getTransitionTerm(TRANSITION),
259
+ renderTransitionBases([TRANSITION])[0],
252
260
  )
253
261
  })
254
262
 
255
263
  it("returns the hints alone for an ABSENT prompt", () => {
256
264
  expect(composeVideoPromptText(undefined, { transition: TRANSITION })).toBe(
257
- getTransitionTerm(TRANSITION),
265
+ renderTransitionBases([TRANSITION])[0],
258
266
  )
259
267
  })
260
268
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Every Camera Motion injection was approved on screen (the Camera Motion Lab,
3
+ * 2026-08/09). A cinematic label alone did not drive the model — "camera
4
+ * trucks to the left" barely moved the image and "orbits to the left" went a
5
+ * random way — so each option carries a description of what the camera
6
+ * physically does, tested take by take on a base frame built for its family.
7
+ *
8
+ * The approved wording is pinned here with the clip that approved it. Changing
9
+ * an injection means re-running it on its base frame first; a new option needs
10
+ * its own approved take before it joins this list.
11
+ */
12
+ import { readFileSync } from "node:fs"
13
+ import { describe, expect, it } from "vitest"
14
+ import { CAMERA_MOTIONS } from "../camera-motions"
15
+
16
+ interface ApprovedInjection {
17
+ readonly id: string
18
+ readonly approvedClip: string
19
+ readonly injection: string
20
+ }
21
+
22
+ const fixture = JSON.parse(
23
+ readFileSync(new URL("./fixtures/camera-motion-approved.json", import.meta.url), "utf8"),
24
+ ) as { readonly unapproved: readonly string[]; readonly approved: readonly ApprovedInjection[] }
25
+
26
+ const byId = new Map(CAMERA_MOTIONS.map((motion) => [motion.id, motion]))
27
+
28
+ describe("Camera Motion injections", () => {
29
+ it.each(fixture.approved.map((row) => [row.id, row] as const))("%s sends its lab-approved wording", (id, row) => {
30
+ expect(byId.get(id)?.promptHint).toBe(row.injection)
31
+ })
32
+
33
+ it("has an approved take for every option but the ones never built", () => {
34
+ const approved = new Set(fixture.approved.map((row) => row.id))
35
+ const unaccounted = CAMERA_MOTIONS.map((motion) => motion.id).filter((id) => !approved.has(id))
36
+ expect(unaccounted.sort()).toEqual([...fixture.unapproved].sort())
37
+ })
38
+
39
+ // An edit-type transition's real-world trigger gets drawn into the scene:
40
+ // "finger tap" put a finger on screen, and the compact term is sent as-is.
41
+ it("never names a transition's physical trigger in the compact term", () => {
42
+ for (const id of ["screen-tap", "phone-flip"]) {
43
+ expect(byId.get(id)?.term ?? "").not.toMatch(/finger|phone|hand|tap|click/i)
44
+ }
45
+ })
46
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { composeCameraMotionHintFromConnections } from "../camera-motions.js"
2
+ import { CAMERA_MOTIONS, composeCameraMotionHintFromConnections } from "../camera-motions.js"
3
3
 
4
4
  describe("composeCameraMotionHintFromConnections", () => {
5
5
  it("returns empty string for unknown motion", () => {
@@ -64,7 +64,8 @@ describe("composeCameraMotionHintFromConnections", () => {
64
64
  ["wide shot"],
65
65
  ["close-up"],
66
66
  )
67
- expect(out).toContain("static")
67
+ // The static hint itself, whatever its approved wording.
68
+ expect(out).toContain(CAMERA_MOTIONS.find((motion) => motion.id === "static")!.promptHint)
68
69
  expect(out).toContain("beginning with wide shot")
69
70
  expect(out).toContain("ending with close-up")
70
71
  })
@@ -40,6 +40,19 @@ describe("composePickerCatalogs — pure", () => {
40
40
  expect(out[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest", "beach", "shul"])
41
41
  })
42
42
 
43
+ it("denies auxiliary dimensions on single catalogs and replaces a removed default", () => {
44
+ const hybrid: PickerCatalog = { ...base[0], defaultValue: "beach", dimensions: [
45
+ { field: "speed", label: "Speed", options: [{ id: "fast", label: "Fast", term: "fast", promptHint: "fast" }] },
46
+ ] }
47
+ const [out] = composePickerCatalogs([hybrid], [{ id: "curation", catalogId: "setting", mode: "deny", denyIds: ["beach", "fast"] }])
48
+ expect(out.options?.map((o) => o.id)).toEqual(["forest"])
49
+ expect(out.dimensions?.[0].options).toEqual([])
50
+ expect(out.defaultValue).toBe("forest")
51
+ expect(hybrid.dimensions?.[0].options).toHaveLength(1)
52
+ const [empty] = composePickerCatalogs([hybrid], [{ id: "empty", catalogId: "setting", mode: "deny", denyIds: ["forest", "beach", "fast"] }])
53
+ expect(empty.defaultValue).toBeUndefined()
54
+ })
55
+
43
56
  it("replace swaps the catalog wholesale for its catalogId", () => {
44
57
  const vendored: PickerCatalogInput = { nodeType: "setting", label: "Setting", catalogId: "setting", kind: "single",
45
58
  valueField: "setting", options: [{ id: "forest", label: "Forest", promptHint: "in a forest", term: "forest" }] }
@@ -86,8 +86,9 @@ describe("character-fx timing catalogs", () => {
86
86
  // The one constraint that matters most: these scales must never be unified
87
87
  // with the transition ones. Position and duration are worded differently
88
88
  // on purpose, so pointing character-fx at the transition rows fails here.
89
- // Intensity is deliberately NOT compared: its three clauses happen to be
90
- // identical today, and that is a coincidence, not a shared definition.
89
+ // Intensity is deliberately NOT compared: its clauses happen to mostly
90
+ // match, and that is a coincidence, not a shared definition (transitions'
91
+ // "natural" already reads differently since 2026-09-24).
91
92
  expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).not.toEqual(
92
93
  TRANSITION_POSITIONS.map((o) => o.promptHint),
93
94
  )
@@ -5,7 +5,7 @@ import { COMPOSER_PLAN_MAP, COMPOSER_PLAN_FIELDS } from "@nodaro/shared"
5
5
  import { STYLE_IDS } from "../index.js"
6
6
  import { joinPromptParts } from "../resolve-prompt.js"
7
7
  import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
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"
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, VIDEO_DURATION_AUTO, supportsAutoVideoDuration } from "@nodaro/shared"
9
9
 
10
10
  /**
11
11
  * The text a preset ACTUALLY sends: a preset may ship its doctrine as pre/post
@@ -327,6 +327,8 @@ describe("generate-video factory preset data validity", () => {
327
327
  new Set<number>([
328
328
  ...(durationsByMode("t2v")[provider] ?? []),
329
329
  ...(durationsByMode("i2v")[provider] ?? []),
330
+ // Auto (-1) is a catalog capability, not a member of the seconds list.
331
+ ...(supportsAutoVideoDuration(provider) ? [VIDEO_DURATION_AUTO] : []),
330
332
  ])
331
333
 
332
334
  it("uses a known video provider when set", () => {
@@ -382,8 +384,10 @@ describe("generate-video factory preset data validity", () => {
382
384
  NATIVE_NEGATIVE_VIDEO_PROVIDERS.has(provider),
383
385
  `${p.id}: missing negativePrompt on a native-negative provider`,
384
386
  ).toBe(false)
387
+ // The ASSEMBLED prompt — an affix-only preset (the user's own sentence is
388
+ // the whole prompt) carries its constraint tail in the post text.
385
389
  expect(
386
- (p.data.prompt as string) ?? "",
390
+ effectivePrompt(p.data),
387
391
  `${p.id}: omits negativePrompt but carries no in-prompt constraint tail`,
388
392
  ).toContain("subtitle-free")
389
393
  }
@@ -392,7 +396,7 @@ describe("generate-video factory preset data validity", () => {
392
396
  it("non-style-pinned generate-video prompts are substantive", () => {
393
397
  for (const p of presets) {
394
398
  if (p.data.style !== undefined) continue
395
- const prompt = (p.data.prompt as string | undefined) ?? ""
399
+ const prompt = effectivePrompt(p.data)
396
400
  expect(prompt.trim().length, `${p.id}: prompt too thin (${prompt.length} chars)`).toBeGreaterThanOrEqual(40)
397
401
  }
398
402
  })
@@ -1099,3 +1103,24 @@ describe("factory-presets split integrity", () => {
1099
1103
  }
1100
1104
  })
1101
1105
  })
1106
+
1107
+ describe("generate-video/edit-video — the Seedance 2.5 edit shape, up front", () => {
1108
+ const preset = getFactoryPresets("generate-video").find((p) => p.id === "generate-video/edit-video")!
1109
+
1110
+ it("ships exactly what edit mode requires: Adaptive ratio + Auto duration on seedance-2-5", () => {
1111
+ expect(preset.data.provider).toBe("seedance-2-5")
1112
+ expect(preset.data.aspectRatio).toBe("adaptive")
1113
+ expect(preset.data.duration).toBe(VIDEO_DURATION_AUTO)
1114
+ expect(supportsAutoVideoDuration(preset.data.provider as string)).toBe(true)
1115
+ })
1116
+
1117
+ it("owns only the pre & post text — the user's sentence is the prompt", () => {
1118
+ expect(preset.data.prompt).toBeUndefined()
1119
+ expect(preset.data.promptPrefix).toBe("edit {video:1} as follows:\n")
1120
+ // The instruction lands on its own line, straight after the prefix.
1121
+ expect(effectivePrompt({ ...preset.data, prompt: "make it black and white" })).toMatch(
1122
+ /^edit \{video:1\} as follows:\nmake it black and white/,
1123
+ )
1124
+ })
1125
+ })
1126
+