@nodaro/prompts 1.29.0 → 1.32.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.
@@ -0,0 +1,109 @@
1
+ // packages/prompts/src/caption-treatments.ts
2
+ import type { CaptionPlan, SupportedFontName } from "@nodaro/shared"
3
+ import { ADD_CAPTIONS_PRESETS } from "./factory-presets/video-edit.js"
4
+
5
+ export const HOOK_PLATE_PRESET_ID = "add-captions/hook-plate"
6
+ export const BODY_CAPTIONS_PRESET_ID = "add-captions/body-captions"
7
+
8
+ /** Levers a spoken language needs on every caption segment; a language with no entry adds none. */
9
+ export interface CaptionLanguageLevers {
10
+ readonly fontFamily?: SupportedFontName
11
+ readonly uppercase?: boolean
12
+ }
13
+
14
+ /**
15
+ * Keyed by primary language tag. Hebrew: one face that carries Hebrew and Latin, so a Latin
16
+ * brand inside a Hebrew line is drawn in the same face, and no upper-casing, so the brand keeps
17
+ * its spelling.
18
+ */
19
+ export const CAPTION_LEVERS_BY_LANGUAGE: Readonly<Record<string, CaptionLanguageLevers>> = {
20
+ he: { fontFamily: "Rubik", uppercase: false },
21
+ }
22
+
23
+ /** The add-captions segment keys a lever set may carry: the segment schema minus times and words. */
24
+ export const CAPTION_SEGMENT_LEVER_KEYS: readonly string[] = [
25
+ "style", "position", "fontSize", "color", "backgroundColor", "look", "fontFamily", "fontWeight",
26
+ "strokeColor", "strokeWidth", "highlightColor", "uppercase", "positionY", "animate", "maxWordsPerLine",
27
+ ]
28
+
29
+ export type CaptionSegmentRole = "hook" | "body"
30
+
31
+ export interface HookPlateSegmentsOptions {
32
+ /** The body's levers; default = the Body Captions preset. Keys outside CAPTION_SEGMENT_LEVER_KEYS are ignored. */
33
+ readonly bodyLevers?: Readonly<Record<string, unknown>>
34
+ }
35
+
36
+ export interface HookPlateSegmentsResult {
37
+ /** Route-shaped (camelCase) `segments`. Empty means "no captions". */
38
+ readonly segments: Record<string, unknown>[]
39
+ /** Segments left out, in order hook, body. */
40
+ readonly dropped: CaptionSegmentRole[]
41
+ }
42
+
43
+ const LEVER_KEYS: ReadonlySet<string> = new Set(CAPTION_SEGMENT_LEVER_KEYS)
44
+
45
+ /** A lever set: segment lever keys only, without null or undefined values. */
46
+ function pickLevers(source: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> {
47
+ const out: Record<string, unknown> = {}
48
+ if (!source) return out
49
+ for (const [k, v] of Object.entries(source)) if (LEVER_KEYS.has(k) && v !== undefined && v !== null) out[k] = v
50
+ return out
51
+ }
52
+
53
+ function presetLevers(id: string): Record<string, unknown> {
54
+ const preset = ADD_CAPTIONS_PRESETS.find((p) => p.id === id)
55
+ if (!preset) throw new Error(`The ${id} caption preset is missing from the catalog.`)
56
+ return pickLevers(preset.data)
57
+ }
58
+
59
+ const PLATE_LEVERS = presetLevers(HOOK_PLATE_PRESET_ID)
60
+ const BODY_LEVERS = presetLevers(BODY_CAPTIONS_PRESET_ID)
61
+
62
+ function languageLevers(tag: string | undefined): CaptionLanguageLevers {
63
+ if (typeof tag !== "string") return {}
64
+ const primary = tag.trim().split(/[-_]/)[0]!.toLowerCase()
65
+ return Object.hasOwn(CAPTION_LEVERS_BY_LANGUAGE, primary) ? CAPTION_LEVERS_BY_LANGUAGE[primary]! : {}
66
+ }
67
+
68
+ const isTime = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n)
69
+
70
+ /**
71
+ * One caption plan → the `segments` of one add-captions request: the opening line as the Hook
72
+ * Plate, then the body's words in the Body Captions style (or the caller's own body levers).
73
+ * The styling is read from the two presets, never restated here. Pure: no I/O.
74
+ */
75
+ export function hookPlateCaptionSegments(plan: CaptionPlan, opts: HookPlateSegmentsOptions = {}): HookPlateSegmentsResult {
76
+ const hookEnd = plan.hookEndMs
77
+ if (!isTime(hookEnd) || hookEnd < 0) return { segments: [], dropped: ["hook", "body"] }
78
+ const language = languageLevers(plan.language)
79
+ const limit = typeof plan.videoDurationMs === "number" ? plan.videoDurationMs : Number.POSITIVE_INFINITY
80
+ const segments: Record<string, unknown>[] = []
81
+ const dropped: CaptionSegmentRole[] = []
82
+
83
+ const plateEnd = Math.min(hookEnd, limit)
84
+ if (plateEnd > 0 && typeof plan.hookText === "string" && /\S/.test(plan.hookText)) {
85
+ segments.push({ startMs: 0, endMs: plateEnd, text: plan.hookText, ...PLATE_LEVERS, ...language })
86
+ } else {
87
+ dropped.push("hook")
88
+ }
89
+
90
+ // The body starts where the opening line ends, even when no plate is shown, so it never
91
+ // draws over the opening line.
92
+ const bodyEnd = Math.min(plan.bodyEndMs, limit)
93
+ const words = Array.isArray(plan.captions) ? plan.captions : []
94
+ const chosen = pickLevers(opts.bodyLevers)
95
+ const bodyLevers = Object.keys(chosen).length > 0 ? chosen : BODY_LEVERS
96
+ if (bodyEnd > hookEnd && words.length > 0) {
97
+ segments.push({
98
+ startMs: hookEnd,
99
+ endMs: bodyEnd,
100
+ captions: words.map((w) => ({ text: w.text, startMs: w.startMs, endMs: w.endMs })),
101
+ ...bodyLevers,
102
+ ...language,
103
+ })
104
+ } else {
105
+ dropped.push("body")
106
+ }
107
+
108
+ return { segments, dropped }
109
+ }
@@ -53,6 +53,27 @@ export const TEXT_TO_AUDIO_PRESETS: readonly FactoryPreset[] = [
53
53
  group: "Ambiences (loopable)",
54
54
  data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.5, prompt: "low sci-fi ambient drone, ominous spaceship hum" },
55
55
  },
56
+ {
57
+ id: "text-to-audio/living-room-tone",
58
+ name: "Living Room Tone",
59
+ description: "Quiet, lived-in room.",
60
+ group: "Ambiences (loopable)",
61
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "quiet living room room tone, faint air and a soft distant household hum, steady and even, an empty room at rest" },
62
+ },
63
+ {
64
+ id: "text-to-audio/kitchen-hum",
65
+ name: "Kitchen Hum",
66
+ description: "Fridge hum in a still kitchen.",
67
+ group: "Ambiences (loopable)",
68
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "home kitchen room tone, a low steady refrigerator hum with faint air, calm and continuous, an empty kitchen at rest" },
69
+ },
70
+ {
71
+ id: "text-to-audio/cafe-murmur",
72
+ name: "Café Murmur",
73
+ description: "Soft, indistinct café chatter.",
74
+ group: "Ambiences (loopable)",
75
+ data: { provider: "elevenlabs-sfx", duration: 22, loop: true, promptInfluence: 0.4, prompt: "café background ambience, a soft indistinct murmur of distant conversation and the occasional faint cup clink, steady and even, no clear words" },
76
+ },
56
77
 
57
78
  // ── UI & Stingers ────────────────────────────────────────────────────────
58
79
  {
@@ -80,6 +80,24 @@ export const LLM_CHAT_PRESETS: readonly FactoryPreset[] = [
80
80
  temperature: 0.7,
81
81
  },
82
82
  },
83
+ {
84
+ id: "llm-chat/hook-generator",
85
+ name: "Hook Generator",
86
+ description: "10 opening lines for a talking-to-camera video.",
87
+ group: "Writing & Marketing",
88
+ data: {
89
+ systemPrompt:
90
+ "You write opening lines for short vertical videos in which one person talks straight to the camera about a product, a site or an idea. Given the user's topic, write 10 different opening lines.\n\n" +
91
+ "Every line:\n" +
92
+ "- is one short spoken clause of at most 8 words;\n" +
93
+ "- leads with the payload the viewer can check: a number, a price, a time saved, a before and after, a contrast with the usual way, or a named result;\n" +
94
+ "- opens on the claim itself — no greeting, no filler word, no introduction of the product before the claim;\n" +
95
+ "- sounds said, not written: plain words, contractions welcome, no hashtags, no emoji, no stage directions;\n" +
96
+ "- spells the product or brand exactly as the user gives it and writes numbers as digits.\n\n" +
97
+ "Vary the angle across the 10: a surprising number, a price, time saved, a mistake to avoid, a before and after, a contrast, a named result, a direct question. Write in the language the user writes in. Return only the numbered list, one line each.",
98
+ temperature: 0.9,
99
+ },
100
+ },
83
101
 
84
102
  // ── Utility ──────────────────────────────────────────────────────────────
85
103
  {
@@ -273,6 +291,17 @@ export const IMAGE_TO_TEXT_PRESETS: readonly FactoryPreset[] = [
273
291
  group: "Extraction",
274
292
  data: { detailLevel: "detailed", customPrompt: "Write a compelling e-commerce product description based on this product image." },
275
293
  },
294
+ {
295
+ id: "image-to-text/site-screenshot-summary",
296
+ name: "Site Screenshot Summary",
297
+ description: "Section, headings and figures from a web page.",
298
+ group: "Extraction",
299
+ data: {
300
+ detailLevel: "structured",
301
+ customPrompt:
302
+ "Describe this website screenshot as five labelled lines. Section: which part of the site it is (for example the top of the home page, a feature, how it works, reviews, pricing, sign-up, navigation or footer). Headings: every heading exactly as written. Figures: every number, price, rating and date exactly as written, each with what it refers to. Brand: the product or company name exactly as spelled. First look: what a viewer notices first, in one sentence. Write \"none\" on a line with nothing to report.",
303
+ },
304
+ },
276
305
 
277
306
  // ── Creative ─────────────────────────────────────────────────────────────
278
307
  {
@@ -95,6 +95,20 @@ export const ADD_CAPTIONS_PRESETS: readonly FactoryPreset[] = [
95
95
  group: "Caption Styles",
96
96
  data: { style: "subtitle", position: "top", fontSize: 36, color: "#FFFFFF", autoTranscribe: true },
97
97
  },
98
+ {
99
+ id: "add-captions/hook-plate",
100
+ name: "Hook Plate",
101
+ description: "Your opening line as a bold plate near the top.",
102
+ group: "Caption Styles",
103
+ data: { style: "subtitle", look: "clean", position: "top", positionY: 20, fontSize: 32, fontWeight: 800, maxWordsPerLine: 4, color: "#111111", backgroundColor: "#FFFFFF", autoTranscribe: false },
104
+ },
105
+ {
106
+ id: "add-captions/body-captions",
107
+ name: "Body Captions",
108
+ description: "Word-by-word captions under the face.",
109
+ group: "Caption Styles",
110
+ data: { style: "word-highlight", look: "outline", position: "bottom", positionY: 72, fontSize: 32, maxWordsPerLine: 3, color: "#FFFFFF", autoTranscribe: true },
111
+ },
98
112
  ]
99
113
 
100
114
  export const COMBINE_VIDEOS_PRESETS: readonly FactoryPreset[] = [
@@ -115,6 +115,20 @@ export const VOICE_DESIGN_PRESETS: readonly FactoryPreset[] = [
115
115
  group: "Narration & Character",
116
116
  data: { voiceDescription: "A calm, soft-spoken meditation guide with a slow, breathy, reassuring delivery." },
117
117
  },
118
+ {
119
+ id: "voice-design/casual-creator-female",
120
+ name: "Casual Creator (female)",
121
+ description: "Relaxed, warm, everyday.",
122
+ group: "Narration & Character",
123
+ data: { voiceDescription: "An adult woman in her late twenties talking casually to the viewer as if telling a friend about something she tried: relaxed, warm and upbeat, a natural conversational pace with small pauses, a light neutral American accent, recorded close and dry in a quiet room, never polished or announcer-like." },
124
+ },
125
+ {
126
+ id: "voice-design/casual-creator-male",
127
+ name: "Casual Creator (male)",
128
+ description: "Relaxed, warm, everyday.",
129
+ group: "Narration & Character",
130
+ data: { voiceDescription: "An adult man in his late twenties talking casually to the viewer as if telling a friend about something he tried: relaxed, warm and upbeat, a natural conversational pace with small pauses, a light neutral American accent, recorded close and dry in a quiet room, never polished or announcer-like." },
131
+ },
118
132
 
119
133
  // ── Professional & Assistant ─────────────────────────────────────────────
120
134
  {
package/src/index.ts CHANGED
@@ -86,8 +86,10 @@ export * from "./image-reference-doctrine.js"
86
86
  export * from "./scene3d-reference-doctrine.js"
87
87
  export * from "./prompt-wizard-categories.js"
88
88
  export * from "./resolve-prompt.js"
89
+ export { stripAudioTags } from "./audio-tags.js"
89
90
  export * from "./node-prompt-fields.js"
90
91
  export * from "./factory-presets.js"
92
+ export * from "./caption-treatments.js"
91
93
  export * from "./style-presets.js"
92
94
  export * from "./object-asset-presets.js"
93
95
  export * from "./factory-snippets/index.js"
@@ -103,3 +105,6 @@ export * from "./ad-creative-analysis.js"
103
105
 
104
106
  // --- Picker art: option pictures + topic icons (editor and API) ---
105
107
  export * from "./picker-art/index.js"
108
+
109
+ // --- What a text-requiring node would send (the orchestrator's empty-input skip reads it) ---
110
+ export * from "./node-send-text.js"
@@ -0,0 +1,120 @@
1
+ import { SOCIAL_POST_NODE_TYPES } from "@nodaro/shared"
2
+ import { computeLlmChatFields, computeNodePrompt, computeScriptTopic } from "./resolve-prompt.js"
3
+
4
+ /**
5
+ * Output nodes that send exactly what is WIRED into them — a webhook's
6
+ * parameters, a social post's caption and media. When every wire produced
7
+ * nothing in this run (a Filter List that kept no row, a node that was itself
8
+ * skipped), there is nothing to send: the node is skipped with
9
+ * `skipReason: "empty_input"` instead of posting an empty payload (a site that
10
+ * validates its input answers 400 and alerts its owner) or failing the run
11
+ * (a publish route refuses a post with no text and no media). A typed caption
12
+ * with no wire at all is the author's choice and still runs, as before. ONE
13
+ * list, read by the orchestrator's skip rule (`empty-input-skips.ts`).
14
+ */
15
+ export const WIRED_OUTPUT_NODE_TYPES: ReadonlySet<string> = new Set(["webhook-output", ...SOCIAL_POST_NODE_TYPES])
16
+
17
+ /**
18
+ * Node types that send TEXT to a model or a voice and fail on an empty one —
19
+ * `userInput: Too small` (llm-chat), "no text found" (text-to-speech) — so a
20
+ * run with nothing upstream can skip them instead of failing. Never an image
21
+ * or video node (an empty prompt is a legal request there) and never a social
22
+ * post (an empty caption is the author's choice). ONE list, read by the
23
+ * orchestrator's skip rule (`empty-input-skips.ts`); the engine never names a
24
+ * type on its own.
25
+ */
26
+ export const TEXT_REQUIRED_NODE_TYPES: ReadonlySet<string> = new Set([
27
+ "llm-chat",
28
+ "ai-writer",
29
+ "generate-script",
30
+ "text-to-speech",
31
+ "generate-music",
32
+ "text-to-audio",
33
+ // Save to Collection refuses an empty record ("nothing to save"): a feed
34
+ // with nothing new wired straight into it must skip it, not fail the run.
35
+ "collection-write",
36
+ // Generate Image: an empty prompt is a legal request in general (references
37
+ // alone), but when the text it was WIRED produced nothing this run — the skip
38
+ // rule's precondition — an empty prompt is a paid picture of nothing.
39
+ "generate-image",
40
+ ])
41
+
42
+ /**
43
+ * The node's data without its prompt pre/post text: the skip decision is about
44
+ * the CORE text. A prefix alone ("Write a news item about:") is not something
45
+ * to send — with the affixes applied, an empty core read as non-empty and an
46
+ * idle tick paid for a model call on the prefix.
47
+ */
48
+ const withoutAffixes = (data: Record<string, unknown>): Record<string, unknown> => ({ ...data, promptPrefix: undefined, promptSuffix: undefined })
49
+
50
+ export interface NodeSendTextArgs {
51
+ /** A list fan-out item (highest precedence). */
52
+ override?: string
53
+ /** The text wired into the node's main text input. */
54
+ wired?: string
55
+ /** llm-chat only: the text wired into `system-prompt`. */
56
+ wiredSystemPrompt?: string
57
+ refMap: ReadonlyMap<string, string>
58
+ }
59
+
60
+ const present = (s?: string): s is string => typeof s === "string" && s.trim().length > 0
61
+
62
+ /**
63
+ * ai-writer's user input — the rule its sync-HTTP body applies: a fan-out
64
+ * item, else the wired text, else the typed `userInput`, else the legacy
65
+ * `prompt`. (ai-writer is renamed to llm-chat on load; saved graphs that
66
+ * never reloaded still run it.)
67
+ */
68
+ export function computeAiWriterInput(
69
+ data: Record<string, unknown>,
70
+ { override, wired }: Pick<NodeSendTextArgs, "override" | "wired">,
71
+ ): string {
72
+ return [override, wired, data.userInput as string | undefined, data.prompt as string | undefined].find(present) ?? ""
73
+ }
74
+
75
+ /**
76
+ * The text a text-requiring node would send, by the SAME rule its executor
77
+ * uses (`computeLlmChatFields`, `computeScriptTopic`, `computeNodePrompt`,
78
+ * `computeAiWriterInput`) — so "would this node run on nothing?" is answered
79
+ * by the function that decides what it sends, not by a second reading of it.
80
+ * Undefined for any other node type.
81
+ */
82
+ export function computeNodeSendText(
83
+ nodeType: string,
84
+ data: Record<string, unknown>,
85
+ args: NodeSendTextArgs,
86
+ ): string | undefined {
87
+ const bare = withoutAffixes(data)
88
+ switch (nodeType) {
89
+ case "llm-chat":
90
+ return computeLlmChatFields(bare, {
91
+ override: args.override,
92
+ wiredUserInput: args.wired,
93
+ wiredSystemPrompt: args.wiredSystemPrompt,
94
+ refMap: args.refMap,
95
+ }).userInput
96
+ case "ai-writer":
97
+ return computeAiWriterInput(bare, args)
98
+ case "generate-script":
99
+ return computeScriptTopic(bare, { override: args.override, wired: args.wired, refMap: args.refMap })
100
+ case "generate-music": {
101
+ // A music node sings typed lyrics with no prompt; only both empty is nothing to send.
102
+ const prompt = computeNodePrompt(nodeType, bare, { override: args.override, wired: args.wired, refMap: args.refMap })
103
+ return present(prompt) ? prompt : present(data.lyrics as string | undefined) ? (data.lyrics as string) : ""
104
+ }
105
+ case "text-to-speech":
106
+ case "text-to-audio":
107
+ case "generate-image":
108
+ return computeNodePrompt(nodeType, bare, { override: args.override, wired: args.wired, refMap: args.refMap })
109
+ case "collection-write": {
110
+ // The record's content: the item (a fan-out row, else what reached `in`),
111
+ // else a title, text or link typed on the node. A picture alone is a
112
+ // record too, but it never starves the node: only a wired TEXT input that
113
+ // produced nothing can (the skip rule's precondition).
114
+ const typed = [data.title, data.text, data.link].find((v): v is string => present(v as string | undefined))
115
+ return [args.override, args.wired, typed].find(present) ?? ""
116
+ }
117
+ default:
118
+ return undefined
119
+ }
120
+ }