@nodaro/prompts 1.20.0 → 1.23.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 +8214 -7143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +213 -61
- package/dist/index.d.ts +213 -61
- package/dist/index.js +8206 -7145
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +9 -1
- package/src/__tests__/catalog-packs.test.ts +13 -0
- package/src/__tests__/character-motion-audit.test.ts +77 -0
- package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
- package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
- package/src/__tests__/character-motion.test.ts +11 -4
- package/src/__tests__/factory-presets.test.ts +28 -3
- package/src/__tests__/fixtures/parameter-hint-golden.json +22 -6
- package/src/__tests__/frame-delivery.test.ts +93 -0
- package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
- package/src/__tests__/node-prompt-fields.test.ts +2 -2
- package/src/__tests__/parameter-prompt-hint.test.ts +9 -3
- package/src/__tests__/transitions-instant.test.ts +112 -0
- package/src/ad-creative-analysis.ts +99 -0
- package/src/catalog-packs.ts +13 -2
- package/src/character-motion/animals-pets.ts +112 -85
- package/src/character-motion/athletic-stunts.ts +155 -214
- package/src/character-motion/camera-interaction.ts +92 -133
- package/src/character-motion/combat-weapons.ts +143 -202
- package/src/character-motion/dance.ts +121 -169
- package/src/character-motion/entrances-exits.ts +88 -109
- package/src/character-motion/evasive-falls.ts +88 -130
- package/src/character-motion/everyday-actions.ts +233 -274
- package/src/character-motion/face-expression.ts +125 -178
- package/src/character-motion/gestures.ts +211 -304
- package/src/character-motion/head-gestures.ts +66 -94
- package/src/character-motion/idle-ambient.ts +85 -124
- package/src/character-motion/posture-shifts.ts +116 -139
- package/src/character-motion/runway.ts +73 -97
- package/src/character-motion/stage-performance.ts +92 -127
- package/src/character-motion/turns-looks.ts +86 -112
- package/src/character-motion/two-person.ts +241 -229
- package/src/character-motion/types.ts +4 -1
- package/src/character-motion/unnatural-horror.ts +53 -91
- package/src/character-motion/vehicles-mounts.ts +101 -97
- package/src/character-motion/walks-runs.ts +93 -121
- package/src/character-motion-diagnostics.ts +49 -0
- package/src/character-motion.ts +89 -13
- package/src/factory-presets/generate-video.ts +22 -0
- package/src/frame-delivery.ts +106 -0
- package/src/index.ts +7 -0
- package/src/node-prompt-fields.ts +4 -0
- package/src/parameter-prompt-hint.ts +23 -17
- package/src/picker-catalogs.ts +23 -2
- package/src/ref-binding.ts +19 -0
- package/src/transitions.ts +47 -10
- package/src/video-reference-resolver.ts +1 -1
|
@@ -658,17 +658,17 @@
|
|
|
658
658
|
"expected": "the subject lets the jaw fall open under its own weight until the mouth hangs wide and slack, the lips loose and the tongue still, holding it open while the rest of the face stays blank"
|
|
659
659
|
},
|
|
660
660
|
{
|
|
661
|
-
"key": "character-motion/id:
|
|
661
|
+
"key": "character-motion/id:sprint-start",
|
|
662
662
|
"note": "single-dim catalog id",
|
|
663
663
|
"node": {
|
|
664
664
|
"id": "n1",
|
|
665
665
|
"type": "character-motion",
|
|
666
666
|
"data": {
|
|
667
|
-
"characterMotion": "
|
|
667
|
+
"characterMotion": "sprint-start"
|
|
668
668
|
}
|
|
669
669
|
},
|
|
670
670
|
"ctx": null,
|
|
671
|
-
"expected": "the subject
|
|
671
|
+
"expected": "the subject crouches in starting blocks with fingertips on the line, raises the pelvis into the set position, then drives forward off the front foot in a low lean and accelerates into full stride"
|
|
672
672
|
},
|
|
673
673
|
{
|
|
674
674
|
"key": "character-motion/multi-pick",
|
|
@@ -2970,19 +2970,19 @@
|
|
|
2970
2970
|
},
|
|
2971
2971
|
{
|
|
2972
2972
|
"key": "transition/timing",
|
|
2973
|
-
"note": "transition with position + duration + intensity, no ctx",
|
|
2973
|
+
"note": "transition with position + duration + intensity, no ctx (a non-instant row, so the duration clause is emitted)",
|
|
2974
2974
|
"node": {
|
|
2975
2975
|
"id": "n1",
|
|
2976
2976
|
"type": "transition",
|
|
2977
2977
|
"data": {
|
|
2978
|
-
"transition": "
|
|
2978
|
+
"transition": "cross-dissolve",
|
|
2979
2979
|
"position": "middle",
|
|
2980
2980
|
"duration": "short",
|
|
2981
2981
|
"intensity": "dynamic"
|
|
2982
2982
|
}
|
|
2983
2983
|
},
|
|
2984
2984
|
"ctx": null,
|
|
2985
|
-
"expected": "
|
|
2985
|
+
"expected": "smooth cross-dissolve transition where the first shot gradually fades out as the second shot fades in, the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
|
|
2986
2986
|
},
|
|
2987
2987
|
{
|
|
2988
2988
|
"key": "transition/timing-auto",
|
|
@@ -3000,6 +3000,22 @@
|
|
|
3000
3000
|
"ctx": null,
|
|
3001
3001
|
"expected": "no transition, hard cut, instantaneous switch from first shot to second shot"
|
|
3002
3002
|
},
|
|
3003
|
+
{
|
|
3004
|
+
"key": "transition/timing-instant",
|
|
3005
|
+
"note": "an INSTANT transition (a cut) with position + duration + intensity — the duration clause is dropped",
|
|
3006
|
+
"node": {
|
|
3007
|
+
"id": "n1",
|
|
3008
|
+
"type": "transition",
|
|
3009
|
+
"data": {
|
|
3010
|
+
"transition": "none",
|
|
3011
|
+
"position": "middle",
|
|
3012
|
+
"duration": "short",
|
|
3013
|
+
"intensity": "dynamic"
|
|
3014
|
+
}
|
|
3015
|
+
},
|
|
3016
|
+
"ctx": null,
|
|
3017
|
+
"expected": "no transition, hard cut, instantaneous switch from first shot to second shot, the transition occurs in the middle of the clip, with dynamic energy and assertive flourish"
|
|
3018
|
+
},
|
|
3003
3019
|
{
|
|
3004
3020
|
"key": "transition/with-ctx",
|
|
3005
3021
|
"note": "transition composing startState + endState clauses",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { planFrameDelivery } from "../frame-delivery.js"
|
|
3
|
+
|
|
4
|
+
const START = "https://cdn.example/start.png"
|
|
5
|
+
const END = "https://cdn.example/end.png"
|
|
6
|
+
|
|
7
|
+
describe("planFrameDelivery", () => {
|
|
8
|
+
it("keeps a frame as a frame on a model whose frame mode measured fine", () => {
|
|
9
|
+
const plan = planFrameDelivery({
|
|
10
|
+
provider: "seedance-2-5", supportsReferenceImages: true, startFrameUrl: START, prompt: "a walk",
|
|
11
|
+
})
|
|
12
|
+
expect(plan).toEqual({ delivery: "frame", referenceImageUrls: [], promptSuffix: "" })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("moves the frame into the reference list on the Seedance 2.0 family", () => {
|
|
16
|
+
const plan = planFrameDelivery({
|
|
17
|
+
provider: "seedance-2-fast", supportsReferenceImages: true, startFrameUrl: START,
|
|
18
|
+
})
|
|
19
|
+
expect(plan.delivery).toBe("reference")
|
|
20
|
+
expect(plan.referenceImageUrls).toEqual([START])
|
|
21
|
+
expect(plan.promptSuffix).toBe("Use @image_1 as the opening (first) frame of the video.")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("appends frames after the user's own images so their ordinals hold", () => {
|
|
25
|
+
const plan = planFrameDelivery({
|
|
26
|
+
provider: "seedance-2-fast",
|
|
27
|
+
supportsReferenceImages: true,
|
|
28
|
+
startFrameUrl: START,
|
|
29
|
+
endFrameUrl: END,
|
|
30
|
+
userReferenceUrls: ["https://cdn.example/a.png", "https://cdn.example/b.png"],
|
|
31
|
+
})
|
|
32
|
+
expect(plan.referenceImageUrls).toEqual(["https://cdn.example/a.png", "https://cdn.example/b.png", START, END])
|
|
33
|
+
expect(plan.promptSuffix).toContain("@image_3 as the opening (first) frame")
|
|
34
|
+
expect(plan.promptSuffix).toContain("@image_4 as the closing (last) frame")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("adds no second opening sentence when the prompt already binds one", () => {
|
|
38
|
+
const plan = planFrameDelivery({
|
|
39
|
+
provider: "seedance-2-fast",
|
|
40
|
+
supportsReferenceImages: true,
|
|
41
|
+
startFrameUrl: START,
|
|
42
|
+
prompt: "Use @image_1 as the first frame, it is the last keyframe of @video_1",
|
|
43
|
+
})
|
|
44
|
+
expect(plan.delivery).toBe("reference")
|
|
45
|
+
expect(plan.promptSuffix).toBe("")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("honours an explicit choice in both directions", () => {
|
|
49
|
+
expect(planFrameDelivery({
|
|
50
|
+
provider: "seedance-2-fast", requested: "frame", supportsReferenceImages: true, startFrameUrl: START,
|
|
51
|
+
}).delivery).toBe("frame")
|
|
52
|
+
expect(planFrameDelivery({
|
|
53
|
+
provider: "veo3.1", requested: "reference", supportsReferenceImages: true, startFrameUrl: START,
|
|
54
|
+
}).delivery).toBe("reference")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("refuses, with a reason, on a model that takes no reference images", () => {
|
|
58
|
+
const plan = planFrameDelivery({
|
|
59
|
+
provider: "wan-i2v", requested: "reference", supportsReferenceImages: false, startFrameUrl: START,
|
|
60
|
+
})
|
|
61
|
+
expect(plan).toMatchObject({ delivery: "frame", refusedReason: "no-reference-support" })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("refuses rather than displace a user's reference image at the cap", () => {
|
|
65
|
+
const refs = Array.from({ length: 9 }, (_, i) => `https://cdn.example/ref-${i}.png`)
|
|
66
|
+
const plan = planFrameDelivery({
|
|
67
|
+
provider: "seedance-2-fast", supportsReferenceImages: true, startFrameUrl: START, userReferenceUrls: refs,
|
|
68
|
+
})
|
|
69
|
+
expect(plan).toMatchObject({ delivery: "frame", refusedReason: "image-cap" })
|
|
70
|
+
expect(plan.referenceImageUrls).toEqual(refs)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("is a no-op when no frame is wired", () => {
|
|
74
|
+
const plan = planFrameDelivery({ provider: "seedance-2-fast", supportsReferenceImages: true })
|
|
75
|
+
expect(plan).toEqual({ delivery: "frame", referenceImageUrls: [], promptSuffix: "" })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("plans the same shape whether it is asked with urls or with stand-ins", () => {
|
|
79
|
+
// The config panel knows a frame is wired before it knows its url, so it
|
|
80
|
+
// passes placeholders. Ordering and sentences must not depend on the string.
|
|
81
|
+
const real = planFrameDelivery({
|
|
82
|
+
provider: "seedance-2-mini", supportsReferenceImages: true, startFrameUrl: START, endFrameUrl: END,
|
|
83
|
+
userReferenceUrls: ["https://cdn.example/a.png"],
|
|
84
|
+
})
|
|
85
|
+
const preview = planFrameDelivery({
|
|
86
|
+
provider: "seedance-2-mini", supportsReferenceImages: true, startFrameUrl: "frame:start", endFrameUrl: "frame:end",
|
|
87
|
+
userReferenceUrls: ["ref:0"],
|
|
88
|
+
})
|
|
89
|
+
expect(preview.delivery).toBe(real.delivery)
|
|
90
|
+
expect(preview.promptSuffix).toBe(real.promptSuffix)
|
|
91
|
+
expect(preview.referenceImageUrls).toHaveLength(real.referenceImageUrls.length)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
4
|
+
import { getPickerCatalog, type PickerOption } from "../picker-catalogs.js"
|
|
5
|
+
import { composeTransitionHintFromConnections } from "../transitions.js"
|
|
6
|
+
import { composeCharacterFxHintFromConnections } from "../character-fx.js"
|
|
7
|
+
import type { HintEdgeLike, HintGraphContext, HintNodeLike } from "@nodaro/shared"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* THE BOUND ON "compose transition and character-fx from the graph on every
|
|
11
|
+
* path", as a proof rather than a claim.
|
|
12
|
+
*
|
|
13
|
+
* Transition and character-fx joined `EXECUTION_GRAPH_COMPOSED_PARAMETER_TYPES`
|
|
14
|
+
* (@nodaro/shared) and `LABEL_REF_GRAPH_COMPOSED_PARAMETER_TYPES` (backend
|
|
15
|
+
* `services/workflow-engine/label-ref-hint-context.ts`), so every executor now
|
|
16
|
+
* hands them the graph. That deliberately changes the prompt of a workflow that
|
|
17
|
+
* WIRES them — signed off, and the new text is the text the picker's own
|
|
18
|
+
* injection preview has always shown.
|
|
19
|
+
*
|
|
20
|
+
* What was signed off with it is the BOUND: a picker with nothing wired to its
|
|
21
|
+
* own composing handles (`startState` / `endState` for transition, `target` for
|
|
22
|
+
* character-fx) must emit BYTE-IDENTICAL text with and without the graph, so a
|
|
23
|
+
* workflow that wires nothing cannot move at all. This file walks every entry
|
|
24
|
+
* of both catalogs, in both hint modes, under two unwired graph shapes, and
|
|
25
|
+
* asserts that equality — then proves it is not vacuous with wired positive
|
|
26
|
+
* controls that MUST differ.
|
|
27
|
+
*
|
|
28
|
+
* Why two unwired shapes: "unwired" in a real workflow does not mean an
|
|
29
|
+
* isolated node. The picker is normally wired INTO a consumer (an outgoing
|
|
30
|
+
* `cinematography` edge) while nothing feeds its own handles — the walker in
|
|
31
|
+
* `resolveParameterHint` only ever looks at edges whose TARGET is the picker,
|
|
32
|
+
* and this pins that.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const MODES = ["full", "compact"] as const
|
|
36
|
+
|
|
37
|
+
/** The two pickers this change admitted, with the data field each reads. */
|
|
38
|
+
const SUBJECTS = [
|
|
39
|
+
{ type: "transition", valueField: "transition" },
|
|
40
|
+
{ type: "character-fx", valueField: "characterFx" },
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
function optionsFor(type: string): readonly PickerOption[] {
|
|
44
|
+
const catalog = getPickerCatalog(type)
|
|
45
|
+
if (!catalog?.options?.length) throw new Error(`no registered picker catalog options for ${type}`)
|
|
46
|
+
return catalog.options
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pickerNode(type: string, valueField: string, id: string, mode: string): HintNodeLike {
|
|
50
|
+
return { id: "picker", type, data: { [valueField]: id, hintMode: mode } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nothing at all around the picker. */
|
|
54
|
+
function isolated(node: HintNodeLike): HintGraphContext {
|
|
55
|
+
return { nodes: [node], edges: [] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The realistic unwired shape: the picker FEEDS a consumer, but nothing feeds it. */
|
|
59
|
+
function wiredIntoConsumer(node: HintNodeLike): HintGraphContext {
|
|
60
|
+
const consumer: HintNodeLike = { id: "consumer", type: "generate-video", data: { prompt: "a man walks forward" } }
|
|
61
|
+
const edges: HintEdgeLike[] = [
|
|
62
|
+
{ source: node.id, target: consumer.id, sourceHandle: null, targetHandle: "cinematography" },
|
|
63
|
+
]
|
|
64
|
+
return { nodes: [node, consumer], edges }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const UNWIRED_SHAPES = [
|
|
68
|
+
{ name: "isolated (no edges at all)", build: isolated },
|
|
69
|
+
{ name: "wired INTO a consumer, nothing wired into it", build: wiredIntoConsumer },
|
|
70
|
+
] as const
|
|
71
|
+
|
|
72
|
+
describe("unwired transition / character-fx pickers are byte-identical with and without the graph", () => {
|
|
73
|
+
for (const { type, valueField } of SUBJECTS) {
|
|
74
|
+
for (const { name, build } of UNWIRED_SHAPES) {
|
|
75
|
+
it(`${type}: every catalog entry × both hint modes, ${name}`, () => {
|
|
76
|
+
const options = optionsFor(type)
|
|
77
|
+
const differences: string[] = []
|
|
78
|
+
let compared = 0
|
|
79
|
+
|
|
80
|
+
for (const option of options) {
|
|
81
|
+
for (const mode of MODES) {
|
|
82
|
+
const node = pickerNode(type, valueField, option.id, mode)
|
|
83
|
+
const withoutGraph = getParameterPromptHint(node)
|
|
84
|
+
const withGraph = getParameterPromptHint(node, build(node))
|
|
85
|
+
compared += 1
|
|
86
|
+
if (withGraph !== withoutGraph) {
|
|
87
|
+
differences.push(`${type}/${option.id}/${mode}: ${JSON.stringify(withoutGraph)} -> ${JSON.stringify(withGraph)}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Non-vacuity of the WALK itself: a catalog that somehow arrived empty
|
|
93
|
+
// would pass a zero-difference assertion trivially.
|
|
94
|
+
expect(compared).toBe(options.length * MODES.length)
|
|
95
|
+
expect(compared).toBeGreaterThan(20)
|
|
96
|
+
expect(differences).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it("the catalogs really do carry injecting entries (the comparison is over real text, not all empty strings)", () => {
|
|
102
|
+
// `auto` / `none` inject "" by design and are legitimately compared; this
|
|
103
|
+
// asserts they are not ALL of it, so the identity above is meaningful.
|
|
104
|
+
for (const { type, valueField } of SUBJECTS) {
|
|
105
|
+
const injecting = optionsFor(type).filter(
|
|
106
|
+
(o) => getParameterPromptHint(pickerNode(type, valueField, o.id, "full")).trim().length > 0,
|
|
107
|
+
)
|
|
108
|
+
expect(injecting.length, type).toBeGreaterThan(20)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("positive control — a WIRED picker must differ (or the bound above proves nothing)", () => {
|
|
114
|
+
/** A tone node: free text, so its fragment is identical in both hint modes,
|
|
115
|
+
* which keeps the control honest without re-deriving inherited-mode text. */
|
|
116
|
+
const tone = (id: string, text: string): HintNodeLike => ({ id, type: "tone", data: { tone: text } })
|
|
117
|
+
|
|
118
|
+
it("transition: a startState + endState wire changes the text, in both modes", () => {
|
|
119
|
+
const id = optionsFor("transition").find(
|
|
120
|
+
(o) => getParameterPromptHint(pickerNode("transition", "transition", o.id, "full")).trim().length > 0,
|
|
121
|
+
)!.id
|
|
122
|
+
const start = tone("start", "warm golden morning light")
|
|
123
|
+
const end = tone("end", "cold blue dusk")
|
|
124
|
+
|
|
125
|
+
for (const mode of MODES) {
|
|
126
|
+
const node = pickerNode("transition", "transition", id, mode)
|
|
127
|
+
const ctx: HintGraphContext = {
|
|
128
|
+
nodes: [node, start, end],
|
|
129
|
+
edges: [
|
|
130
|
+
{ source: "start", target: node.id, sourceHandle: null, targetHandle: "startState" },
|
|
131
|
+
{ source: "end", target: node.id, sourceHandle: null, targetHandle: "endState" },
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
const composed = getParameterPromptHint(node, ctx)
|
|
135
|
+
expect(composed).not.toBe(getParameterPromptHint(node))
|
|
136
|
+
// Derived from the catalog composer, never a hand-written sentence.
|
|
137
|
+
expect(composed).toBe(
|
|
138
|
+
composeTransitionHintFromConnections(
|
|
139
|
+
id,
|
|
140
|
+
[getParameterPromptHint(start)],
|
|
141
|
+
[getParameterPromptHint(end)],
|
|
142
|
+
undefined,
|
|
143
|
+
mode,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("character-fx: a target ref wire changes the text, in both modes", () => {
|
|
150
|
+
const id = optionsFor("character-fx").find(
|
|
151
|
+
(o) => getParameterPromptHint(pickerNode("character-fx", "characterFx", o.id, "full")).trim().length > 0,
|
|
152
|
+
)!.id
|
|
153
|
+
const mira: HintNodeLike = { id: "mira", type: "character", data: { characterName: "Mira" } }
|
|
154
|
+
|
|
155
|
+
for (const mode of MODES) {
|
|
156
|
+
const node = pickerNode("character-fx", "characterFx", id, mode)
|
|
157
|
+
const ctx: HintGraphContext = {
|
|
158
|
+
nodes: [node, mira],
|
|
159
|
+
edges: [{ source: "mira", target: node.id, sourceHandle: null, targetHandle: "target" }],
|
|
160
|
+
}
|
|
161
|
+
const composed = getParameterPromptHint(node, ctx)
|
|
162
|
+
expect(composed).not.toBe(getParameterPromptHint(node))
|
|
163
|
+
expect(composed).toBe(composeCharacterFxHintFromConnections(id, ["Mira"], undefined, mode))
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -16,10 +16,10 @@ describe("nodeSupportsPromptAffixes", () => {
|
|
|
16
16
|
expect(nodeSupportsPromptAffixes("not-a-node")).toBe(false)
|
|
17
17
|
expect(nodeSupportsPromptAffixes(undefined)).toBe(false)
|
|
18
18
|
})
|
|
19
|
-
it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (
|
|
19
|
+
it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (41 types)", () => {
|
|
20
20
|
const expected = Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t)
|
|
21
21
|
expect([...PROMPT_AFFIX_NODE_TYPES].sort()).toEqual(expected.sort())
|
|
22
|
-
expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(
|
|
22
|
+
expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(41)
|
|
23
23
|
})
|
|
24
24
|
it("getPromptFields still resolves", () => expect(getPromptFields("generate-image")?.prompt).toBe("prompt"))
|
|
25
25
|
})
|
|
@@ -330,8 +330,14 @@ describe("getParameterPromptHint — character-motion minor-age floor", () => {
|
|
|
330
330
|
const ADULT = { age: "age-30s" }
|
|
331
331
|
const motion = { id: "n1", type: "character-motion", data: { characterMotion: ["wave-hello", "kiss-partner"] } }
|
|
332
332
|
const wave = (target: string) => getCharacterMotionPromptHint("wave-hello").replace(/\bthe subject\b/g, target)
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
// kiss-partner names the partner twice. A wired name repeats; the unwired
|
|
334
|
+
// fallback is introduced once and referred back to after that.
|
|
335
|
+
const kiss = (target: string, ...partner: readonly string[]) => {
|
|
336
|
+
let i = 0
|
|
337
|
+
return getCharacterMotionPromptHint("kiss-partner")
|
|
338
|
+
.replace(/\bthe subject\b/g, target)
|
|
339
|
+
.replace(/\bthe partner\b/g, () => partner[Math.min(i++, partner.length - 1)]!)
|
|
340
|
+
}
|
|
335
341
|
|
|
336
342
|
it("a minor wired to target drops the adult-only move and keeps the neutral one", () => {
|
|
337
343
|
const out = getParameterPromptHint(motion, {
|
|
@@ -347,7 +353,7 @@ describe("getParameterPromptHint — character-motion minor-age floor", () => {
|
|
|
347
353
|
nodes: [{ id: "c1", type: "character", data: { characterName: "Mira", person: ADULT } }],
|
|
348
354
|
edges: [{ source: "c1", target: "n1", targetHandle: "target" }],
|
|
349
355
|
})
|
|
350
|
-
expect(out).toBe(`${wave("Mira")}, then ${kiss("Mira", "another person")}`)
|
|
356
|
+
expect(out).toBe(`${wave("Mira")}, then ${kiss("Mira", "another person", "that same person")}`)
|
|
351
357
|
expect(out).toMatch(/kiss/)
|
|
352
358
|
})
|
|
353
359
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
TRANSITIONS,
|
|
4
|
+
TRANSITION_DURATIONS,
|
|
5
|
+
composeTransitionHintFromConnections,
|
|
6
|
+
isInstantTransition,
|
|
7
|
+
} from "../transitions.js"
|
|
8
|
+
import { getPickerCatalog } from "../picker-catalogs.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* F5 (transition QA, 2026-09-22): a duration clause on a cut ("match cut, …,
|
|
12
|
+
* lasting approximately 1 second") made the video model render a 1.75 s
|
|
13
|
+
* dissolve. Rows whose mechanism IS a cut carry `instant: true`, and the
|
|
14
|
+
* composer drops the duration lever for them.
|
|
15
|
+
*/
|
|
16
|
+
const INSTANT_IDS = [
|
|
17
|
+
"none",
|
|
18
|
+
"snap-to-black",
|
|
19
|
+
"match-cut",
|
|
20
|
+
"smash-cut",
|
|
21
|
+
"seamless-match",
|
|
22
|
+
"jump-cut",
|
|
23
|
+
"jump-match",
|
|
24
|
+
"action-relay",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const DURATION_HINTS = TRANSITION_DURATIONS.map((d) => d.promptHint).filter((h) => h.length > 0)
|
|
28
|
+
|
|
29
|
+
describe("instant transitions — the catalog marker", () => {
|
|
30
|
+
it("marks exactly the cut rows", () => {
|
|
31
|
+
expect(TRANSITIONS.filter((t) => t.instant).map((t) => t.id).sort()).toEqual([...INSTANT_IDS].sort())
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("isInstantTransition answers per id, and false for unknown / auto / empty", () => {
|
|
35
|
+
for (const id of INSTANT_IDS) expect(isInstantTransition(id)).toBe(true)
|
|
36
|
+
expect(isInstantTransition("cross-dissolve")).toBe(false)
|
|
37
|
+
expect(isInstantTransition("whip-pan")).toBe(false)
|
|
38
|
+
expect(isInstantTransition("freeze-frame-jump")).toBe(false)
|
|
39
|
+
expect(isInstantTransition("auto")).toBe(false)
|
|
40
|
+
expect(isInstantTransition("nonexistent")).toBe(false)
|
|
41
|
+
expect(isInstantTransition("")).toBe(false)
|
|
42
|
+
expect(isInstantTransition(undefined)).toBe(false)
|
|
43
|
+
expect(isInstantTransition(null)).toBe(false)
|
|
44
|
+
expect(isInstantTransition([])).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("a multi-pick is instant only when every id is", () => {
|
|
48
|
+
expect(isInstantTransition(["match-cut", "smash-cut"])).toBe(true)
|
|
49
|
+
expect(isInstantTransition(["match-cut", "cross-dissolve"])).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("instant transitions — the composer drops the duration lever", () => {
|
|
54
|
+
for (const mode of ["full", "compact"] as const) {
|
|
55
|
+
it(`${mode}: no duration clause on any instant row, for every duration`, () => {
|
|
56
|
+
for (const id of INSTANT_IDS) {
|
|
57
|
+
for (const d of TRANSITION_DURATIONS) {
|
|
58
|
+
const r = composeTransitionHintFromConnections(id, [], [], { duration: d.id }, mode)
|
|
59
|
+
for (const hint of DURATION_HINTS) expect(r).not.toContain(hint)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it(`${mode}: position and intensity still apply to a cut`, () => {
|
|
65
|
+
const r = composeTransitionHintFromConnections(
|
|
66
|
+
"match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, mode,
|
|
67
|
+
)
|
|
68
|
+
expect(r).toContain("the transition occurs in the middle of the clip")
|
|
69
|
+
expect(r).toContain("with natural unhurried timing")
|
|
70
|
+
expect(r).not.toContain("lasting approximately")
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("compact match cut reads term + position + intensity only", () => {
|
|
75
|
+
expect(
|
|
76
|
+
composeTransitionHintFromConnections(
|
|
77
|
+
"match-cut", [], [], { position: "middle", duration: "short", intensity: "natural" }, "compact",
|
|
78
|
+
),
|
|
79
|
+
).toBe("match cut, the transition occurs in the middle of the clip, with natural unhurried timing")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("a non-instant row keeps its duration", () => {
|
|
83
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { duration: "short" })
|
|
84
|
+
expect(r).toContain("lasting approximately 1 second")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("a mixed pick keeps its duration — the non-cut still has a length", () => {
|
|
88
|
+
const r = composeTransitionHintFromConnections(["match-cut", "ink-splash"], [], [], { duration: "short" })
|
|
89
|
+
expect(r).toContain("lasting approximately 1 second")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("a no-op 'auto' beside a cut does not make the pick non-instant", () => {
|
|
93
|
+
const r = composeTransitionHintFromConnections(["auto", "smash-cut"], [], [], { duration: "long" })
|
|
94
|
+
expect(r).not.toContain("lasting approximately")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("instant transitions — on the wire catalog", () => {
|
|
99
|
+
it("the transition picker options carry instant: true on exactly the cut rows", () => {
|
|
100
|
+
const options = getPickerCatalog("transition")?.options ?? []
|
|
101
|
+
expect(options.length).toBe(TRANSITIONS.length)
|
|
102
|
+
expect(options.filter((o) => o.instant === true).map((o) => o.id).sort()).toEqual([...INSTANT_IDS].sort())
|
|
103
|
+
// Absent (not `false`) everywhere else, so a non-transition catalog's shape is unchanged.
|
|
104
|
+
for (const o of options) if (!INSTANT_IDS.includes(o.id)) expect(o).not.toHaveProperty("instant")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("no other catalog grows the field", () => {
|
|
108
|
+
const camera = getPickerCatalog("camera-motion")?.options ?? []
|
|
109
|
+
expect(camera.length).toBeGreaterThan(0)
|
|
110
|
+
for (const o of camera) expect(o).not.toHaveProperty("instant")
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -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
|
+
}
|
package/src/catalog-packs.ts
CHANGED
|
@@ -125,8 +125,13 @@ function applyExtend(c: PickerCatalog, pack: CatalogPack): PickerCatalog {
|
|
|
125
125
|
|
|
126
126
|
function applyDeny(c: PickerCatalog, denyIds: readonly string[]): PickerCatalog {
|
|
127
127
|
const deny = new Set(denyIds)
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
// Single-value catalogs can also declare auxiliary dimensions (e.g.
|
|
129
|
+
// transition duration or motion speed). Those are curated too.
|
|
130
|
+
return {
|
|
131
|
+
...c,
|
|
132
|
+
options: c.options?.filter((o) => !deny.has(o.id)),
|
|
133
|
+
dimensions: c.dimensions?.map((d) => ({ ...d, options: d.options.filter((o) => !deny.has(o.id)) })),
|
|
134
|
+
}
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
export function composePickerCatalogs(
|
|
@@ -145,6 +150,12 @@ export function composePickerCatalogs(
|
|
|
145
150
|
else if (pack.mode === "extend") out = applyExtend(out, pack)
|
|
146
151
|
else if (pack.mode === "deny") out = applyDeny(out, pack.denyIds ?? [])
|
|
147
152
|
}
|
|
153
|
+
if (activePacks.some((p) => p.catalogId === c.catalogId) && out.defaultValue) {
|
|
154
|
+
const options = [...(out.options ?? []), ...(out.dimensions ?? []).flatMap((d) => d.options)]
|
|
155
|
+
if (!options.some((o) => o.id === out.defaultValue)) {
|
|
156
|
+
out = { ...out, defaultValue: out.options?.[0]?.id }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
148
159
|
return out
|
|
149
160
|
})
|
|
150
161
|
}
|