@nodaro/prompts 1.21.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 +132 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -1
- package/dist/index.d.ts +94 -1
- package/dist/index.js +126 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/catalog-packs.test.ts +13 -0
- package/src/__tests__/factory-presets.test.ts +28 -3
- package/src/__tests__/fixtures/parameter-hint-golden.json +19 -3
- package/src/__tests__/node-prompt-fields.test.ts +2 -2
- 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/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 +47 -10
- package/src/video-reference-resolver.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^3.
|
|
23
|
+
"@nodaro/shared": "^3.12.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -40,6 +40,19 @@ describe("composePickerCatalogs — pure", () => {
|
|
|
40
40
|
expect(out[0].options!.map((o: PickerOption) => o.id)).toEqual(["forest", "beach", "shul"])
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
it("denies auxiliary dimensions on single catalogs and replaces a removed default", () => {
|
|
44
|
+
const hybrid: PickerCatalog = { ...base[0], defaultValue: "beach", dimensions: [
|
|
45
|
+
{ field: "speed", label: "Speed", options: [{ id: "fast", label: "Fast", term: "fast", promptHint: "fast" }] },
|
|
46
|
+
] }
|
|
47
|
+
const [out] = composePickerCatalogs([hybrid], [{ id: "curation", catalogId: "setting", mode: "deny", denyIds: ["beach", "fast"] }])
|
|
48
|
+
expect(out.options?.map((o) => o.id)).toEqual(["forest"])
|
|
49
|
+
expect(out.dimensions?.[0].options).toEqual([])
|
|
50
|
+
expect(out.defaultValue).toBe("forest")
|
|
51
|
+
expect(hybrid.dimensions?.[0].options).toHaveLength(1)
|
|
52
|
+
const [empty] = composePickerCatalogs([hybrid], [{ id: "empty", catalogId: "setting", mode: "deny", denyIds: ["forest", "beach", "fast"] }])
|
|
53
|
+
expect(empty.defaultValue).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
43
56
|
it("replace swaps the catalog wholesale for its catalogId", () => {
|
|
44
57
|
const vendored: PickerCatalogInput = { nodeType: "setting", label: "Setting", catalogId: "setting", kind: "single",
|
|
45
58
|
valueField: "setting", options: [{ id: "forest", label: "Forest", promptHint: "in a forest", term: "forest" }] }
|
|
@@ -5,7 +5,7 @@ import { COMPOSER_PLAN_MAP, COMPOSER_PLAN_FIELDS } from "@nodaro/shared"
|
|
|
5
5
|
import { STYLE_IDS } from "../index.js"
|
|
6
6
|
import { joinPromptParts } from "../resolve-prompt.js"
|
|
7
7
|
import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
|
|
8
|
-
import { IMAGE_GEN_PROVIDERS, MODIFY_IMAGE_PROVIDERS, VIDEO_GEN_PROVIDERS, VIDEO_TO_VIDEO_PROVIDERS, MUSIC_PROVIDERS, SUNO_MODELS, TTS_PROVIDERS, TEXT_TO_AUDIO_PROVIDERS, ALL_CAPTION_STYLES, COMBINE_TRANSITION_IDS, AUDIO_CROSSFADE_CURVE_IDS, aspectRatioOptionsByKind, durationsByMode, IMAGE_PROMPT_MAX, MODEL_CATALOG, NATIVE_NEGATIVE_VIDEO_PROVIDERS } from "@nodaro/shared"
|
|
8
|
+
import { IMAGE_GEN_PROVIDERS, MODIFY_IMAGE_PROVIDERS, VIDEO_GEN_PROVIDERS, VIDEO_TO_VIDEO_PROVIDERS, MUSIC_PROVIDERS, SUNO_MODELS, TTS_PROVIDERS, TEXT_TO_AUDIO_PROVIDERS, ALL_CAPTION_STYLES, COMBINE_TRANSITION_IDS, AUDIO_CROSSFADE_CURVE_IDS, aspectRatioOptionsByKind, durationsByMode, IMAGE_PROMPT_MAX, MODEL_CATALOG, NATIVE_NEGATIVE_VIDEO_PROVIDERS, VIDEO_DURATION_AUTO, supportsAutoVideoDuration } from "@nodaro/shared"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The text a preset ACTUALLY sends: a preset may ship its doctrine as pre/post
|
|
@@ -327,6 +327,8 @@ describe("generate-video factory preset data validity", () => {
|
|
|
327
327
|
new Set<number>([
|
|
328
328
|
...(durationsByMode("t2v")[provider] ?? []),
|
|
329
329
|
...(durationsByMode("i2v")[provider] ?? []),
|
|
330
|
+
// Auto (-1) is a catalog capability, not a member of the seconds list.
|
|
331
|
+
...(supportsAutoVideoDuration(provider) ? [VIDEO_DURATION_AUTO] : []),
|
|
330
332
|
])
|
|
331
333
|
|
|
332
334
|
it("uses a known video provider when set", () => {
|
|
@@ -382,8 +384,10 @@ describe("generate-video factory preset data validity", () => {
|
|
|
382
384
|
NATIVE_NEGATIVE_VIDEO_PROVIDERS.has(provider),
|
|
383
385
|
`${p.id}: missing negativePrompt on a native-negative provider`,
|
|
384
386
|
).toBe(false)
|
|
387
|
+
// The ASSEMBLED prompt — an affix-only preset (the user's own sentence is
|
|
388
|
+
// the whole prompt) carries its constraint tail in the post text.
|
|
385
389
|
expect(
|
|
386
|
-
(p.data
|
|
390
|
+
effectivePrompt(p.data),
|
|
387
391
|
`${p.id}: omits negativePrompt but carries no in-prompt constraint tail`,
|
|
388
392
|
).toContain("subtitle-free")
|
|
389
393
|
}
|
|
@@ -392,7 +396,7 @@ describe("generate-video factory preset data validity", () => {
|
|
|
392
396
|
it("non-style-pinned generate-video prompts are substantive", () => {
|
|
393
397
|
for (const p of presets) {
|
|
394
398
|
if (p.data.style !== undefined) continue
|
|
395
|
-
const prompt = (p.data
|
|
399
|
+
const prompt = effectivePrompt(p.data)
|
|
396
400
|
expect(prompt.trim().length, `${p.id}: prompt too thin (${prompt.length} chars)`).toBeGreaterThanOrEqual(40)
|
|
397
401
|
}
|
|
398
402
|
})
|
|
@@ -1099,3 +1103,24 @@ describe("factory-presets split integrity", () => {
|
|
|
1099
1103
|
}
|
|
1100
1104
|
})
|
|
1101
1105
|
})
|
|
1106
|
+
|
|
1107
|
+
describe("generate-video/edit-video — the Seedance 2.5 edit shape, up front", () => {
|
|
1108
|
+
const preset = getFactoryPresets("generate-video").find((p) => p.id === "generate-video/edit-video")!
|
|
1109
|
+
|
|
1110
|
+
it("ships exactly what edit mode requires: Adaptive ratio + Auto duration on seedance-2-5", () => {
|
|
1111
|
+
expect(preset.data.provider).toBe("seedance-2-5")
|
|
1112
|
+
expect(preset.data.aspectRatio).toBe("adaptive")
|
|
1113
|
+
expect(preset.data.duration).toBe(VIDEO_DURATION_AUTO)
|
|
1114
|
+
expect(supportsAutoVideoDuration(preset.data.provider as string)).toBe(true)
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
it("owns only the pre & post text — the user's sentence is the prompt", () => {
|
|
1118
|
+
expect(preset.data.prompt).toBeUndefined()
|
|
1119
|
+
expect(preset.data.promptPrefix).toBe("edit {video:1} as follows:\n")
|
|
1120
|
+
// The instruction lands on its own line, straight after the prefix.
|
|
1121
|
+
expect(effectivePrompt({ ...preset.data, prompt: "make it black and white" })).toMatch(
|
|
1122
|
+
/^edit \{video:1\} as follows:\nmake it black and white/,
|
|
1123
|
+
)
|
|
1124
|
+
})
|
|
1125
|
+
})
|
|
1126
|
+
|
|
@@ -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",
|
|
@@ -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
|
})
|
|
@@ -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
|
}
|
|
@@ -1113,4 +1113,26 @@ export const GENERATE_VIDEO_PRESETS: readonly FactoryPreset[] = [
|
|
|
1113
1113
|
"{creature || A monstrous yeti with the physique and bone structure of a giant silverback gorilla}, {texture || thick matted white fur with snow crusted on the shoulders}, charges forward and smashes both fists into the ground. Low-angle ground shot looking up to emphasize its scale, heavy camera shake on impact, snow and debris bursting toward the lens. Overcast mountain light, volumetric breath mist around its muzzle. <deep guttural roar, the crunch of impact, debris scattering>(low ominous brass drone)Biologically accurate anatomy, epic cinematic scale. HD, sharp micro-details, stable picture, keep it subtitle-free, do not generate a watermark.",
|
|
1114
1114
|
},
|
|
1115
1115
|
},
|
|
1116
|
+
// ── Video Editing ─────────────────────────────────────────────────────────
|
|
1117
|
+
// Seedance 2.5 EDITS a wired reference clip when the prompt reads as an edit
|
|
1118
|
+
// instruction. Edit mode takes the source clip's own ratio and length, so the
|
|
1119
|
+
// preset ships the exact shape the provider requires — Adaptive + Auto
|
|
1120
|
+
// (duration -1) — and the run goes through on the first submit. The user's
|
|
1121
|
+
// own sentence is the whole prompt: the preset only supplies the pre & post
|
|
1122
|
+
// text, with the clip bound as {video:1} (wire it into Video Refs).
|
|
1123
|
+
{
|
|
1124
|
+
id: "generate-video/edit-video",
|
|
1125
|
+
name: "Edit Video",
|
|
1126
|
+
description: "Change a wired clip by instruction — keeps its length, ratio and everything you don't mention.",
|
|
1127
|
+
group: "Video Editing",
|
|
1128
|
+
data: {
|
|
1129
|
+
provider: "seedance-2-5",
|
|
1130
|
+
aspectRatio: "adaptive",
|
|
1131
|
+
duration: -1,
|
|
1132
|
+
generateAudio: true,
|
|
1133
|
+
promptPrefix: "edit {video:1} as follows:\n",
|
|
1134
|
+
promptSuffix:
|
|
1135
|
+
"Keep everything else in the clip unchanged: the same people, motion, framing and timing. Keep it subtitle-free, do not generate a watermark.",
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1116
1138
|
]
|
package/src/index.ts
CHANGED
|
@@ -96,3 +96,6 @@ export * from "./surround-fill.js"
|
|
|
96
96
|
// --- Start/end frame delivery: one plan for the dispatch and the editor ---
|
|
97
97
|
export * from "./frame-delivery.js"
|
|
98
98
|
export * from "./character-motion-diagnostics.js"
|
|
99
|
+
|
|
100
|
+
// --- Per-ad creative analysis for the social scraper nodes ---
|
|
101
|
+
export * from "./ad-creative-analysis.js"
|
|
@@ -128,6 +128,10 @@ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
|
|
|
128
128
|
// Video Analysis — its focus hint is the editable prompt (renders a JSON scene
|
|
129
129
|
// table, not a media preview → no inline editor).
|
|
130
130
|
"video-analysis": { prompt: "analysisFocus", promptLabel: "Analysis focus", media: "video", inline: false },
|
|
131
|
+
// Edit Plan (podcast editing) — its free-text editing instructions are the
|
|
132
|
+
// editable prompt; the affixes wrap them. Emits an EDL (JSON), not a media
|
|
133
|
+
// preview, so no inline editor.
|
|
134
|
+
"edit-plan": { prompt: "instructions", promptLabel: "Editing instructions", media: "video", inline: false },
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/** The prompt-field spec for a node type, or undefined if it has none. */
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -119,6 +119,16 @@ export interface PickerOption {
|
|
|
119
119
|
* `label` and inject `term`; they never derive it themselves.
|
|
120
120
|
*/
|
|
121
121
|
readonly term: string
|
|
122
|
+
/**
|
|
123
|
+
* Transitions only: present (`true`) on a row whose mechanism is a CUT, so
|
|
124
|
+
* it takes no duration — see `Transition.instant` / `isInstantTransition`.
|
|
125
|
+
* A consumer that only reads this wire catalog (Studio builds the transition
|
|
126
|
+
* Duration lever from `getPickerCatalog("transition")`) hides that lever for
|
|
127
|
+
* such a row. Carried from the base catalog; a row a catalog pack ADDS has
|
|
128
|
+
* it only when the pack's own option says so, and absent means "has a
|
|
129
|
+
* duration" — the safe reading, since the composer then behaves as before.
|
|
130
|
+
*/
|
|
131
|
+
readonly instant?: true
|
|
122
132
|
/** Only present if the source catalog entry already carries a data icon/emoji/thumbnail field. */
|
|
123
133
|
readonly icon?: string
|
|
124
134
|
}
|
|
@@ -172,6 +182,8 @@ interface BaseCatalogEntry {
|
|
|
172
182
|
readonly term?: string
|
|
173
183
|
/** W1-a: see Person.adultOnly. Propagated verbatim into the flattened option. */
|
|
174
184
|
readonly adultOnly?: true
|
|
185
|
+
/** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
|
|
186
|
+
readonly instant?: boolean
|
|
175
187
|
}
|
|
176
188
|
|
|
177
189
|
/**
|
|
@@ -200,6 +212,7 @@ function toOptions<T extends BaseCatalogEntry>(
|
|
|
200
212
|
if (e.description) opt.description = e.description
|
|
201
213
|
if (categoryField) opt.category = e[categoryField] as unknown as string
|
|
202
214
|
if (e.adultOnly) opt.adultOnly = true
|
|
215
|
+
if (e.instant) opt.instant = true
|
|
203
216
|
return opt as PickerOption
|
|
204
217
|
})
|
|
205
218
|
}
|
package/src/ref-binding.ts
CHANGED
|
@@ -43,3 +43,22 @@ export const REF_BINDING = {
|
|
|
43
43
|
frame: (n: number, role: "opening" | "closing") =>
|
|
44
44
|
`Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
|
|
45
45
|
} as const
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The instruction that makes Seedance EDIT a wired clip rather than use it as a
|
|
49
|
+
* style reference — the Video to Video node's Seedance lane prepends it to the
|
|
50
|
+
* user's own sentence. Written with the EDITOR token (`{video:1}`) so it goes
|
|
51
|
+
* through the same reference resolver as any prompt: the source clip is the
|
|
52
|
+
* run's first reference video, so it resolves to `@video_1` on the wire. The
|
|
53
|
+
* instruction lands on its own line after it.
|
|
54
|
+
*/
|
|
55
|
+
export const SEEDANCE_VIDEO_EDIT_PREFIX = "edit {video:1} as follows:\n"
|
|
56
|
+
|
|
57
|
+
/** The user's sentence, framed as an edit of the source clip. Idempotent: a
|
|
58
|
+
* prompt that already opens with the instruction (a preset's pre text, a
|
|
59
|
+
* hand-written one, either token spelling) is left alone. */
|
|
60
|
+
export function buildSeedanceVideoEditPrompt(prompt: string | undefined): string {
|
|
61
|
+
const body = (prompt ?? "").trim()
|
|
62
|
+
if (/^edit\s+(?:\{video:1(?::[^}]*)?\}|@video_1(?!\w))/i.test(body)) return body
|
|
63
|
+
return `${SEEDANCE_VIDEO_EDIT_PREFIX}${body}`
|
|
64
|
+
}
|