@nodaro/prompts 1.27.0 → 1.28.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.
@@ -0,0 +1,202 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ AURORA_SWEEP_STYLE,
4
+ DEBRIS_SHOWER_STYLE,
5
+ GARDEN_BLOOM_STYLE,
6
+ SAND_STORM_STYLE,
7
+ SAKURA_PETALS_STYLE,
8
+ SMOKE_PUFF_STYLE,
9
+ TRANSITIONS,
10
+ TRANSITION_STYLE_FIELD,
11
+ WHITE_FLASH_STYLE,
12
+ composeTransitionHintFromConnections,
13
+ getParameterPromptHint,
14
+ getTransitionOptions,
15
+ getTransitionPromptHint,
16
+ renderTransitionBases,
17
+ } from "../index.js"
18
+
19
+ /**
20
+ * THE STYLE OPTION — a row's alternative looks, each the text a tested take
21
+ * was generated from. `landed` is the transition clause EXACTLY as it sat in
22
+ * that take's prompt (the f1ab / f2ab / f3ab A/B rounds and the sandsweep2 redraft, tile-default levers,
23
+ * `scope: "shot"`); the catalog must render it byte for byte. The one planned
24
+ * difference is smoke-puff's Full cover: it was tested as an untidied draft
25
+ * (capital first letter, final full stop), and ships with the approved tidy.
26
+ */
27
+ const TESTED: ReadonlyArray<{ row: string; style: string; take: string; landed: string }> = [
28
+ { row: "debris-shower", style: "auto", take: "f2ab 06-B 64f5537b",
29
+ landed: "debris shower (a dense shower of loose leaves, paper scraps and dust whips across the frame from one side, close to the lens, thick enough to hide the whole picture. The camera stays where it is and the framing does not change. As the last of the debris blows past the far edge, the second shot is revealed behind it. The shot ends on the second shot, clear and fully resolved, with no debris left. The debris passes in front of the picture in one direction, and the second shot appears only once it has passed)" },
30
+ { row: "debris-shower", style: "debris-shower-light-sweep", take: "f2ab 06-A 399ee116",
31
+ landed: "debris shower (a shower of debris — leaves, papers, dust — sweeps across the frame in front of the camera, and once the debris clears the scene behind has changed)" },
32
+ { row: "garden-bloom", style: "auto", take: "f2ab 04-A 583c84ec",
33
+ landed: "garden bloom (lush flowers and vines rapidly grow and bloom outward from the edges of the frame, the foliage spreads to overtake the entire image, then parts open like curtains to reveal the new scene behind)" },
34
+ { row: "garden-bloom", style: "garden-bloom-hedge-doors", take: "f2ab 04-B ebe96b89",
35
+ landed: "garden bloom (vines and flowers grow rapidly inward from the edges of the frame, blooming as they spread, until leaves and blossoms cover the whole picture. The camera stays where it is and the framing does not change. The foliage then splits down the middle and draws apart toward the side edges, opening onto the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no leaves or flowers left. The plants grow over the front of the picture, and the first shot stays unchanged until they cover it completely)" },
36
+ { row: "sakura-petals", style: "auto", take: "f2ab 03-A 7e31da0d",
37
+ landed: "cherry blossom petal storm (a dense storm of cherry blossom petals swirls in from one side and fills the frame in soft pink motion, the petals cluster to fully veil the image, then drift past to reveal the new scene)" },
38
+ { row: "sakura-petals", style: "sakura-petals-side-sweep", take: "f2ab 03-B ae385f2f",
39
+ landed: "cherry blossom petal storm (a dense storm of pink cherry blossom petals swirls in from one side of the frame, close to the lens, and thickens until the petals veil the whole picture. The camera stays where it is and the framing does not change. The petals keep drifting the same way and thin out, revealing the second shot behind them. The shot ends on the second shot, clear and fully resolved, with no petals left. The petals fly across the front of the picture in one direction, and the first shot stays unchanged until they hide it completely)" },
40
+ { row: "aurora-sweep", style: "auto", take: "f2ab 07-B ab3dd61e",
41
+ landed: "aurora sweep (a luminous curtain of green and violet aurora light ripples across the whole frame, and its bright bands veil the first shot. The camera stays where it is and the framing does not change. As the bands fade, the second shot is revealed behind them. The shot ends on the second shot, clear and fully resolved, with no aurora light left. The aurora glows over the front of the picture, and the second shot appears only as it fades)" },
42
+ { row: "aurora-sweep", style: "aurora-sweep-veil", take: "f2ab 07-A 794058f2",
43
+ landed: "aurora sweep (a luminous green and violet aurora curtain ripples across the entire frame, the bright bands obscure the first scene, and as the aurora dissipates the second scene resolves in the clear sky)" },
44
+ { row: "smoke-puff", style: "auto", take: "f1ab 04-A 739d8c22",
45
+ landed: "smoke puff (the subject vanishes in a soft puff of smoke that billows outward and fills the frame, the smoke then clears to reveal the new subject in the new scene)" },
46
+ { row: "smoke-puff", style: "smoke-puff-full-cover", take: "f1ab 04-B 3a80fdfa",
47
+ landed: "smoke puff (The first subject vanishes in a sudden soft puff of smoke that billows outward from where it stood until the smoke fills the frame. The camera stays where it is and the framing does not change. The smoke thins and clears from the centre outward, revealing the second shot with the second subject at the same place in the frame. The shot ends on the second shot, clear and fully resolved, with no smoke left. The smoke comes only from where the first subject was.)" },
48
+ { row: "sand-storm", style: "auto", take: "f2ab 01-B bc5bf08d",
49
+ landed: "sand storm (a wall of opaque, swirling ochre sand sweeps in from one side of the frame and surges across it until the whole picture is hidden in dust. The camera stays where it is and the framing does not change. The dust then thins and sinks away toward the lower edge of the frame, revealing the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no dust left. The first shot stays as it is until the sand covers it completely, and the second shot appears only as the dust clears)" },
50
+ { row: "sand-storm", style: "sand-storm-light-sweep", take: "sandsweep2 R1 64574737",
51
+ landed: "sand storm (a narrow streak of blown sand races across the frame close to the lens in one direction. The first shot stays clear ahead of the streak and the second shot is already clear behind it. The camera stays where it is and the framing does not change. The shot ends on the second shot, fully resolved, with no sand left)" },
52
+ { row: "white-flash", style: "auto", take: "f3ab 07-B 4e42fbe4",
53
+ landed: "white flash (a bright camera-flash bloom fills the whole frame with pure white. The camera stays where it is and the framing does not change. The white holds for a clear beat, then fades down to reveal the second shot. The shot ends on the second shot, fully resolved, with no white haze left. The first shot is gone once the frame is white, and the second shot appears only out of the white)" },
54
+ { row: "white-flash", style: "white-flash-overexposure", take: "f3ab 07-A f22e5913",
55
+ landed: "white flash (a bright camera-flash bloom fills the frame with pure white, holds for a fraction of a second, then resolves into the new scene)" },
56
+ ]
57
+
58
+ /** The approved tidy: first letter lower-cased, a final full stop dropped. */
59
+ const tidy = (clause: string): string =>
60
+ clause.replace(/\((.)/, (_, c: string) => `(${c.toLowerCase()}`).replace(/\.\)$/, ")")
61
+
62
+ const values = (style: string) => (style === "auto" ? undefined : { style })
63
+
64
+ const STYLES = {
65
+ "smoke-puff": SMOKE_PUFF_STYLE,
66
+ "sand-storm": SAND_STORM_STYLE,
67
+ "aurora-sweep": AURORA_SWEEP_STYLE,
68
+ "sakura-petals": SAKURA_PETALS_STYLE,
69
+ "garden-bloom": GARDEN_BLOOM_STYLE,
70
+ "debris-shower": DEBRIS_SHOWER_STYLE,
71
+ "white-flash": WHITE_FLASH_STYLE,
72
+ } as const
73
+
74
+ describe("transition Style — the rows and their choices", () => {
75
+ it("seven rows declare a Style, all on the one shared field", () => {
76
+ const styled = TRANSITIONS.filter((t) => t.options?.some((o) => o.field === TRANSITION_STYLE_FIELD))
77
+ expect(styled.map((t) => t.id)).toEqual(Object.keys(STYLES))
78
+ for (const [id, option] of Object.entries(STYLES)) {
79
+ expect(getTransitionOptions(id)).toEqual([option])
80
+ expect(TRANSITIONS.find((t) => t.id === id)!.promptTemplate).toBe("{style}")
81
+ }
82
+ })
83
+
84
+ it("the default look is `auto`, labelled by its own name — never \"Auto\"", () => {
85
+ const labels = Object.fromEntries(
86
+ Object.entries(STYLES).map(([id, o]) => [id, o.choices.map((c) => `${c.id}=${c.label}`)]),
87
+ )
88
+ expect(labels).toEqual({
89
+ "smoke-puff": ["auto=Engulf (default)", "smoke-puff-full-cover=Full cover"],
90
+ "sand-storm": ["auto=Full cover (default)", "sand-storm-light-sweep=Light sweep"],
91
+ "aurora-sweep": ["auto=Sky glow (default)", "aurora-sweep-veil=Veil"],
92
+ "sakura-petals": ["auto=Swirling veil (default)", "sakura-petals-side-sweep=Side sweep"],
93
+ "garden-bloom": ["auto=Grow & part (default)", "garden-bloom-hedge-doors=Hedge doors"],
94
+ "debris-shower": ["auto=Full cover (default)", "debris-shower-light-sweep=Light sweep"],
95
+ "white-flash": ["auto=Flash (default)", "white-flash-overexposure=Overexposure"],
96
+ })
97
+ })
98
+
99
+ it("every choice but auto is prefixed with its row's id, so no id means anything on two rows", () => {
100
+ const seen = new Set<string>()
101
+ for (const [id, option] of Object.entries(STYLES)) {
102
+ for (const c of option.choices.slice(1)) {
103
+ expect(c.id.startsWith(`${id}-`)).toBe(true)
104
+ expect(seen.has(c.id)).toBe(false)
105
+ seen.add(c.id)
106
+ }
107
+ for (const c of option.choices) expect(c.term).toBe("")
108
+ }
109
+ })
110
+
111
+ it("every body is ship-tidy: lower-case first letter, no final full stop, no token", () => {
112
+ for (const option of Object.values(STYLES)) {
113
+ for (const c of option.choices) {
114
+ expect(c.phrase[0]).toBe(c.phrase[0]!.toLowerCase())
115
+ expect(c.phrase).not.toMatch(/[.\s]$/)
116
+ expect(c.phrase).not.toMatch(/[{}]/)
117
+ }
118
+ }
119
+ })
120
+
121
+ it("a styled row's plain promptHint IS its default look", () => {
122
+ for (const [id, option] of Object.entries(STYLES)) {
123
+ expect(getTransitionPromptHint(id)).toBe(option.choices[0]!.phrase)
124
+ }
125
+ })
126
+ })
127
+
128
+ describe("transition Style — the rendered text is the tested text", () => {
129
+ it("every row × style has a tested take", () => {
130
+ const covered = TESTED.map((t) => `${t.row}|${t.style}`).sort()
131
+ const declared = Object.entries(STYLES)
132
+ .flatMap(([id, o]) => o.choices.map((c) => `${id}|${c.id}`))
133
+ .sort()
134
+ expect(covered).toEqual(declared)
135
+ })
136
+
137
+ it.each(TESTED.map((t) => [`${t.row} · ${t.style} (${t.take})`, t] as const))("%s", (_, t) => {
138
+ const rendered = composeTransitionHintFromConnections(t.row, [], [], {}, "full", {
139
+ scope: "shot",
140
+ optionValues: values(t.style),
141
+ })
142
+ const planned = t.row === "smoke-puff" && t.style !== "auto" ? tidy(t.landed) : t.landed
143
+ expect(rendered).toBe(planned)
144
+ })
145
+
146
+ it("smoke-puff's Full cover differs from its take only by the approved tidy", () => {
147
+ const t = TESTED.find((x) => x.row === "smoke-puff" && x.style !== "auto")!
148
+ expect(t.landed).not.toBe(tidy(t.landed))
149
+ expect(t.landed.replace(/\.\)$/, ")").toLowerCase()).toBe(tidy(t.landed).toLowerCase())
150
+ })
151
+
152
+ it("the levers follow the style's body unchanged", () => {
153
+ for (const t of TESTED) {
154
+ const base = renderTransitionBases([t.row], "full", values(t.style))[0]!
155
+ expect(
156
+ composeTransitionHintFromConnections(
157
+ t.row, [], [], { position: "middle", duration: "short", intensity: "natural" }, "full",
158
+ { optionValues: values(t.style) },
159
+ ),
160
+ ).toBe(
161
+ `${base}, the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
162
+ )
163
+ }
164
+ })
165
+
166
+ it("compact renders the same `term (body)` — the row's term leads every style", () => {
167
+ for (const t of TESTED) {
168
+ expect(
169
+ composeTransitionHintFromConnections(t.row, [], [], {}, "compact", { optionValues: values(t.style) }),
170
+ ).toBe(renderTransitionBases([t.row], "full", values(t.style))[0])
171
+ }
172
+ })
173
+
174
+ it("another row's style — or none, or auto — reads as the row's default look", () => {
175
+ const light = { style: "debris-shower-light-sweep" }
176
+ for (const id of ["garden-bloom", "smoke-puff", "aurora-sweep", "sakura-petals"]) {
177
+ expect(renderTransitionBases([id], "full", light)).toEqual(renderTransitionBases([id]))
178
+ }
179
+ expect(renderTransitionBases(["debris-shower"], "full", { style: "auto" })).toEqual(
180
+ renderTransitionBases(["debris-shower"]),
181
+ )
182
+ expect(renderTransitionBases(["debris-shower"], "full", { style: "light-sweep" })).toEqual(
183
+ renderTransitionBases(["debris-shower"]),
184
+ )
185
+ })
186
+
187
+ it("a canvas transition node writes its style", () => {
188
+ const node = (data: Record<string, unknown>) => ({ id: "t", type: "transition", data })
189
+ const light = TESTED.find((t) => t.style === "debris-shower-light-sweep")!
190
+ expect(
191
+ getParameterPromptHint(node({ transition: "debris-shower", style: "debris-shower-light-sweep" })),
192
+ ).toBe(light.landed)
193
+ })
194
+
195
+ it("a two-pick writes the style into its own row only", () => {
196
+ const hedge = { style: "garden-bloom-hedge-doors" }
197
+ expect(renderTransitionBases(["debris-shower", "garden-bloom"], "full", hedge)).toEqual([
198
+ renderTransitionBases(["debris-shower"])[0],
199
+ renderTransitionBases(["garden-bloom"], "full", hedge)[0],
200
+ ])
201
+ })
202
+ })
@@ -14,6 +14,13 @@ import { getPickerCatalog } from "../picker-catalogs.js"
14
14
  import { VIDEO_HINT_MODE_DEFAULT, renderDirectionHints } from "../direction-registry.js"
15
15
  import { composeVideoPromptText } from "../assemble-video-input.js"
16
16
 
17
+ /** match-cut's body (F8 arm B, 2026-10-03), as `transitions-scope.test.ts` pins it. */
18
+ const MATCH_CUT_BODY =
19
+ "the last picture of the first shot and the first picture of the second share one shape, at the same " +
20
+ "place and the same size in the frame. The camera holds that shape in place across the cut. On the ne" +
21
+ "xt frame everything around the shape has changed while the shape itself stays put. The shot ends on " +
22
+ "the second shot, fully resolved, with no flash frame or zoom between the two"
23
+
17
24
  /**
18
25
  * F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
19
26
  * lasting approximately 1 second") made the video model render a 1.75 s
@@ -94,9 +101,7 @@ describe("instant transitions — the composer drops the duration lever", () =>
94
101
  "match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
95
102
  ),
96
103
  ).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",
104
+ `match cut (${MATCH_CUT_BODY}; ${INSTANT_CUT_CLAUSE}), the transition occurs in the middle of the clip`,
100
105
  )
101
106
  })
102
107
 
@@ -129,8 +134,7 @@ describe("transitions in a video prompt — `term (hint)`", () => {
129
134
  it("the six A/B strings (compact, short + natural levers)", () => {
130
135
  const c = (id: string) => composeTransitionHintFromConnections(id, [], [], LEVERS, "compact")
131
136
  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})`,
137
+ `match cut (${MATCH_CUT_BODY}; ${INSTANT_CUT_CLAUSE})`,
134
138
  )
135
139
  expect(c("none")).toBe(
136
140
  `hard cut (no transition, instantaneous switch from first shot to second shot; ${INSTANT_CUT_CLAUSE})`,