@nodaro/prompts 1.23.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.
@@ -368,7 +368,7 @@
368
368
  }
369
369
  },
370
370
  "ctx": null,
371
- "expected": "locked off static camera, no camera movement"
371
+ "expected": "The camera is locked on a solid tripod and does not move at all for the entire shot: it never travels, never rotates, never tilts, never changes focal length. The viewpoint is completely fixed and the framing stays exactly the same from the first frame to the last.\nLife within the scene carries on naturally, but the camera holds perfectly still throughout.\nNo camera movement of any kind, no drift, no sway, no zoom."
372
372
  },
373
373
  {
374
374
  "key": "camera-motion/id:arc-left",
@@ -407,7 +407,7 @@
407
407
  }
408
408
  },
409
409
  "ctx": null,
410
- "expected": "parallax camera motion, lateral movement that emphasizes the depth separation between foreground and background elements, foreground objects appearing to move faster than distant ones"
410
+ "expected": "The camera makes a small, smooth sideways move that shows off the depth of the scene through parallax. It eases gently a short way across, staying at the same distance and keeping its aim forward.\nThe defining quality is a STRONG parallax gradient: the nearest foreground elements slide across noticeably and quickly, the mid-distance elements shift more gently, and the most distant elements barely move at all — one smooth continuous gradient of speed from near to far that makes the separation between the layers obvious. Nothing is frozen; everything shifts, just by very different amounts according to its distance.\nThe move is modest and ambient rather than a full travelling shot: a gentle reveal of depth, not a journey across the scene.\nThe lens does not change; the camera does not push in, pull back, rotate, or shake.\nNo zoom, no forward or backward travel, no rotation, no handheld shake."
411
411
  },
412
412
  {
413
413
  "key": "camera-motion/pre-post",
@@ -422,7 +422,7 @@
422
422
  }
423
423
  },
424
424
  "ctx": null,
425
- "expected": "shot on location, locked off static camera, no camera movement, no text overlays"
425
+ "expected": "shot on location, The camera is locked on a solid tripod and does not move at all for the entire shot: it never travels, never rotates, never tilts, never changes focal length. The viewpoint is completely fixed and the framing stays exactly the same from the first frame to the last.\nLife within the scene carries on naturally, but the camera holds perfectly still throughout.\nNo camera movement of any kind, no drift, no sway, no zoom. no text overlays"
426
426
  },
427
427
  {
428
428
  "key": "camera-motion/with-ctx",
@@ -466,7 +466,7 @@
466
466
  }
467
467
  ]
468
468
  },
469
- "expected": "locked off static camera, no camera movement, beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression"
469
+ "expected": "The camera is locked on a solid tripod and does not move at all for the entire shot: it never travels, never rotates, never tilts, never changes focal length. The viewpoint is completely fixed and the framing stays exactly the same from the first frame to the last.\nLife within the scene carries on naturally, but the camera holds perfectly still throughout.\nNo camera movement of any kind, no drift, no sway, no zoom. beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression"
470
470
  },
471
471
  {
472
472
  "key": "camera-motion/with-ctx-pre-post",
@@ -512,7 +512,7 @@
512
512
  }
513
513
  ]
514
514
  },
515
- "expected": "handheld, locked off static camera, no camera movement, beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression, 35mm"
515
+ "expected": "handheld, The camera is locked on a solid tripod and does not move at all for the entire shot: it never travels, never rotates, never tilts, never changes focal length. The viewpoint is completely fixed and the framing stays exactly the same from the first frame to the last.\nLife within the scene carries on naturally, but the camera holds perfectly still throughout.\nNo camera movement of any kind, no drift, no sway, no zoom. beginning with wide shot, full body of the subject with visible surroundings, ending with with a calm, centered expression, 35mm"
516
516
  },
517
517
  {
518
518
  "key": "character-fx/id:auto",
@@ -2896,7 +2896,7 @@
2896
2896
  }
2897
2897
  },
2898
2898
  "ctx": null,
2899
- "expected": "no transition, hard cut, instantaneous switch from first shot to second shot"
2899
+ "expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend)"
2900
2900
  },
2901
2901
  {
2902
2902
  "key": "transition/id:auto",
@@ -2922,7 +2922,7 @@
2922
2922
  }
2923
2923
  },
2924
2924
  "ctx": null,
2925
- "expected": "the first and second scenes blend as a translucent double exposure where both images coexist semi-transparently on the frame, the first image then gradually fades out leaving the second image fully resolved"
2925
+ "expected": "double exposure (the first and second scenes blend as a translucent double exposure where both images coexist semi-transparently on the frame, the first image then gradually fades out leaving the second image fully resolved)"
2926
2926
  },
2927
2927
  {
2928
2928
  "key": "transition/id:dream-ripple",
@@ -2935,7 +2935,7 @@
2935
2935
  }
2936
2936
  },
2937
2937
  "ctx": null,
2938
- "expected": "a circular ripple radiates outward across the frame as if the image were the surface of water, and where the ripple passes the first scene is replaced by the second scene"
2938
+ "expected": "dream ripple (a circular ripple radiates outward across the frame as if the image were the surface of water, and where the ripple passes the first scene is replaced by the second scene)"
2939
2939
  },
2940
2940
  {
2941
2941
  "key": "transition/multi-pick",
@@ -2951,7 +2951,7 @@
2951
2951
  }
2952
2952
  },
2953
2953
  "ctx": null,
2954
- "expected": "no transition, hard cut, instantaneous switch from first shot to second shot, and smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in"
2954
+ "expected": "hard cut (no transition, instantaneous switch from first shot to second shot), and cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in)"
2955
2955
  },
2956
2956
  {
2957
2957
  "key": "transition/pre-post",
@@ -2966,7 +2966,7 @@
2966
2966
  }
2967
2967
  },
2968
2968
  "ctx": null,
2969
- "expected": "shot on location, no transition, hard cut, instantaneous switch from first shot to second shot, no text overlays"
2969
+ "expected": "shot on location, hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), no text overlays"
2970
2970
  },
2971
2971
  {
2972
2972
  "key": "transition/timing",
@@ -2982,7 +2982,7 @@
2982
2982
  }
2983
2983
  },
2984
2984
  "ctx": null,
2985
- "expected": "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in, the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
2985
+ "expected": "cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in), the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
2986
2986
  },
2987
2987
  {
2988
2988
  "key": "transition/timing-auto",
@@ -2998,7 +2998,7 @@
2998
2998
  }
2999
2999
  },
3000
3000
  "ctx": null,
3001
- "expected": "no transition, hard cut, instantaneous switch from first shot to second shot"
3001
+ "expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend)"
3002
3002
  },
3003
3003
  {
3004
3004
  "key": "transition/timing-instant",
@@ -3014,7 +3014,7 @@
3014
3014
  }
3015
3015
  },
3016
3016
  "ctx": null,
3017
- "expected": "no transition, hard cut, instantaneous switch from first shot to second shot, the transition occurs in the middle of the clip, with dynamic energy and assertive flourish"
3017
+ "expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), the transition occurs in the middle of the clip"
3018
3018
  },
3019
3019
  {
3020
3020
  "key": "transition/with-ctx",
@@ -3058,7 +3058,7 @@
3058
3058
  }
3059
3059
  ]
3060
3060
  },
3061
- "expected": "no transition, hard cut, instantaneous switch from first shot to second shot, starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression"
3061
+ "expected": "hard cut (no transition, instantaneous switch from first shot to second shot; an abrupt single-frame hard cut, no dissolve, crossfade or superimposition; the two images never blend), starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression"
3062
3062
  },
3063
3063
  {
3064
3064
  "key": "transition/with-ctx-timing-multi-pick",
@@ -3110,7 +3110,7 @@
3110
3110
  }
3111
3111
  ]
3112
3112
  },
3113
- "expected": "on the beat, no transition, hard cut, instantaneous switch from first shot to second shot, and smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in, the transition occurs at the end of the clip, lasting approximately 3 seconds, with subtle restrained energy and minimal flourish, starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression, hold the last frame"
3113
+ "expected": "on the beat, hard cut (no transition, instantaneous switch from first shot to second shot), and cross-dissolve (smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in), the transition occurs at the end of the clip, lasting approximately 3 seconds, with subtle restrained energy and minimal flourish, starting from wide shot, full body of the subject with visible surroundings, ending at with a calm, centered expression, hold the last frame"
3114
3114
  },
3115
3115
  {
3116
3116
  "key": "unknown/type",
@@ -50,8 +50,9 @@ describe("sentence-style camera-motion hints compose cleanly", () => {
50
50
  })
51
51
 
52
52
  it("keeps a clause-style hint byte-identical to the pre-joiner output", () => {
53
- expect(composeCameraMotionHintFromConnections("static", ["wide shot"], [])).toBe(
54
- "locked off static camera, no camera movement, beginning with wide shot",
53
+ // roll-left kept its one-clause catalog wording through the Camera Motion Lab.
54
+ expect(composeCameraMotionHintFromConnections("roll-left", ["wide shot"], [])).toBe(
55
+ "camera rolls counterclockwise around the lens axis, beginning with wide shot",
55
56
  )
56
57
  })
57
58
  })
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"
3
3
 
4
4
  import { getParameterPromptHint } from "../parameter-prompt-hint.js"
5
5
  import { PICKER_CATALOGS, type PickerCatalog, type PickerOption } from "../picker-catalogs.js"
6
+ import { renderTransitionBases } from "../transitions.js"
6
7
  import type { HintGraphContext, HintNodeLike } from "@nodaro/shared"
7
8
 
8
9
  /**
@@ -145,6 +146,15 @@ describe("hint mode: compact injects the term instead of the promptHint", () =>
145
146
  const compact = getParameterPromptHint(node)
146
147
  const full = getParameterPromptHint(withMode(node, "full"))
147
148
 
149
+ // A transition only ever reaches a VIDEO prompt, where it renders as
150
+ // `term (hint)` in BOTH modes — the bare term did not steer the model
151
+ // (see `renderTransitionBases`). The mode is deliberately inert there.
152
+ if (probe.nodeType === "transition") {
153
+ expect(compact).toBe(full)
154
+ expect(compact.startsWith(`${probe.option.term} (`)).toBe(true)
155
+ return
156
+ }
157
+
148
158
  expect(compact).toContain(probe.option.term)
149
159
  expect(full).toContain(probe.option.promptHint)
150
160
  if (probe.shortens) {
@@ -170,7 +180,7 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
170
180
  expect(compact.length).toBeLessThan(full.length)
171
181
  })
172
182
 
173
- it("transition timing clauses are emitted identically in compact mode", () => {
183
+ it("transition clauses are emitted identically in compact mode", () => {
174
184
  const data = {
175
185
  transition: "cross-dissolve",
176
186
  position: "middle",
@@ -181,8 +191,8 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
181
191
  expect(compact).toContain("the transition occurs in the middle of the clip")
182
192
  expect(compact).toContain("lasting approximately 1 second")
183
193
  expect(compact).toContain("with dynamic energy and assertive flourish")
184
- expect(compact).toContain("cross-dissolve")
185
- expect(compact.length).toBeLessThan(getParameterPromptHint({ id: "n1", type: "transition", data }).length)
194
+ expect(compact).toContain("cross-dissolve (")
195
+ expect(compact).toBe(getParameterPromptHint({ id: "n1", type: "transition", data }))
186
196
  })
187
197
 
188
198
  it("character-fx timing clauses are emitted identically in compact mode", () => {
@@ -256,7 +266,7 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
256
266
  expect(compact).toBe(term)
257
267
  })
258
268
 
259
- it("multi-pick joins the TERMS with the same ', and ' separator", () => {
269
+ it("transition multi-pick joins each `term (hint)` with the same ', and ' separator", () => {
260
270
  const options = registeredOptions("transition").filter((o) => o.promptHint.length > 0)
261
271
  const [a, b] = options
262
272
  const compact = getParameterPromptHint({
@@ -264,7 +274,9 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
264
274
  type: "transition",
265
275
  data: { transition: [a.id, b.id], hintMode: "compact" },
266
276
  })
267
- expect(compact).toBe(`${a.term}, and ${b.term}`)
277
+ expect(compact).toBe(renderTransitionBases([a.id, b.id]).join(", and "))
278
+ expect(compact.startsWith(`${a.term} (`)).toBe(true)
279
+ expect(compact).toContain(`, and ${b.term} (`)
268
280
  })
269
281
 
270
282
  it("multi-dimension composition still walks every dimension", () => {
@@ -27,7 +27,7 @@ import { getCameraFormatPromptHint } from "../camera-format.js"
27
27
  import { getFramingPromptHint } from "../framing.js"
28
28
  import { getLightingPromptHint } from "../lighting.js"
29
29
  import { getCameraMotionTerm } from "../camera-motions.js"
30
- import { getTransitionTerm } from "../transitions.js"
30
+ import { renderTransitionBases } from "../transitions.js"
31
31
 
32
32
  /**
33
33
  * THE `[style]` SECTION CONTRACT, at the level it is defined: clauses in, one
@@ -97,7 +97,7 @@ describe("partitionStyleClauses — which slot a clause lands in", () => {
97
97
  ).map((c) => [c.text, c.slot]),
98
98
  )
99
99
  expect(slots.get(getCameraMotionTerm(CAMERA_MOTION))).toBe("body")
100
- expect(slots.get(getTransitionTerm(TRANSITION))).toBe("body")
100
+ expect(slots.get(renderTransitionBases([TRANSITION])[0])).toBe("body")
101
101
  expect(slots.get(getStylePromptHint(STYLE))).toBe("film")
102
102
  expect(slots.get(getFramingPromptHint(SHOT_SIZE))).toBe("scene")
103
103
  })
@@ -1,11 +1,18 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import {
3
+ INSTANT_CUT_CLAUSE,
3
4
  TRANSITIONS,
4
5
  TRANSITION_DURATIONS,
6
+ TRANSITION_INTENSITIES,
5
7
  composeTransitionHintFromConnections,
8
+ getTransitionPromptHint,
9
+ getTransitionTerm,
6
10
  isInstantTransition,
11
+ renderTransitionBases,
7
12
  } from "../transitions.js"
8
13
  import { getPickerCatalog } from "../picker-catalogs.js"
14
+ import { VIDEO_HINT_MODE_DEFAULT, renderDirectionHints } from "../direction-registry.js"
15
+ import { composeVideoPromptText } from "../assemble-video-input.js"
9
16
 
10
17
  /**
11
18
  * F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
@@ -25,6 +32,7 @@ const INSTANT_IDS = [
25
32
  ]
26
33
 
27
34
  const DURATION_HINTS = TRANSITION_DURATIONS.map((d) => d.promptHint).filter((h) => h.length > 0)
35
+ const INTENSITY_HINTS = TRANSITION_INTENSITIES.map((d) => d.promptHint).filter((h) => h.length > 0)
28
36
 
29
37
  describe("instant transitions — the catalog marker", () => {
30
38
  it("marks exactly the cut rows", () => {
@@ -61,22 +69,35 @@ describe("instant transitions — the composer drops the duration lever", () =>
61
69
  }
62
70
  })
63
71
 
64
- it(`${mode}: position and intensity still apply to a cut`, () => {
72
+ it(`${mode}: no intensity clause on any instant row, for every intensity`, () => {
73
+ for (const id of INSTANT_IDS) {
74
+ for (const i of TRANSITION_INTENSITIES) {
75
+ const r = composeTransitionHintFromConnections(id, [], [], { intensity: i.id }, mode)
76
+ for (const hint of INTENSITY_HINTS) expect(r).not.toContain(hint)
77
+ }
78
+ }
79
+ })
80
+
81
+ it(`${mode}: position still applies to a cut`, () => {
65
82
  const r = composeTransitionHintFromConnections(
66
83
  "match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, mode,
67
84
  )
68
85
  expect(r).toContain("the transition occurs in the middle of the clip")
69
- expect(r).toContain("with natural unhurried timing")
86
+ expect(r).not.toContain("unhurried")
70
87
  expect(r).not.toContain("lasting approximately")
71
88
  })
72
89
  }
73
90
 
74
- it("compact match cut reads term + position + intensity only", () => {
91
+ it("compact match cut reads term (hint; anti-blend clause) + position only", () => {
75
92
  expect(
76
93
  composeTransitionHintFromConnections(
77
94
  "match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
78
95
  ),
79
- ).toBe("match cut, the transition occurs in the middle of the clip, with natural unhurried timing")
96
+ ).toBe(
97
+ "match cut (the final composition of the first shot matches the opening composition of the second shot " +
98
+ `in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE}), ` +
99
+ "the transition occurs in the middle of the clip",
100
+ )
80
101
  })
81
102
 
82
103
  it("a non-instant row keeps its duration", () => {
@@ -92,21 +113,130 @@ describe("instant transitions — the composer drops the duration lever", () =>
92
113
  it("a no-op 'auto' beside a cut does not make the pick non-instant", () => {
93
114
  const r = composeTransitionHintFromConnections(["auto", "smash-cut"], [], [], { duration: "long" })
94
115
  expect(r).not.toContain("lasting approximately")
116
+ expect(r).toContain(INSTANT_CUT_CLAUSE)
95
117
  })
96
118
  })
97
119
 
98
- describe("instant transitions — on the wire catalog", () => {
99
- it("the transition picker options carry instant: true on exactly the cut rows", () => {
100
- const options = getPickerCatalog("transition")?.options ?? []
101
- expect(options.length).toBe(TRANSITIONS.length)
102
- expect(options.filter((o) => o.instant === true).map((o) => o.id).sort()).toEqual([...INSTANT_IDS].sort())
103
- // Absent (not `false`) everywhere else, so a non-transition catalog's shape is unchanged.
104
- for (const o of options) if (!INSTANT_IDS.includes(o.id)) expect(o).not.toHaveProperty("instant")
120
+ /**
121
+ * Transition QA, 2026-09-24 (seedance-2-5, prod): the bare term "match cut,
122
+ * with natural unhurried timing" still rendered as a ~1 s dissolve. Every
123
+ * transition in a video prompt now reads `<term> (<hint body>)`, and an
124
+ * all-instant pick carries the anti-blend clause inside its parentheses.
125
+ */
126
+ describe("transitions in a video prompt — `term (hint)`", () => {
127
+ const LEVERS = { duration: "short", intensity: "natural" } as const
128
+
129
+ it("the six A/B strings (compact, short + natural levers)", () => {
130
+ const c = (id: string) => composeTransitionHintFromConnections(id, [], [], LEVERS, "compact")
131
+ expect(c("match-cut")).toBe(
132
+ "match cut (the final composition of the first shot matches the opening composition of the second shot " +
133
+ `in shape, color, and motion, so the cut feels like a visual rhyme; ${INSTANT_CUT_CLAUSE})`,
134
+ )
135
+ expect(c("none")).toBe(
136
+ `hard cut (no transition, instantaneous switch from first shot to second shot; ${INSTANT_CUT_CLAUSE})`,
137
+ )
138
+ expect(c("whip-pan")).toBe(
139
+ "whip pan (the camera whips sideways at high speed, smearing the frame into heavy horizontal motion blur, " +
140
+ "and the second shot enters already travelling in the same direction before it settles into its framing), " +
141
+ "lasting approximately 1 second, with natural timing",
142
+ )
143
+ expect(c("roll-transition")).toBe(
144
+ `camera roll transition (${getTransitionPromptHint("roll-transition")}), ` +
145
+ "lasting approximately 1 second, with natural timing",
146
+ )
147
+ expect(c("freeze-frame-jump")).toBe(
148
+ "freeze-frame time jump (all motion stops mid-action and the picture holds still for a beat; only then does it " +
149
+ "jump to the same view hours or days later, everything in new positions, and motion resumes), " +
150
+ "lasting approximately 1 second, with natural timing",
151
+ )
152
+ expect(c("shatter-glass")).toBe(
153
+ `shatter like glass and reform (${getTransitionPromptHint("shatter-glass")}), ` +
154
+ "lasting approximately 1 second, with natural timing",
155
+ )
156
+ })
157
+
158
+ for (const mode of ["full", "compact"] as const) {
159
+ it(`${mode}: every row reads \`term (…)\` — the hint never restates its own "<label>:" heading`, () => {
160
+ for (const t of TRANSITIONS) {
161
+ const term = getTransitionTerm(t.id)
162
+ const r = composeTransitionHintFromConnections(t.id, [], [], undefined, mode)
163
+ if (!term) { expect(r).toBe(""); continue }
164
+ expect(r.startsWith(`${term} (`), t.id).toBe(true)
165
+ expect(r.endsWith(")"), t.id).toBe(true)
166
+ expect(r.slice(term.length + 2).toLowerCase().startsWith(`${term.toLowerCase()}:`), t.id).toBe(false)
167
+ expect(r).not.toMatch(/\(\s*[^,;()]{1,60}: /)
168
+ }
169
+ })
170
+
171
+ it(`${mode}: every instant row carries the clause exactly once, inside its parentheses`, () => {
172
+ for (const id of INSTANT_IDS) {
173
+ const r = composeTransitionHintFromConnections(id, [], [], undefined, mode)
174
+ expect(r.endsWith(`; ${INSTANT_CUT_CLAUSE})`), id).toBe(true)
175
+ expect(r.split(INSTANT_CUT_CLAUSE).length, id).toBe(2)
176
+ }
177
+ })
178
+
179
+ it(`${mode}: an all-instant multi-pick carries it once, in the first parentheses`, () => {
180
+ const r = composeTransitionHintFromConnections(["match-cut", "smash-cut"], [], [], undefined, mode)
181
+ const [a, b] = r.split(", and smash cut (")
182
+ expect(a.startsWith("match cut (")).toBe(true)
183
+ expect(a.endsWith(`; ${INSTANT_CUT_CLAUSE})`)).toBe(true)
184
+ expect(b).not.toContain(INSTANT_CUT_CLAUSE)
185
+ })
186
+
187
+ it(`${mode}: a mixed pick carries no clause and keeps duration and intensity`, () => {
188
+ const r = composeTransitionHintFromConnections(
189
+ ["match-cut", "cross-dissolve"], [], [], { position: "end", duration: "short", intensity: "natural" }, mode,
190
+ )
191
+ expect(r).not.toContain(INSTANT_CUT_CLAUSE)
192
+ expect(r).toBe(
193
+ renderTransitionBases(["match-cut", "cross-dissolve"]).join(", and ") +
194
+ ", the transition occurs at the end of the clip, lasting approximately 1 second, with natural timing",
195
+ )
196
+ })
197
+
198
+ it(`${mode}: a non-instant row never carries the clause`, () => {
199
+ for (const t of TRANSITIONS) {
200
+ if (t.instant) continue
201
+ expect(composeTransitionHintFromConnections(t.id, [], [], undefined, mode)).not.toContain(INSTANT_CUT_CLAUSE)
202
+ }
203
+ })
204
+ }
205
+
206
+ it("`none` does not say 'hard cut' three times", () => {
207
+ const r = composeTransitionHintFromConnections("none", [], [], undefined, "compact")
208
+ expect(r.split("hard cut").length - 1).toBe(2)
209
+ })
210
+
211
+ it("the direction-registry fold (server path) words a transition identically", () => {
212
+ for (const mode of [VIDEO_HINT_MODE_DEFAULT, "full", "compact"] as const) {
213
+ expect(renderDirectionHints({ transition: "match-cut" }, { surface: "video", mode }))
214
+ .toEqual([composeTransitionHintFromConnections("match-cut", [], [])])
215
+ expect(renderDirectionHints({ transition: "whip-pan" }, { surface: "video", mode }))
216
+ .toEqual([composeTransitionHintFromConnections("whip-pan", [], [])])
217
+ // Multi-pick: one fragment per id, the clause only in the first of an all-instant pick.
218
+ expect(renderDirectionHints({ transition: ["match-cut", "cross-dissolve"] }, { surface: "video", mode }))
219
+ .toEqual(renderTransitionBases(["match-cut", "cross-dissolve"]))
220
+ }
221
+ // A transition never reaches an image prompt.
222
+ expect(renderDirectionHints({ transition: "match-cut" }, { surface: "image" })).toEqual([])
105
223
  })
106
224
 
107
- it("no other catalog grows the field", () => {
108
- const camera = getPickerCatalog("camera-motion")?.options ?? []
109
- expect(camera.length).toBeGreaterThan(0)
110
- for (const o of camera) expect(o).not.toHaveProperty("instant")
225
+ it("under a prompt cap, the tail-shed keeps the anti-blend clause on a two-cut pick", () => {
226
+ const direction = { transition: ["match-cut", "none"] }
227
+ const [first, second] = renderTransitionBases(["match-cut", "none"])
228
+ expect(first.endsWith(`; ${INSTANT_CUT_CLAUSE})`)).toBe(true)
229
+ expect(second).not.toContain(INSTANT_CUT_CLAUSE)
230
+ const full = composeVideoPromptText("a knight", direction)!
231
+ expect(full).toContain(second)
232
+ // A cap the whole fold does not fit, but the first fragment does: the second
233
+ // cut is shed and the clause survives with the first. (With the clause on
234
+ // the LAST fragment, this shed dropped it.)
235
+ for (const cap of [full.length - 1, `a knight. ${first}`.length]) {
236
+ const shed = composeVideoPromptText("a knight", direction, undefined, { cap })!
237
+ expect(shed.length).toBeLessThanOrEqual(cap)
238
+ expect(shed).not.toContain(second)
239
+ expect(shed).toContain(INSTANT_CUT_CLAUSE)
240
+ }
111
241
  })
112
242
  })
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ INSTANT_CUT_CLAUSE,
4
+ TRANSITIONS,
5
+ TRANSITION_POSITIONS,
6
+ composeTransitionHintFromConnections,
7
+ getTransitionPromptHint,
8
+ } from "../transitions.js"
9
+ import { CHARACTER_FX_INTENSITIES } from "../character-fx.js"
10
+
11
+ /**
12
+ * Transition wording round 2 (2026-09-24, from the transition description A/B):
13
+ * - `aging` and `zoom-into-mouth` carry new bodies;
14
+ * - L1: a cut with position `full` renders no position clause;
15
+ * - L5: a hint folded into one shot's time window says "of this shot"
16
+ * (and a non-cut's `full` "spans this entire shot").
17
+ */
18
+
19
+ const INSTANT_IDS = TRANSITIONS.filter((t) => t.instant).map((t) => t.id)
20
+ const NON_INSTANT_IDS = TRANSITIONS.filter((t) => !t.instant && t.id !== "auto").map((t) => t.id)
21
+ const FULL_CLAUSE = "the transition spans the entire clip"
22
+ const FULL_SHOT_CLAUSE = "the transition spans this entire shot"
23
+ const MATCH_CUT_BASE =
24
+ "match cut (the final composition of the first shot matches the opening composition of the second shot " +
25
+ "in shape, color, and motion, so the cut feels like a visual rhyme; " + INSTANT_CUT_CLAUSE + ")"
26
+
27
+ describe("approved row bodies", () => {
28
+ it("aging", () => {
29
+ expect(getTransitionPromptHint("aging")).toBe(
30
+ "accelerated aging transition: the subject visibly ages forward - fine lines deepen into wrinkles, " +
31
+ "hair greys to silver, posture settles - while the framing stays unchanged",
32
+ )
33
+ expect(composeTransitionHintFromConnections("aging", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
34
+ "accelerated aging (the subject visibly ages forward - fine lines deepen into wrinkles, hair greys to silver, " +
35
+ "posture settles - while the framing stays unchanged), the transition occurs in the middle of the clip, " +
36
+ "lasting approximately 1 second, with natural timing",
37
+ )
38
+ })
39
+
40
+ it("freeze-frame-jump (stays a timed transition, not a cut)", () => {
41
+ expect(getTransitionPromptHint("freeze-frame-jump")).toBe(
42
+ "freeze-frame transition: all motion stops mid-action and the picture holds still for a beat; only then does it " +
43
+ "jump to the same view hours or days later, everything in new positions, and motion resumes",
44
+ )
45
+ expect(composeTransitionHintFromConnections("freeze-frame-jump", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
46
+ "freeze-frame time jump (all motion stops mid-action and the picture holds still for a beat; only then does it " +
47
+ "jump to the same view hours or days later, everything in new positions, and motion resumes), " +
48
+ "the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing",
49
+ )
50
+ })
51
+
52
+ it("zoom-into-mouth drops only 'the throat'", () => {
53
+ expect(getTransitionPromptHint("zoom-into-mouth")).toBe(
54
+ "the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes " +
55
+ "through into the new scene which materialises as if emerging from inside the body",
56
+ )
57
+ })
58
+ })
59
+
60
+ describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
61
+ it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
62
+ const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
63
+ expect(out).not.toContain("spans")
64
+ expect(out).toBe(composeTransitionHintFromConnections(id, [], []))
65
+ })
66
+
67
+ it("match cut + full is the fragment alone", () => {
68
+ expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "full" })).toBe(MATCH_CUT_BASE)
69
+ })
70
+
71
+ it.each(["start", "middle", "end"] as const)("a cut keeps its %s clause", (position) => {
72
+ const clause = TRANSITION_POSITIONS.find((p) => p.id === position)!.promptHint
73
+ expect(composeTransitionHintFromConnections("match-cut", [], [], { position })).toBe(`${MATCH_CUT_BASE}, ${clause}`)
74
+ })
75
+
76
+ it.each(NON_INSTANT_IDS)("non-cut %s keeps the full clause", (id) => {
77
+ expect(composeTransitionHintFromConnections(id, [], [], { position: "full" })).toContain(`, ${FULL_CLAUSE}`)
78
+ })
79
+
80
+ it("a mixed pick (cut + non-cut) keeps the full clause", () => {
81
+ expect(
82
+ composeTransitionHintFromConnections(["match-cut", "cross-dissolve"], [], [], { position: "full" }),
83
+ ).toMatch(new RegExp(`, ${FULL_CLAUSE}$`))
84
+ })
85
+
86
+ it("two cuts together still drop it", () => {
87
+ expect(
88
+ composeTransitionHintFromConnections(["match-cut", "smash-cut"], [], [], { position: "full" }),
89
+ ).not.toContain("spans")
90
+ })
91
+ })
92
+
93
+ describe("L5 — scope: shot says 'of this shot'", () => {
94
+ const window = { scope: "shot" } as const
95
+
96
+ it.each([
97
+ ["start", "the transition occurs at the opening of this shot"],
98
+ ["middle", "the transition occurs in the middle of this shot"],
99
+ ["end", "the transition occurs at the end of this shot"],
100
+ ] as const)("%s in a shot window", (position, clause) => {
101
+ const out = composeTransitionHintFromConnections("whip-pan", [], [], { position }, "full", window)
102
+ expect(out).toMatch(new RegExp(`, ${clause}$`))
103
+ expect(out).not.toContain("of the clip")
104
+ })
105
+
106
+ it("every non-auto position clause actually changes in a shot window (guard against a reword)", () => {
107
+ for (const p of TRANSITION_POSITIONS.filter((p) => ["start", "middle", "end"].includes(p.id))) {
108
+ expect(p.promptHint).toContain(" of the clip")
109
+ }
110
+ expect(FULL_CLAUSE).toContain(" the entire clip")
111
+ for (const p of TRANSITION_POSITIONS.filter((p) => p.id !== "auto")) {
112
+ const out = composeTransitionHintFromConnections("whip-pan", [], [], { position: p.id }, "full", window)
113
+ expect(out).not.toContain("clip")
114
+ }
115
+ })
116
+
117
+ it("full in a shot window spans this entire shot (non-cut) and is dropped (cut)", () => {
118
+ expect(composeTransitionHintFromConnections("whip-pan", [], [], { position: "full" }, "full", window)).toMatch(
119
+ new RegExp(`, ${FULL_SHOT_CLAUSE}$`),
120
+ )
121
+ expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "full" }, "full", window)).toBe(MATCH_CUT_BASE)
122
+ })
123
+
124
+ it.each(NON_INSTANT_IDS)("non-cut %s + full: shot scope spans this entire shot, default scope the entire clip", (id) => {
125
+ const shot = composeTransitionHintFromConnections(id, [], [], { position: "full" }, "full", window)
126
+ const clip = composeTransitionHintFromConnections(id, [], [], { position: "full" })
127
+ expect(shot).toBe(clip.replace(FULL_CLAUSE, FULL_SHOT_CLAUSE))
128
+ expect(clip).toContain(`, ${FULL_CLAUSE}`)
129
+ })
130
+
131
+ it("a cut in a shot window", () => {
132
+ expect(composeTransitionHintFromConnections("match-cut", [], [], { position: "middle" }, "full", window)).toBe(
133
+ `${MATCH_CUT_BASE}, the transition occurs in the middle of this shot`,
134
+ )
135
+ })
136
+
137
+ it.each(TRANSITIONS.map((t) => t.id))("%s: no scope and scope clip read 'of the clip', unchanged", (id) => {
138
+ const timing = { position: "middle", duration: "short", intensity: "natural" } as const
139
+ const plain = composeTransitionHintFromConnections(id, [], [], timing)
140
+ expect(composeTransitionHintFromConnections(id, [], [], timing, "full", { scope: "clip" })).toBe(plain)
141
+ if (plain) expect(plain).toContain("in the middle of the clip")
142
+ })
143
+ })
144
+
145
+ describe("intensity natural reads 'with natural timing' (transitions only)", () => {
146
+ it("renders on a non-cut, never 'unhurried'", () => {
147
+ expect(composeTransitionHintFromConnections("whip-pan", [], [], { intensity: "natural" })).toMatch(/, with natural timing$/)
148
+ for (const id of NON_INSTANT_IDS) {
149
+ expect(composeTransitionHintFromConnections(id, [], [], { intensity: "natural" })).not.toContain("unhurried")
150
+ }
151
+ })
152
+
153
+ it("is still dropped on a cut", () => {
154
+ expect(composeTransitionHintFromConnections("match-cut", [], [], { intensity: "natural" })).toBe(MATCH_CUT_BASE)
155
+ })
156
+
157
+ it("character-fx keeps its own 'with natural unhurried timing'", () => {
158
+ expect(CHARACTER_FX_INTENSITIES.find((o) => o.id === "natural")!.promptHint).toBe("with natural unhurried timing")
159
+ })
160
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint } from "../transitions.js"
2
+ import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint, renderTransitionBases } from "../transitions.js"
3
3
 
4
4
  describe("transitions catalog", () => {
5
5
  it("ships 82 unique entries", () => {
@@ -74,9 +74,9 @@ describe("getTransition / getTransitionLabel / getTransitionPromptHint", () => {
74
74
  })
75
75
 
76
76
  describe("composeTransitionHintFromConnections — single-pick", () => {
77
- it("returns the bare hint when no connections + no timing", () => {
77
+ it("returns `term (hint)` when no connections + no timing", () => {
78
78
  const r = composeTransitionHintFromConnections("cross-dissolve", [], [])
79
- expect(r).toBe(getTransitionPromptHint("cross-dissolve"))
79
+ expect(r).toBe(`cross-dissolve (${getTransitionPromptHint("cross-dissolve")})`)
80
80
  })
81
81
 
82
82
  it("returns empty when id is undefined / 'auto' / unknown", () => {
@@ -168,11 +168,12 @@ describe("composeTransitionHintFromConnections — multi-pick", () => {
168
168
  expect(scalar).toBe(array)
169
169
  })
170
170
 
171
- it("joins two base hints with ', and '", () => {
171
+ it("joins two `term (hint)` fragments with ', and '", () => {
172
172
  const r = composeTransitionHintFromConnections(["smash-cut", "white-flash"], [], [])
173
- const a = getTransitionPromptHint("smash-cut")
174
- const b = getTransitionPromptHint("white-flash")
175
- expect(r).toBe(`${a}, and ${b}`)
173
+ expect(r).toBe(
174
+ "smash cut (an abrupt jarring transition between two visually or tonally contrasting shots with no fade, on a beat)" +
175
+ ", and white flash (" + getTransitionPromptHint("white-flash") + ")",
176
+ )
176
177
  })
177
178
 
178
179
  it("dedupes duplicate ids", () => {
@@ -182,8 +183,7 @@ describe("composeTransitionHintFromConnections — multi-pick", () => {
182
183
  })
183
184
 
184
185
  it("caps at 2 ids — extra ids dropped", () => {
185
- const a = getTransitionPromptHint("smash-cut")
186
- const b = getTransitionPromptHint("white-flash")
186
+ const [a, b] = renderTransitionBases(["smash-cut", "white-flash"])
187
187
  const r = composeTransitionHintFromConnections(
188
188
  ["smash-cut", "white-flash", "fade-to-black", "wipe"],
189
189
  [],