@nodaro/prompts 1.8.1 → 1.10.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,206 @@
1
+ /**
2
+ * Single source of truth: which data field(s) hold a node's user-editable
3
+ * prompt text. Drives the quick-edit Prompt modal so it can work generically
4
+ * across every AI node without each call site hardcoding a field name.
5
+ *
6
+ * The registry LIVES HERE (`@nodaro/prompts`) so the backend orchestrator, the
7
+ * `/v1/nodes` registry, the prompt-affix totality tests and the docs tooling
8
+ * read the SAME list the editor does. `frontend/src/lib/prompt-fields.ts` is a
9
+ * pure re-export of this module — frontend code keeps importing
10
+ * `@/lib/prompt-fields` unchanged.
11
+ *
12
+ * Most nodes store the prompt in `data.prompt`, but several don't
13
+ * (`text-prompt` → `text`, `image-to-text` → `customPrompt`, …) — this map is
14
+ * where that knowledge lives, once.
15
+ *
16
+ * INVARIANT: every node that exposes a user-editable prompt MUST have an entry
17
+ * here, or the quick-edit modal silently does nothing for it. A guard test
18
+ * (`prompt-fields.test.ts`) keeps this in sync with the node set.
19
+ *
20
+ * This module is intentionally pure data (no React/lucide imports) so it can be
21
+ * pulled into the app-runtime bundle and any test without dragging in the icon
22
+ * library. The string `icon` kind is mapped to a concrete lucide component in
23
+ * `prompt-edit-button.tsx` (`getPromptIcon`), the only place that renders it.
24
+ */
25
+
26
+ import type { SnippetMedia } from "./factory-snippets/types.js"
27
+ import { NODE_PROMPT_CANDIDATE_FIELDS } from "./resolve-prompt.js"
28
+
29
+ /** Which lucide glyph a node's prompt affordance uses. Kept as a string so this
30
+ * module stays icon-library-free; mapped to a component in the strip button. */
31
+ export type PromptIconKind = "pencil" | "paintbrush"
32
+
33
+ export interface PromptFieldSpec {
34
+ /** Data key holding the primary prompt (e.g. "prompt", "text", "customPrompt"). */
35
+ readonly prompt: string
36
+ /** Data key holding the negative prompt, when the node supports one. */
37
+ readonly negative?: string
38
+ /** Label override for the primary field (defaults to "Prompt"). */
39
+ readonly promptLabel?: string
40
+ /** Icon for the prompt affordance (strip button + modal title). Defaults to a
41
+ * pencil; image-editing nodes use a paintbrush to read as "edit". */
42
+ readonly icon?: PromptIconKind
43
+ /** Node modality for prompt-snippet scoping — drives which snippet pool the
44
+ * "/" menu and Snippets button show for this node's prompt fields. REQUIRED
45
+ * so a new node cannot forget to declare it (compile error). */
46
+ readonly media: SnippetMedia
47
+ /** True when this node renders a media-result preview body and therefore
48
+ * participates in inline-prompt mode (the `InlineNodePrompt` editor on the
49
+ * node face, centralized in `BaseNode`). REQUIRED (like `media`) so a new
50
+ * prompt node must consciously choose — compile error if omitted. The set of
51
+ * `inline: true` types is guarded in `prompt-fields.test.ts`. */
52
+ readonly inline: boolean
53
+ /** Some nodes only READ their prompt field when a sibling discriminator
54
+ * selects it — TTS keeps `directText` but resolves it only while
55
+ * `textSource === "direct"`, defaulting to `"connected"`. Writing the text
56
+ * without flipping the discriminator saved the value and then failed with
57
+ * "no text found" (founder hit it live, 2026-08-14). Declare the pair here
58
+ * and every writer flips it automatically. */
59
+ readonly promptGate?: { readonly field: string; readonly value: string }
60
+ /** Prompt pre/post text (`promptPrefix` / `promptSuffix`) support. Defaults to
61
+ * ON for every prompt node; set `false` ONLY for a node whose "prompt" is not
62
+ * a model prompt (the plain Text input node). Read via `nodeSupportsPromptAffixes`. */
63
+ readonly affixes?: false
64
+ }
65
+
66
+ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
67
+ // ── Image ──
68
+ // (`edit-image` / `image-to-image` are legacy types consolidated into
69
+ // `modify-image`; they're not in the creatable node set, so a node of that
70
+ // type never mounts and needs no entry here. The guard test enforces that.)
71
+ "generate-image": { prompt: "prompt", negative: "negativePrompt", media: "image", inline: true },
72
+ "modify-image": { prompt: "prompt", negative: "negativePrompt", icon: "paintbrush", media: "image", inline: true },
73
+ "generate-mask": { prompt: "prompt", promptLabel: "What to mask", media: "image", inline: true },
74
+ // ── Video ──
75
+ "generate-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
76
+ // Trimmed multi-segment stitch sibling of generate-video — no negativePrompt field.
77
+ "generate-video-pro": { prompt: "prompt", media: "video", inline: true },
78
+ // Span-replace sibling of generate-video-pro — no negativePrompt field.
79
+ "edit-video-pro": { prompt: "prompt", media: "video", inline: true },
80
+ "text-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
81
+ "image-to-video": { prompt: "prompt", negative: "negativePrompt", promptLabel: "Motion prompt", media: "video", inline: true },
82
+ "video-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
83
+ "switchx": { prompt: "prompt", promptLabel: "Look prompt", media: "video", inline: true },
84
+ "extend-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
85
+ "speech-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
86
+ "motion-transfer": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
87
+ // Cinematic Avatar (HeyGen) — generative prompt (NOT a verbatim script),
88
+ // so it participates in the quick-edit Prompt modal like other AI video nodes.
89
+ "cinematic-avatar": { prompt: "prompt", media: "video", inline: true },
90
+ "video-sfx": { prompt: "prompt", negative: "negativePrompt", promptLabel: "Sound prompt", media: "audio", inline: true },
91
+ "video-retake": { prompt: "prompt", promptLabel: "Retake prompt", media: "video", inline: true },
92
+ // ── Audio / music ──
93
+ "generate-music": { prompt: "prompt", media: "audio", inline: true },
94
+ "suno-generate": { prompt: "prompt", media: "audio", inline: true },
95
+ "text-to-audio": { prompt: "prompt", media: "audio", inline: true },
96
+ // ── Text / LLM (no media-result preview body → no inline editor) ──
97
+ "text-prompt": { prompt: "text", promptLabel: "Text", media: "text", inline: false, affixes: false },
98
+ "image-to-text": { prompt: "customPrompt", promptLabel: "Question", media: "text", inline: false },
99
+ "llm-chat": { prompt: "userInput", promptLabel: "Prompt", media: "text", inline: false },
100
+ // ── Speech / voice ──
101
+ "text-to-speech": { prompt: "directText", promptLabel: "Text", media: "audio", inline: true, promptGate: { field: "textSource", value: "direct" } },
102
+ "voice-design": { prompt: "voiceDescription", promptLabel: "Voice description", media: "audio", inline: true },
103
+ "voice-remix": { prompt: "voiceDescription", promptLabel: "Voice description", media: "audio", inline: true },
104
+ "lip-sync": { prompt: "prompt", media: "audio", inline: true },
105
+ // ── Suno (music) ──
106
+ "suno-cover": { prompt: "prompt", media: "audio", inline: true },
107
+ "suno-extend": { prompt: "prompt", media: "audio", inline: true },
108
+ "suno-replace-section": { prompt: "prompt", media: "audio", inline: true },
109
+ "suno-upload-extend": { prompt: "prompt", media: "audio", inline: true },
110
+ // NOTE: `suno-add-vocals` is intentionally absent — its node has no
111
+ // user-editable prompt (SunoAddVocalsData declares only `model`; the config
112
+ // panel + backend route take taskId/audioId/model, never a prompt). A stale
113
+ // entry here rendered a phantom Prompt editor in the quick-edit modal that
114
+ // wrote to a `data.prompt` key nothing ever reads (the dead-field class the
115
+ // guard test now catches via defaultData ownership).
116
+ "suno-lyrics": { prompt: "prompt", media: "audio", inline: false },
117
+ "suno-style-boost": { prompt: "content", promptLabel: "Style", media: "audio", inline: false },
118
+ // ── Composition / FX (compact, no media-result preview → no inline editor) ──
119
+ "image-critic": { prompt: "prompt", promptLabel: "Criteria", media: "image", inline: false },
120
+ "motion-graphics": { prompt: "motionPrompt", promptLabel: "Motion prompt", media: "video", inline: false },
121
+ "3d-title": { prompt: "titlePrompt", promptLabel: "Title", media: "text", inline: false },
122
+ // ── Script / alignment (their primary text field) ──
123
+ "generate-script": { prompt: "styleGuide", promptLabel: "Style guide", media: "text", inline: false },
124
+ "forced-alignment": { prompt: "transcript", promptLabel: "Transcript", media: "audio", inline: false },
125
+ // Video Analysis — its focus hint is the editable prompt (renders a JSON scene
126
+ // table, not a media preview → no inline editor).
127
+ "video-analysis": { prompt: "analysisFocus", promptLabel: "Analysis focus", media: "video", inline: false },
128
+ }
129
+
130
+ /** The prompt-field spec for a node type, or undefined if it has none. */
131
+ export function getPromptFields(nodeType: string | undefined): PromptFieldSpec | undefined {
132
+ return nodeType ? NODE_PROMPT_FIELDS[nodeType] : undefined
133
+ }
134
+
135
+ /** True when this node type has a registered, quick-editable prompt field. */
136
+ export function nodeHasPromptField(nodeType: string | undefined): boolean {
137
+ return getPromptFields(nodeType) !== undefined
138
+ }
139
+
140
+ /** Snippet modality for a node type (drives the snippet pool), or undefined
141
+ * when the node has no prompt field. */
142
+ export function getSnippetMedia(nodeType: string | undefined): SnippetMedia | undefined {
143
+ return getPromptFields(nodeType)?.media
144
+ }
145
+
146
+ /** True when this node type renders the inline on-node prompt editor — the
147
+ * media-preview nodes (image/video/audio result body). Single source for
148
+ * BaseNode's centralized `InlineNodePrompt` rendering and the gold nodes'
149
+ * `showInline` derivation (`useInlinePromptActive`). */
150
+ export function nodeHasInlinePrompt(nodeType: string | undefined): boolean {
151
+ return getPromptFields(nodeType)?.inline === true
152
+ }
153
+
154
+ /** True when the node type carries `promptPrefix` / `promptSuffix` — every
155
+ * registered prompt node except explicit opt-outs (`affixes: false`). The single
156
+ * predicate the config panel, Final view, `/v1/nodes` registry, totality tests
157
+ * and docs tooling all gate on. */
158
+ export function nodeSupportsPromptAffixes(nodeType: string | undefined): boolean {
159
+ const spec = getPromptFields(nodeType)
160
+ return spec !== undefined && spec.affixes !== false
161
+ }
162
+
163
+ /**
164
+ * Where the RUN-TIME prompt lives when it differs from the editor's prompt
165
+ * field (spec §7). `generate-script`'s editable prompt is its `styleGuide`, but
166
+ * the run wraps the topic `prompt` with the affixes — so previewing the style
167
+ * guide must NOT show them.
168
+ */
169
+ export const PROMPT_AFFIX_CORE_FIELD_OVERRIDES: Readonly<Record<string, string>> = { "generate-script": "prompt" }
170
+
171
+ /** The data key whose value the run wraps with promptPrefix/promptSuffix, or
172
+ * undefined when the node has no affixes. */
173
+ export function promptAffixCoreField(nodeType: string | undefined): string | undefined {
174
+ if (!nodeSupportsPromptAffixes(nodeType)) return undefined
175
+ return PROMPT_AFFIX_CORE_FIELD_OVERRIDES[nodeType!] ?? getPromptFields(nodeType)!.prompt
176
+ }
177
+
178
+ /**
179
+ * True when a preview of `promptField` on this node must show the affixes —
180
+ * i.e. that key is what the run actually wraps. THE gate for the editor's Final
181
+ * view, so a sibling field (llm-chat's `systemPrompt`, generate-script's
182
+ * `styleGuide`) never renders a wrap the run doesn't perform.
183
+ *
184
+ * Two ways a key qualifies:
185
+ * - it IS the core field ({@link promptAffixCoreField}), or
186
+ * - it is a run-time FALLBACK candidate for the type
187
+ * ({@link NODE_PROMPT_CANDIDATE_FIELDS}) — `computeNodePrompt` picks the
188
+ * first present candidate and wraps THAT, so i2v's legacy `motionPrompt`
189
+ * genuinely receives the affixes when `data.prompt` is empty.
190
+ *
191
+ * The two rules never collide: no override type declares candidates (guarded in
192
+ * `node-prompt-fields.test.ts`).
193
+ *
194
+ * `undefined` means "the node's primary prompt field" — the common call.
195
+ */
196
+ export function promptFieldCarriesAffixes(nodeType: string | undefined, promptField: string | undefined): boolean {
197
+ const core = promptAffixCoreField(nodeType)
198
+ if (core === undefined) return false
199
+ if (promptField === undefined) return getPromptFields(nodeType)!.prompt === core
200
+ return promptField === core || (NODE_PROMPT_CANDIDATE_FIELDS[nodeType!] ?? []).includes(promptField)
201
+ }
202
+
203
+ /** The affix-capable node types, derived from the registry (never hand-listed). */
204
+ export const PROMPT_AFFIX_NODE_TYPES: ReadonlySet<string> = new Set(
205
+ Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t),
206
+ )
@@ -51,8 +51,22 @@ import { POST_PROCESS_EFFECTS } from "./post-process-effects.js"
51
51
  import { CAMERA_MOTIONS, CAMERA_MOTION_CATEGORY_LABELS, CAMERA_MOTION_CATEGORY_ORDER } from "./camera-motions.js"
52
52
  import { LENSES } from "./lens.js"
53
53
  import { CAMERA_FORMATS } from "./camera-format.js"
54
- import { TRANSITIONS, TRANSITION_CATEGORY_LABELS, TRANSITION_CATEGORY_ORDER } from "./transitions.js"
55
- import { CHARACTER_FX, CHARACTER_FX_CATEGORY_LABELS, CHARACTER_FX_CATEGORY_ORDER } from "./character-fx.js"
54
+ import {
55
+ TRANSITIONS,
56
+ TRANSITION_CATEGORY_LABELS,
57
+ TRANSITION_CATEGORY_ORDER,
58
+ TRANSITION_POSITIONS,
59
+ TRANSITION_DURATIONS,
60
+ TRANSITION_INTENSITIES,
61
+ } from "./transitions.js"
62
+ import {
63
+ CHARACTER_FX,
64
+ CHARACTER_FX_CATEGORY_LABELS,
65
+ CHARACTER_FX_CATEGORY_ORDER,
66
+ CHARACTER_FX_POSITIONS,
67
+ CHARACTER_FX_DURATIONS,
68
+ CHARACTER_FX_INTENSITIES,
69
+ } from "./character-fx.js"
56
70
  import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
57
71
  import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
58
72
  import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
@@ -127,7 +141,10 @@ export interface PickerCatalog {
127
141
  readonly options?: readonly PickerOption[]
128
142
  /** multi-dim: the dimension keys (no single catalog to flatten). */
129
143
  readonly fields?: readonly string[]
130
- /** multi-dim: one self-describing entry per dimension field, in `fields` order. */
144
+ /** Per-field option lists. Multi-dim catalogs always carry these, one per
145
+ * `fields` entry in order; a single-dim catalog carries them when it has
146
+ * secondary parameter fields beside its main picker (transition
147
+ * position/duration/intensity). */
131
148
  readonly dimensions?: readonly PickerDimension[]
132
149
  }
133
150
 
@@ -447,7 +464,10 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
447
464
  catalogId: "composition-effects",
448
465
  kind: "single",
449
466
  valueField: "compositionEffect",
450
- defaultValue: "bursting-through-frame",
467
+ // No default effect — composition effects are dramatic subject transforms,
468
+ // so an unset node injects nothing until the user picks one (matches the
469
+ // "unset folds nothing" behaviour every consumer already honours).
470
+ defaultValue: "none",
451
471
  options: toOptions(COMPOSITION_EFFECTS),
452
472
  },
453
473
  {
@@ -522,6 +542,17 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
522
542
  categoryOrder: TRANSITION_CATEGORY_ORDER,
523
543
  categoryLabels: TRANSITION_CATEGORY_LABELS,
524
544
  options: toOptions(TRANSITIONS, "category"),
545
+ // The node's three timing parameters, alongside the transition itself.
546
+ // `dimensions` on a single-dim catalog carries exactly what it says on a
547
+ // multi-dim one — extra value fields with their own option lists — so a
548
+ // consumer that only sends ids can offer Position/Duration/Intensity
549
+ // without composing the clauses itself. The rich picker UI still renders
550
+ // only `options`; these fields are secondary controls beside it.
551
+ dimensions: perFieldDims([
552
+ ["position", TRANSITION_POSITIONS],
553
+ ["duration", TRANSITION_DURATIONS],
554
+ ["intensity", TRANSITION_INTENSITIES],
555
+ ]),
525
556
  },
526
557
  {
527
558
  nodeType: "character-fx",
@@ -533,6 +564,16 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
533
564
  categoryOrder: CHARACTER_FX_CATEGORY_ORDER,
534
565
  categoryLabels: CHARACTER_FX_CATEGORY_LABELS,
535
566
  options: toOptions(CHARACTER_FX, "category"),
567
+ // The node's three timing parameters, alongside the effect itself — the
568
+ // same shape `transition` carries above, but the character-fx scales, not
569
+ // the transition ones: the wording is deliberately different (an effect
570
+ // manifests and persists; a transition occurs and spans), so an id-only
571
+ // consumer must read these rows, never reuse the transition rows.
572
+ dimensions: perFieldDims([
573
+ ["position", CHARACTER_FX_POSITIONS],
574
+ ["duration", CHARACTER_FX_DURATIONS],
575
+ ["intensity", CHARACTER_FX_INTENSITIES],
576
+ ]),
536
577
  },
537
578
 
538
579
  // -------- "Subject / Object" family --------
@@ -816,10 +857,11 @@ export function summarizePickerCatalogs(): readonly PickerCatalogSummary[] {
816
857
  kind: c.kind,
817
858
  valueField: c.valueField,
818
859
  fields: c.fields,
860
+ // Every option the detail call will return, whichever kind — a single-dim
861
+ // catalog with secondary dimensions carries both.
819
862
  optionCount:
820
- c.kind === "single"
821
- ? (c.options?.length ?? 0)
822
- : (c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
863
+ (c.options?.length ?? 0) +
864
+ (c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
823
865
  }))
824
866
  }
825
867
 
@@ -876,22 +918,29 @@ export function projectPickerCatalog(
876
918
  categoryLabels: c.categoryLabels,
877
919
  detail,
878
920
  }
921
+ const projectDims = (dims: readonly PickerDimension[]) =>
922
+ dims.map((d) => ({
923
+ field: d.field,
924
+ label: d.label,
925
+ options: d.options.map((o) => projectOption(o, detail)),
926
+ }))
927
+
879
928
  if (c.kind === "single") {
880
929
  let options = c.options ?? []
881
930
  if (opts.category) options = options.filter((o) => o.category === opts.category)
882
- return { ...base, options: options.map((o) => projectOption(o, detail)) }
931
+ const projected = { ...base, options: options.map((o) => projectOption(o, detail)) }
932
+ // A single-dim catalog may still carry secondary parameter dimensions
933
+ // (transition's position/duration/intensity). They are additive — a
934
+ // consumer reading only `options` is unaffected — but dropping them here
935
+ // would hide the one thing an id-only client needs to offer those controls.
936
+ if (!c.dimensions) return projected
937
+ let dims = c.dimensions
938
+ if (opts.field) dims = dims.filter((d) => d.field === opts.field)
939
+ return { ...projected, dimensions: projectDims(dims) }
883
940
  }
884
941
  let dims = c.dimensions ?? []
885
942
  if (opts.field) dims = dims.filter((d) => d.field === opts.field)
886
- return {
887
- ...base,
888
- fields: c.fields,
889
- dimensions: dims.map((d) => ({
890
- field: d.field,
891
- label: d.label,
892
- options: d.options.map((o) => projectOption(o, detail)),
893
- })),
894
- }
943
+ return { ...base, fields: c.fields, dimensions: projectDims(dims) }
895
944
  }
896
945
 
897
946
  /**
@@ -147,7 +147,8 @@ export const SINGLE_PICKER_WIRING: ReadonlyArray<SingleDimPickerWiring> = [
147
147
  { kind: "single", nodeType: "photo-genre", label: "Photo Genre", valueField: "photoGenre", defaultValue: "fashion-editorial", catalogId: "photo-genre", entries: mapCat(PHOTO_GENRES, "category"), groupOrder: PHOTO_GENRE_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: PHOTO_GENRE_CATEGORY_LABELS },
148
148
  { kind: "single", nodeType: "backdrop", label: "Backdrop", valueField: "backdrop", defaultValue: "white-seamless", catalogId: "backdrop", entries: mapCat(BACKDROPS, "category"), groupOrder: BACKDROP_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: BACKDROP_CATEGORY_LABELS },
149
149
  { kind: "single", nodeType: "render-quality", label: "Render Quality", valueField: "renderQuality", defaultValue: "raytracing", catalogId: "render-quality", entries: mapCat(RENDER_QUALITIES) },
150
- { kind: "single", nodeType: "composition-effects", label: "Composition Effect", valueField: "compositionEffect", defaultValue: "bursting-through-frame", catalogId: "composition-effects", entries: mapCat(COMPOSITION_EFFECTS) },
150
+ // No default effect — see the note in picker-catalogs.ts.
151
+ { kind: "single", nodeType: "composition-effects", label: "Composition Effect", valueField: "compositionEffect", defaultValue: "none", catalogId: "composition-effects", entries: mapCat(COMPOSITION_EFFECTS) },
151
152
  { kind: "single", nodeType: "action-fx", label: "Action FX", valueField: "actionFx", defaultValue: "earthquake-tremor", catalogId: "action-fx", entries: mapCat(ACTION_FX, "category"), groupOrder: ACTION_FX_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: ACTION_FX_CATEGORY_LABELS as Record<string, string> },
152
153
  { kind: "single", nodeType: "loop-subject", label: "Loop Subject", valueField: "loopSubject", defaultValue: "tunnel", catalogId: "loop-subject", entries: mapCat(LOOP_SUBJECTS, "category"), groupOrder: LOOP_SUBJECT_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: LOOP_SUBJECT_CATEGORY_LABELS as Record<string, string> },
153
154
  { kind: "single", nodeType: "post-process-effects", label: "Post-Process Effect", valueField: "postProcess", defaultValue: "vignette-soft", catalogId: "post-process-effects", entries: mapCat(POST_PROCESS_EFFECTS) },
@@ -73,7 +73,7 @@ precise subject → action details → scene/environment → lighting & color to
73
73
  - Transitions and camera terms on 2.5: state a transition's trigger point AND method in one sentence — "At the 5-second mark, the camera quickly transitions leftward using a left wipe combined with a natural dissolve." Basic shot and camera terms are written directly (push in / pull out / pan / track / orbit / dolly zoom / whip pan / hard cut / dissolve / one-shot / speed ramp); only niche terms need [term + descriptive explanation] — which is exactly what the pickers' compact hint mode emits versus their long hints.
74
74
 
75
75
  **References (when reference media is attached)**
76
- - Refer to assets by ordinal in attachment order: "@Image 1", "Video 2", "Audio 1". Asset ORDER is priority — put the most identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding — \`{image:1:person}\` resolves to "the person from @image_1" — so a wired reference and its mention stay in sync.)
76
+ - Refer to assets by ordinal in attachment order: "@Image 1", "Video 2", "Audio 1". Asset ORDER is priority — put the most identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding — \`{image:1:person}\` resolves to "the person from @image_1" — so a wired reference and its mention stay in sync. An API caller that passes \`connectedReferences\` can instead name a reference by its own id — \`{ref:<id>}\` / \`{ref:<id>:label}\` — and the platform substitutes the \`@image_N\` seat after it has numbered the references, so the client never computes N; a token whose reference was not attached drops to its label or name.)
77
77
  - Define each subject once, then reuse the label consistently: 'Define the woman in the red dress in Image 1 as the courier' … 'the courier opens the door'. In multi-character scenes bind every character to its image ("the man from Image 1 hands the box to the woman from Image 2") and append: "do not generate duplicate copies of the same character".
78
78
  - Character identity: ONE close-up headshot + ONE full-body image is ideal. On the 2.0 SKUs do NOT attach multi-view/three-view character sheets — the model reads the views as separate people, causing identity drift and twin duplicates; 2.5 accepts multi-view images (see "Generation differences").
79
79
  - 4-5 assets total works best (1-2 character images + 1 scene image + 1 camera-movement video + 1 audio clip). Maxing out the 9-image/3-video/3-audio limits degrades feature priority and adherence.
@@ -177,7 +177,7 @@ precise subject → action details → scene/environment → lighting & color to
177
177
  - Nothing visual connected → text-to-video. A concrete aspect ratio is required (21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16 — no adaptive); Nodaro renders 16:9 unless one is picked.
178
178
 
179
179
  **References (when reference media is attached)**
180
- - Refer to assets by ordinal in attachment order: "@Image 1", "Video 1", "Audio 1". Put the identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding, so a wired reference and its mention stay in sync.)
180
+ - Refer to assets by ordinal in attachment order: "@Image 1", "Video 1", "Audio 1". Put the identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding, so a wired reference and its mention stay in sync. An API caller that passes \`connectedReferences\` can instead write \`{ref:<id>}\` / \`{ref:<id>:label}\` with the reference's own id — the platform substitutes the \`@image_N\` seat after numbering.)
181
181
  - Caps: 9 reference images; 3 reference videos, each 2-15s and ≤15s combined; 3 reference audio clips, ≤15s combined. Reference audio cannot be used alone — it must accompany an image or video reference.
182
182
  - Define each subject once, then reuse the label consistently ("the woman from @Image 1 … the woman opens the door"). A focused set of 4-5 assets beats maxing every cap.
183
183
  - Billing note: generated seconds AND reference-video input seconds bill at the same per-second rate; the first 5 input images are free and each extra image adds a small surcharge; audio input is free.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The reference-binding surface string — `@image_N` / `@video_N` / `@audio_N`
3
+ * — and the one identity sentence built on it. Split out of
4
+ * `video-reference-resolver.ts` so the id-addressed token resolver
5
+ * (`ref-id-tokens.ts`) can bind through the same arrows without a module
6
+ * cycle; the resolver re-exports both, so importers are unaffected.
7
+ */
8
+
9
+ /**
10
+ * The SINGLE swap-point for the reference-binding surface-string (design D1/D7).
11
+ *
12
+ * Every place that renders an `@image_N`-style binding into a video prompt — the
13
+ * per-image subject phrasing, the bare ordinal in a "Use these characters" /
14
+ * pair-back bullet, and the opening/closing frame directive — MUST go through
15
+ * these five arrows. The default form is `@image_N`; if the D7 probe shows a
16
+ * provider prefers the legacy `Image N` form, flipping is editing ONLY these five
17
+ * arrows (`@image_${n}` → `Image ${n}`), nothing downstream.
18
+ *
19
+ * This IS the live swap-point: `resolveVideoReferenceCore` routes the per-image
20
+ * subject phrasing, the "Use these characters" / pair-back bullet ordinals, and
21
+ * the frame directive through these arrows, and `resolveReferenceTokens` resolves
22
+ * the body `{image:N}` tokens through `REF_BINDING[kind]` — so the five arrows
23
+ * are the ONLY emission sites for the binding surface string.
24
+ */
25
+ /**
26
+ * The identity-reference binding sentence shared by the flat-image-list
27
+ * resolvers (gemini-omni, veo i2v): names the ordinal span as identities and
28
+ * says the two things a multimodal model needs to hear — match exactly, and
29
+ * these are not frames. One spelling; both resolvers ride it.
30
+ */
31
+ export function identityRefsSentence(firstOrdinal: number, lastOrdinal: number): string {
32
+ return firstOrdinal === lastOrdinal
33
+ ? `${REF_BINDING.ordinal(firstOrdinal)} is an identity reference for this shot's subjects — match its subject's exact appearance; it is not a frame.`
34
+ : `${REF_BINDING.ordinal(firstOrdinal)} through ${REF_BINDING.ordinal(lastOrdinal)} are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.`
35
+ }
36
+
37
+ export const REF_BINDING = {
38
+ image: (label: string, n: number) => `the ${label} from @image_${n}`,
39
+ video: (label: string, n: number) => `the ${label} from @video_${n}`,
40
+ audio: (label: string, n: number) => `the ${label} from @audio_${n}`,
41
+ /** ordinal as it appears in a "Use these characters" bullet / pair-back */
42
+ ordinal: (n: number) => `@image_${n}`,
43
+ frame: (n: number, role: "opening" | "closing") =>
44
+ `Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
45
+ } as const
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
3
+ *
4
+ * The API/Studio form of the positional `{image:N}` token: the client names a
5
+ * reference by its OWN `connectedReferences[].id` and the platform substitutes
6
+ * the `@image_N` seat after IT has done the numbering. Without it a client that
7
+ * wanted the binding inline had to mirror the numbering walk client-side — a
8
+ * duplicated rule that misbinds pictures the moment the walk changes.
9
+ *
10
+ * `resolveVideoReferenceCore` builds the `RefIdTokenContext` DURING its walk
11
+ * and calls `resolveRefIdTokens` before the `referenceOrder` reorder (so the
12
+ * binding follows the reference to its final seat); the video routes call it
13
+ * standalone on their no-image-reference early return (nothing seated, so
14
+ * every token degrades).
15
+ */
16
+
17
+ import { REF_BINDING } from "./ref-binding.js"
18
+
19
+ /** The label class of `REFERENCE_TOKEN_RE` (`{image:N:label}`), shared. */
20
+ const REF_TOKEN_LABEL_RE = /^[a-zA-Z0-9_ -]+$/
21
+ /** Cheap gate for the whole pass — a prompt without it is untouched. */
22
+ const HAS_REF_ID_TOKEN_RE = /\{ref:/i
23
+ /**
24
+ * One well-formed `{ref:…}` token: everything between `{ref:` and the next
25
+ * `}` that contains no brace. Greedy over a brace-free class, so the scan is
26
+ * LINEAR in the prompt length whatever the content — `prompt` is up to
27
+ * `PROMPT_HARD_CEILING` (30k) chars of caller-controlled text, so a lazy
28
+ * quantifier with a nested optional label group here would be a quadratic-time
29
+ * ReDoS surface. The id / label split happens in code (`splitLabel`), not in
30
+ * the regex. `ref` is case-insensitive; ids are not.
31
+ */
32
+ const REF_ID_TOKEN_RE = /\{[rR][eE][fF]:([^{}]*)\}/g
33
+ /**
34
+ * Last-resort net for a MALFORMED `{ref:` (a brace inside the id, or no
35
+ * closing `}`): drop the `{ref:` run up to the next whitespace or brace, so
36
+ * the prefix can never reach a model, without eating prose past the token.
37
+ */
38
+ const MALFORMED_REF_ID_TOKEN_RE = /\{[rR][eE][fF]:[^\s{}]*\}?/g
39
+
40
+ /** What `resolveRefIdTokens` resolves against — the numbering walk's output. */
41
+ export interface RefIdTokenContext {
42
+ /** Reference id → the 1-based `@image_N` seat the walk gave it. */
43
+ readonly slotById: ReadonlyMap<string, number>
44
+ /** Reference id → display name, the degrade target of a token that cannot bind. */
45
+ readonly nameById: ReadonlyMap<string, string>
46
+ /**
47
+ * How many image references actually ship — the same range gate `{image:N}`
48
+ * uses. A seat past it (a capped-out or duplicate-URL ref) must not bind.
49
+ */
50
+ readonly imageCount: number
51
+ }
52
+
53
+ /**
54
+ * Split a token's content into `<id>` and an optional `<label>` at the LAST
55
+ * colon — only when the tail is a well-formed label. Ids are opaque and may
56
+ * themselves contain `:` (`slug:variant`) or `/` (a URL), so nothing before
57
+ * the last colon is ever interpreted.
58
+ */
59
+ function splitLabel(content: string): { id: string; label?: string } {
60
+ const at = content.lastIndexOf(":")
61
+ if (at === -1) return { id: content }
62
+ const tail = content.slice(at + 1)
63
+ if (!REF_TOKEN_LABEL_RE.test(tail)) return { id: content }
64
+ return { id: content.slice(0, at), label: tail }
65
+ }
66
+
67
+ /**
68
+ * Rewrite id-addressed reference tokens into the `@image_N` binding of the
69
+ * reference the caller sent under that id.
70
+ *
71
+ * Ids are matched by IDENTITY against the known ids (seated or named), never
72
+ * parsed by character class: the whole content is tried as an id first (the
73
+ * longest reading — an id may itself end in something label-shaped), then
74
+ * `<id>:<label>` split at the last colon, then the token is unknown. The label
75
+ * class is the one `REFERENCE_TOKEN_RE` uses. An id containing `{`, `}`, an
76
+ * `@name:N` mention or a `{image:N}` token is unsupported (the mention pass
77
+ * runs first and would rewrite it; a brace ends the token).
78
+ *
79
+ * Per token:
80
+ * - id seated in range → `REF_BINDING.image(label, N)` when labeled, else the
81
+ * bare `REF_BINDING.ordinal(N)` — exactly what `{image:N[:label]}` emits.
82
+ * - otherwise (unknown id, ref skipped by the walk, capped out, or no image
83
+ * references at all) → the label if given, else the ref's display name if
84
+ * the id is known, else "". A token never ships raw — a malformed one is
85
+ * dropped by the last-resort net.
86
+ *
87
+ * No whitespace tidy here: every caller runs `resolveReferenceTokens` after
88
+ * this (the core does at every return), and that collapses the gap a dropped
89
+ * token leaves. Returns the input untouched when it carries no `{ref:` at all.
90
+ */
91
+ export function resolveRefIdTokens(
92
+ prompt: string | undefined,
93
+ ctx: RefIdTokenContext,
94
+ ): string | undefined {
95
+ if (!prompt || !HAS_REF_ID_TOKEN_RE.test(prompt)) return prompt
96
+ const known = (id: string): boolean => id.length > 0 && (ctx.slotById.has(id) || ctx.nameById.has(id))
97
+ const bind = (id: string, label: string | undefined): string => {
98
+ const slot = ctx.slotById.get(id)
99
+ if (slot !== undefined && slot >= 1 && slot <= ctx.imageCount) {
100
+ return label ? REF_BINDING.image(label, slot) : REF_BINDING.ordinal(slot)
101
+ }
102
+ return label ?? ctx.nameById.get(id) ?? ""
103
+ }
104
+ return prompt
105
+ .replace(REF_ID_TOKEN_RE, (_match, content: string) => {
106
+ if (known(content)) return bind(content, undefined)
107
+ const { id, label } = splitLabel(content)
108
+ if (known(id)) return bind(id, label)
109
+ return label ?? ""
110
+ })
111
+ .replace(MALFORMED_REF_ID_TOKEN_RE, "")
112
+ }
@@ -1,4 +1,4 @@
1
- import { resolveNodeRefs } from "@nodaro/shared"
1
+ import { resolveNodeRefs, readPromptAffixes, type PromptAffixes } from "@nodaro/shared"
2
2
  import { SOCIAL_POST_NODE_TYPES } from "@nodaro/shared"
3
3
 
4
4
  export interface ResolvePromptArgs {
@@ -13,16 +13,71 @@ export interface ResolvePromptArgs {
13
13
  * Off/undefined = exact legacy precedence (override > typed > wired) for every
14
14
  * other node type. */
15
15
  appendWired?: boolean
16
+ /** Pre/post text wrapped around WHICHEVER core precedence produced (override,
17
+ * typed, wired, or the appendWired combination). Applied last. */
18
+ affixes?: PromptAffixes
16
19
  }
17
20
  const present = (s?: string): s is string => typeof s === "string" && s.trim().length > 0
18
21
  const rr = (s: string, m: ReadonlyMap<string, string>) => (m.size > 0 ? resolveNodeRefs(s, m) : s)
19
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Prompt affixes (pre/post text) — see docs/prompt-pre-post-text.md (join rule, empty-core, no-op guarantee)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const GLUE_PUNCTUATION = new Set([",", ".", ";", ":", "!", "?", ")"])
28
+
29
+ /** The ONE join rule between two non-blank prompt parts: a single space unless
30
+ * the boundary already has whitespace on either side or the right part opens
31
+ * with sentence punctuation. Shared by `joinPromptParts` and the editor's
32
+ * Final-view segment builder so preview and run agree byte-for-byte. */
33
+ export function promptPartSeparator(left: string, right: string): "" | " " {
34
+ if (left.length === 0 || right.length === 0) return ""
35
+ if (/\s$/.test(left) || /^\s/.test(right)) return ""
36
+ if (GLUE_PUNCTUATION.has(right[0])) return ""
37
+ return " "
38
+ }
39
+
40
+ /** Join prompt parts with `promptPartSeparator`; blank parts are dropped,
41
+ * non-blank parts are used verbatim (no trimming). */
42
+ export function joinPromptParts(parts: ReadonlyArray<string | undefined>): string {
43
+ let out = ""
44
+ for (const part of parts) {
45
+ if (!present(part)) continue
46
+ out = out.length === 0 ? part : out + promptPartSeparator(out, part) + part
47
+ }
48
+ return out
49
+ }
50
+
51
+ /**
52
+ * Wrap `core` with the node's pre/post text. `{Label}` refs are resolved in the
53
+ * AFFIXES only — the core arrives exactly as the caller's precedence produced it.
54
+ * NO-OP GUARANTEE: with no non-blank affix the core is returned unchanged (same
55
+ * reference) — every existing workflow stays byte-identical.
56
+ * EMPTY CORE: a blank core with affixes yields the joined affixes alone.
57
+ */
58
+ export function applyPromptAffixes(core: string, affixes: PromptAffixes | undefined, refMap: ReadonlyMap<string, string>): string
59
+ export function applyPromptAffixes(core: string | undefined, affixes: PromptAffixes | undefined, refMap: ReadonlyMap<string, string>): string | undefined
60
+ export function applyPromptAffixes(
61
+ core: string | undefined,
62
+ affixes: PromptAffixes | undefined,
63
+ refMap: ReadonlyMap<string, string>,
64
+ ): string | undefined {
65
+ const prefix = present(affixes?.prefix) ? rr(affixes!.prefix!, refMap) : undefined
66
+ const suffix = present(affixes?.suffix) ? rr(affixes!.suffix!, refMap) : undefined
67
+ if (prefix === undefined && suffix === undefined) return core
68
+ return joinPromptParts([prefix, core, suffix])
69
+ }
70
+
20
71
  /** SINGLE SOURCE OF TRUTH for prompt precedence across both DAG engines:
21
72
  * override (list fan-out) > first present typed candidate > wired > "".
22
73
  * "present" = non-empty after trim. {Label} refs are resolved on the chosen
23
74
  * branch via the shared resolveNodeRefs. With `appendWired`, the chosen base
24
- * AND the wired value are both emitted (joined ". "). */
25
- export function resolvePrompt({ override, typed = [], wired, refMap, appendWired }: ResolvePromptArgs): string {
75
+ * AND the wired value are both emitted (joined ". "). Affixes wrap the result. */
76
+ export function resolvePrompt({ override, typed = [], wired, refMap, appendWired, affixes }: ResolvePromptArgs): string {
77
+ return applyPromptAffixes(resolveCore({ override, typed, wired, refMap, appendWired }), affixes, refMap)
78
+ }
79
+
80
+ function resolveCore({ override, typed = [], wired, refMap, appendWired }: Omit<ResolvePromptArgs, "affixes">): string {
26
81
  // appendWired: a connected prompt APPENDS to the TYPED base. An `override`
27
82
  // (list fan-out item) still fully REPLACES — it never receives a wired append,
28
83
  // so per-item fan-out prompts are unchanged.
@@ -110,7 +165,7 @@ export function computeNodePrompt(
110
165
  const fields = NODE_PROMPT_CANDIDATE_FIELDS[nodeType] ?? ["prompt"]
111
166
  typed = fields.map((f) => data[f] as string | undefined)
112
167
  }
113
- return resolvePrompt({ override, typed, wired, refMap, appendWired })
168
+ return resolvePrompt({ override, typed, wired, refMap, appendWired, affixes: readPromptAffixes(data) })
114
169
  }
115
170
 
116
171
  export interface LlmChatFieldArgs {
@@ -125,7 +180,7 @@ export function computeLlmChatFields(
125
180
  { override, wiredUserInput, wiredSystemPrompt, refMap }: LlmChatFieldArgs,
126
181
  ): { userInput: string; systemPrompt: string } {
127
182
  return {
128
- userInput: resolvePrompt({ override, typed: [data.userInput as string | undefined], wired: wiredUserInput, refMap }),
183
+ userInput: resolvePrompt({ override, typed: [data.userInput as string | undefined], wired: wiredUserInput, refMap, affixes: readPromptAffixes(data) }),
129
184
  systemPrompt: resolvePrompt({ typed: [data.systemPrompt as string | undefined], wired: wiredSystemPrompt, refMap }),
130
185
  }
131
186
  }