@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
@@ -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
  })
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ INSTANT_CUT_CLAUSE,
4
+ TRANSITIONS,
5
+ TRANSITION_DURATIONS,
6
+ TRANSITION_INTENSITIES,
7
+ composeTransitionHintFromConnections,
8
+ getTransitionPromptHint,
9
+ getTransitionTerm,
10
+ isInstantTransition,
11
+ renderTransitionBases,
12
+ } from "../transitions.js"
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"
16
+
17
+ /**
18
+ * F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
19
+ * lasting approximately 1 second") made the video model render a 1.75 s
20
+ * dissolve. Rows whose mechanism IS a cut carry `instant: true`, and the
21
+ * composer drops the duration lever for them.
22
+ */
23
+ const INSTANT_IDS = [
24
+ "none",
25
+ "snap-to-black",
26
+ "match-cut",
27
+ "smash-cut",
28
+ "seamless-match",
29
+ "jump-cut",
30
+ "jump-match",
31
+ "action-relay",
32
+ ]
33
+
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)
36
+
37
+ describe("instant transitions — the catalog marker", () => {
38
+ it("marks exactly the cut rows", () => {
39
+ expect(TRANSITIONS.filter((t) => t.instant).map((t) => t.id).sort()).toEqual([...INSTANT_IDS].sort())
40
+ })
41
+
42
+ it("isInstantTransition answers per id, and false for unknown / auto / empty", () => {
43
+ for (const id of INSTANT_IDS) expect(isInstantTransition(id)).toBe(true)
44
+ expect(isInstantTransition("cross-dissolve")).toBe(false)
45
+ expect(isInstantTransition("whip-pan")).toBe(false)
46
+ expect(isInstantTransition("freeze-frame-jump")).toBe(false)
47
+ expect(isInstantTransition("auto")).toBe(false)
48
+ expect(isInstantTransition("nonexistent")).toBe(false)
49
+ expect(isInstantTransition("")).toBe(false)
50
+ expect(isInstantTransition(undefined)).toBe(false)
51
+ expect(isInstantTransition(null)).toBe(false)
52
+ expect(isInstantTransition([])).toBe(false)
53
+ })
54
+
55
+ it("a multi-pick is instant only when every id is", () => {
56
+ expect(isInstantTransition(["match-cut", "smash-cut"])).toBe(true)
57
+ expect(isInstantTransition(["match-cut", "cross-dissolve"])).toBe(false)
58
+ })
59
+ })
60
+
61
+ describe("instant transitions — the composer drops the duration lever", () => {
62
+ for (const mode of ["full", "compact"] as const) {
63
+ it(`${mode}: no duration clause on any instant row, for every duration`, () => {
64
+ for (const id of INSTANT_IDS) {
65
+ for (const d of TRANSITION_DURATIONS) {
66
+ const r = composeTransitionHintFromConnections(id, [], [], { duration: d.id }, mode)
67
+ for (const hint of DURATION_HINTS) expect(r).not.toContain(hint)
68
+ }
69
+ }
70
+ })
71
+
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`, () => {
82
+ const r = composeTransitionHintFromConnections(
83
+ "match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, mode,
84
+ )
85
+ expect(r).toContain("the transition occurs in the middle of the clip")
86
+ expect(r).not.toContain("unhurried")
87
+ expect(r).not.toContain("lasting approximately")
88
+ })
89
+ }
90
+
91
+ it("compact match cut reads term (hint; anti-blend clause) + position only", () => {
92
+ expect(
93
+ composeTransitionHintFromConnections(
94
+ "match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
95
+ ),
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
+ )
101
+ })
102
+
103
+ it("a non-instant row keeps its duration", () => {
104
+ const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { duration: "short" })
105
+ expect(r).toContain("lasting approximately 1 second")
106
+ })
107
+
108
+ it("a mixed pick keeps its duration — the non-cut still has a length", () => {
109
+ const r = composeTransitionHintFromConnections(["match-cut", "ink-splash"], [], [], { duration: "short" })
110
+ expect(r).toContain("lasting approximately 1 second")
111
+ })
112
+
113
+ it("a no-op 'auto' beside a cut does not make the pick non-instant", () => {
114
+ const r = composeTransitionHintFromConnections(["auto", "smash-cut"], [], [], { duration: "long" })
115
+ expect(r).not.toContain("lasting approximately")
116
+ expect(r).toContain(INSTANT_CUT_CLAUSE)
117
+ })
118
+ })
119
+
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([])
223
+ })
224
+
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
+ }
241
+ })
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
  [],
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-ad creative analysis — the "expert competitor ad analyst" pass the
3
+ * social scraper nodes (Meta Ads first; TikTok / Instagram / LinkedIn next)
4
+ * can run on every ad they return. Node-agnostic on purpose: the input is
5
+ * "one creative + its copy + a little context", never a Meta-shaped ad.
6
+ *
7
+ * The model answers in a fixed JSON shape (the schema lives with the backend
8
+ * call site); this file owns the WORDS — what each field means and how to
9
+ * judge it — so the prompt can be tuned without touching the wire contract.
10
+ */
11
+
12
+ /** The fields the analysis returns, in the order a reader wants them. */
13
+ export const AD_CREATIVE_ANALYSIS_FIELDS = [
14
+ "assetType",
15
+ "format",
16
+ "visualHooks",
17
+ "audiences",
18
+ "graphicIdentity",
19
+ "copywritingHooks",
20
+ "usps",
21
+ "cta",
22
+ "summary",
23
+ ] as const
24
+ export type AdCreativeAnalysisField = (typeof AD_CREATIVE_ANALYSIS_FIELDS)[number]
25
+
26
+ export const AD_CREATIVE_ANALYSIS_SYSTEM_PROMPT = `You are an expert competitor ad analyst. You look at a competitor's ad — its creative (image or video poster frame) and its copy — and extract the relevant ad information into a structured summary a marketing team can act on.
27
+
28
+ Judge from what is actually in the creative and copy; never invent claims that are not visible or written. Be concrete and specific (name the objects, colors, people, layouts, words), not generic ("eye-catching visuals"). Write in the language of the ad copy when it is not English, otherwise in English.
29
+
30
+ Fill every field:
31
+ - assetType: "static" (a still image), "motion" (a video — you see its poster frame), "carousel" (several creatives in one ad), or "unknown".
32
+ - format: the placement the creative is built for — e.g. "in-feed", "story / reel (9:16)", "square feed", "banner / web", judged from its shape and framing.
33
+ - visualHooks: the key visuals and visual angles used to stop the scroll (product close-up, before/after, face + eye contact, big number, screenshot, meme style, UGC selfie, text-on-image…). 2–6 short items.
34
+ - audiences: who the ad represents or addresses (age band, gender, life situation, profession, interest, geography, pain point). 1–5 short items.
35
+ - graphicIdentity: the graphic components — color palette, typography style, logo placement, layout system, illustration vs photo, brand consistency cues. One or two sentences.
36
+ - copywritingHooks: the copywriting angles used in the visual text and in the body (curiosity, urgency, social proof, question, offer, fear of missing out, authority, humor…). 1–6 short items, each naming the angle and quoting or paraphrasing the line that carries it.
37
+ - usps: the unique selling points the ad claims (price, speed, exclusivity, guarantee, results, features). 1–5 short items.
38
+ - cta: the call to action — the button label and/or the closing line that tells the viewer what to do.
39
+ - summary: two to four sentences: what the ad sells, to whom, with what hook, and why it likely works (or does not).`
40
+
41
+ /**
42
+ * The organic-content twin of the ad prompt — for scraped social POSTS
43
+ * (Instagram / TikTok / LinkedIn), not paid ads. SAME output fields (so one
44
+ * schema and one UI serve both), read for organic content: hooks, audience,
45
+ * the value/benefit the post conveys (`usps`), and its ask (`cta` — "link in
46
+ * bio", "follow", a comment prompt, or none).
47
+ */
48
+ export const POST_CONTENT_ANALYSIS_SYSTEM_PROMPT = `You are an expert social-media content analyst. You look at a single organic post — its creative (image or video cover frame) and its caption — and extract what a marketing team can learn from it into a structured summary.
49
+
50
+ Judge from what is actually in the creative and caption; never invent claims that are not visible or written. Be concrete and specific (name the objects, colors, people, layouts, words), not generic ("engaging content"). Write in the language of the caption when it is not English, otherwise in English.
51
+
52
+ Fill every field:
53
+ - assetType: "static" (a still image), "motion" (a video / reel — you see its cover frame), "carousel" (a multi-image post), or "unknown".
54
+ - format: the format the post is built for — e.g. "reel (9:16)", "square feed photo", "carousel", "portrait (4:5)", judged from its shape and framing.
55
+ - visualHooks: the key visuals and visual angles used to stop the scroll (product close-up, before/after, face + eye contact, big text overlay, meme style, UGC selfie, trend/format…). 2–6 short items.
56
+ - audiences: who the post represents or speaks to (age band, gender, life situation, profession, interest, geography, community). 1–5 short items.
57
+ - graphicIdentity: the graphic components — color palette, typography style, logo / handle placement, layout, illustration vs photo, brand consistency cues. One or two sentences.
58
+ - copywritingHooks: the caption / content angles (curiosity, storytelling, question, trend, humor, social proof, education, behind-the-scenes…). 1–6 short items, each naming the angle and quoting or paraphrasing the line that carries it.
59
+ - usps: the value or benefit the post conveys to the viewer (entertainment, education, inspiration, a product benefit, a deal). 1–5 short items.
60
+ - cta: the ask — the caption's call to action ("link in bio", "shop now", "follow", "comment below"), or "none" if the post makes none.
61
+ - summary: two to four sentences: what the post is about, who it speaks to, with what hook, and why it likely performs (or does not).`
62
+
63
+ export interface AdCreativeAnalysisInput {
64
+ /** Who is advertising (the Page / account name). */
65
+ readonly advertiser?: string
66
+ readonly headline?: string
67
+ readonly body?: string
68
+ readonly ctaLabel?: string
69
+ /** Where the ad points (the landing domain is enough). */
70
+ readonly landing?: string
71
+ /** Placements the ad ran on (Facebook, Instagram, TikTok…). */
72
+ readonly platforms?: readonly string[]
73
+ /** Creative shape already classified by the caller ("vertical", "square", "horizontal"). */
74
+ readonly creativeFormat?: string
75
+ /** "1 video, 2 images" — what the ad carries beyond the one frame the model sees. */
76
+ readonly mediaSummary?: string
77
+ /** The user's optional focus ("we sell running shoes — compare against our positioning"). */
78
+ readonly focus?: string
79
+ }
80
+
81
+ /**
82
+ * The user turn's text. The creative itself travels as a separate image
83
+ * block; this is everything else the analyst should know, one labelled
84
+ * line per fact, blanks left out.
85
+ */
86
+ export function buildAdCreativeAnalysisUserText(input: AdCreativeAnalysisInput): string {
87
+ const lines: string[] = []
88
+ if (input.advertiser) lines.push(`Account / advertiser: ${input.advertiser}`)
89
+ if (input.headline) lines.push(`Headline: ${input.headline}`)
90
+ if (input.body) lines.push(`Body copy:\n${input.body}`)
91
+ if (input.ctaLabel) lines.push(`CTA button: ${input.ctaLabel}`)
92
+ if (input.landing) lines.push(`Landing: ${input.landing}`)
93
+ if (input.platforms && input.platforms.length > 0) lines.push(`Placements: ${input.platforms.join(", ")}`)
94
+ if (input.creativeFormat) lines.push(`Creative shape: ${input.creativeFormat}`)
95
+ if (input.mediaSummary) lines.push(`Media: ${input.mediaSummary}`)
96
+ lines.push("The attached image is the ad's creative (for a video, its poster frame).")
97
+ if (input.focus) lines.push(`\nAnalyst focus from the user: ${input.focus}`)
98
+ return lines.join("\n")
99
+ }