@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.21.0",
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.11.0"
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.prompt as string) ?? "",
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.prompt as string | undefined) ?? ""
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": "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",
@@ -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
  })
@@ -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
  }
@@ -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. */
@@ -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
  }
@@ -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
+ }