@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.
Files changed (54) hide show
  1. package/dist/index.cjs +8214 -7143
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +213 -61
  4. package/dist/index.d.ts +213 -61
  5. package/dist/index.js +8206 -7145
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +9 -1
  9. package/src/__tests__/catalog-packs.test.ts +13 -0
  10. package/src/__tests__/character-motion-audit.test.ts +77 -0
  11. package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
  12. package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
  13. package/src/__tests__/character-motion.test.ts +11 -4
  14. package/src/__tests__/factory-presets.test.ts +28 -3
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +22 -6
  16. package/src/__tests__/frame-delivery.test.ts +93 -0
  17. package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
  18. package/src/__tests__/node-prompt-fields.test.ts +2 -2
  19. package/src/__tests__/parameter-prompt-hint.test.ts +9 -3
  20. package/src/__tests__/transitions-instant.test.ts +112 -0
  21. package/src/ad-creative-analysis.ts +99 -0
  22. package/src/catalog-packs.ts +13 -2
  23. package/src/character-motion/animals-pets.ts +112 -85
  24. package/src/character-motion/athletic-stunts.ts +155 -214
  25. package/src/character-motion/camera-interaction.ts +92 -133
  26. package/src/character-motion/combat-weapons.ts +143 -202
  27. package/src/character-motion/dance.ts +121 -169
  28. package/src/character-motion/entrances-exits.ts +88 -109
  29. package/src/character-motion/evasive-falls.ts +88 -130
  30. package/src/character-motion/everyday-actions.ts +233 -274
  31. package/src/character-motion/face-expression.ts +125 -178
  32. package/src/character-motion/gestures.ts +211 -304
  33. package/src/character-motion/head-gestures.ts +66 -94
  34. package/src/character-motion/idle-ambient.ts +85 -124
  35. package/src/character-motion/posture-shifts.ts +116 -139
  36. package/src/character-motion/runway.ts +73 -97
  37. package/src/character-motion/stage-performance.ts +92 -127
  38. package/src/character-motion/turns-looks.ts +86 -112
  39. package/src/character-motion/two-person.ts +241 -229
  40. package/src/character-motion/types.ts +4 -1
  41. package/src/character-motion/unnatural-horror.ts +53 -91
  42. package/src/character-motion/vehicles-mounts.ts +101 -97
  43. package/src/character-motion/walks-runs.ts +93 -121
  44. package/src/character-motion-diagnostics.ts +49 -0
  45. package/src/character-motion.ts +89 -13
  46. package/src/factory-presets/generate-video.ts +22 -0
  47. package/src/frame-delivery.ts +106 -0
  48. package/src/index.ts +7 -0
  49. package/src/node-prompt-fields.ts +4 -0
  50. package/src/parameter-prompt-hint.ts +23 -17
  51. package/src/picker-catalogs.ts +23 -2
  52. package/src/ref-binding.ts +19 -0
  53. package/src/transitions.ts +47 -10
  54. 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:rise-after-knockdown",
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": "rise-after-knockdown"
667
+ "characterMotion": "sprint-start"
668
668
  }
669
669
  },
670
670
  "ctx": null,
671
- "expected": "the subject pushes up from the ground onto one knee, plants a hand and rises to standing, shakes the head clear and raises both fists back into guard"
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": "none",
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": "no transition, hard cut, instantaneous switch from first shot to second shot, the transition occurs in the middle of the clip, lasting approximately 1 second, with dynamic energy and assertive flourish"
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 (40 types)", () => {
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(40)
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
- const kiss = (target: string, partner: string) =>
334
- getCharacterMotionPromptHint("kiss-partner").replace(/\bthe subject\b/g, target).replace(/\bthe partner\b/g, partner)
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
+ }
@@ -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
- if (c.kind === "single") return { ...c, options: (c.options ?? []).filter((o) => !deny.has(o.id)) }
129
- return { ...c, dimensions: (c.dimensions ?? []).map((d) => ({ ...d, options: d.options.filter((o) => !deny.has(o.id)) })) }
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
  }