@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.
- package/dist/index.cjs +191 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +362 -207
- package/dist/index.d.ts +362 -207
- package/dist/index.js +183 -46
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-video-input.test.ts +20 -12
- package/src/__tests__/camera-motion-approved.test.ts +46 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +3 -2
- package/src/__tests__/catalog-packs.test.ts +13 -0
- package/src/__tests__/character-fx-timing-catalogs.test.ts +3 -2
- package/src/__tests__/factory-presets.test.ts +28 -3
- package/src/__tests__/fixtures/camera-motion-approved.json +330 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +32 -16
- package/src/__tests__/hint-join.test.ts +3 -2
- package/src/__tests__/node-prompt-fields.test.ts +2 -2
- package/src/__tests__/parameter-hint-mode.test.ts +17 -5
- package/src/__tests__/prompt-style-section.test.ts +2 -2
- package/src/__tests__/transitions-instant.test.ts +242 -0
- package/src/__tests__/transitions-scope.test.ts +160 -0
- package/src/__tests__/transitions.test.ts +9 -9
- package/src/ad-creative-analysis.ts +99 -0
- package/src/camera-motions.ts +21 -21
- package/src/catalog-packs.ts +13 -2
- package/src/direction-registry.ts +2 -2
- package/src/factory-presets/generate-video.ts +22 -0
- package/src/index.ts +3 -0
- package/src/node-prompt-fields.ts +4 -0
- package/src/picker-catalogs.ts +13 -0
- package/src/ref-binding.ts +19 -0
- package/src/transitions.ts +204 -27
- 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
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
171
|
+
it("joins two `term (hint)` fragments with ', and '", () => {
|
|
172
172
|
const r = composeTransitionHintFromConnections(["smash-cut", "white-flash"], [], [])
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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 =
|
|
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
|
+
}
|