@nodaro/prompts 1.8.1 → 1.9.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 +263 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +298 -34
- package/dist/index.d.ts +298 -34
- package/dist/index.js +249 -83
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/factory-presets.test.ts +62 -12
- package/src/__tests__/node-prompt-fields.test.ts +81 -0
- package/src/__tests__/parameter-registry-sync.test.ts +4 -1
- package/src/__tests__/prompt-affixes.test.ts +83 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +151 -0
- package/src/composition-effects.ts +6 -1
- package/src/factory-presets/generate-image.ts +37 -24
- package/src/factory-presets/shared-image.ts +10 -10
- package/src/factory-presets/switchx.ts +6 -6
- package/src/factory-presets/video-edit.ts +6 -6
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +206 -0
- package/src/picker-catalogs.ts +48 -16
- package/src/picker-wiring.ts +2 -1
- package/src/resolve-prompt.ts +60 -5
- package/src/transitions.ts +79 -19
|
@@ -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
|
+
)
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -51,7 +51,14 @@ 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 {
|
|
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"
|
|
55
62
|
import { CHARACTER_FX, CHARACTER_FX_CATEGORY_LABELS, CHARACTER_FX_CATEGORY_ORDER } from "./character-fx.js"
|
|
56
63
|
import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
|
|
57
64
|
import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
|
|
@@ -127,7 +134,10 @@ export interface PickerCatalog {
|
|
|
127
134
|
readonly options?: readonly PickerOption[]
|
|
128
135
|
/** multi-dim: the dimension keys (no single catalog to flatten). */
|
|
129
136
|
readonly fields?: readonly string[]
|
|
130
|
-
/**
|
|
137
|
+
/** Per-field option lists. Multi-dim catalogs always carry these, one per
|
|
138
|
+
* `fields` entry in order; a single-dim catalog carries them when it has
|
|
139
|
+
* secondary parameter fields beside its main picker (transition
|
|
140
|
+
* position/duration/intensity). */
|
|
131
141
|
readonly dimensions?: readonly PickerDimension[]
|
|
132
142
|
}
|
|
133
143
|
|
|
@@ -447,7 +457,10 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
|
|
|
447
457
|
catalogId: "composition-effects",
|
|
448
458
|
kind: "single",
|
|
449
459
|
valueField: "compositionEffect",
|
|
450
|
-
|
|
460
|
+
// No default effect — composition effects are dramatic subject transforms,
|
|
461
|
+
// so an unset node injects nothing until the user picks one (matches the
|
|
462
|
+
// "unset folds nothing" behaviour every consumer already honours).
|
|
463
|
+
defaultValue: "none",
|
|
451
464
|
options: toOptions(COMPOSITION_EFFECTS),
|
|
452
465
|
},
|
|
453
466
|
{
|
|
@@ -522,6 +535,17 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
|
|
|
522
535
|
categoryOrder: TRANSITION_CATEGORY_ORDER,
|
|
523
536
|
categoryLabels: TRANSITION_CATEGORY_LABELS,
|
|
524
537
|
options: toOptions(TRANSITIONS, "category"),
|
|
538
|
+
// The node's three timing parameters, alongside the transition itself.
|
|
539
|
+
// `dimensions` on a single-dim catalog carries exactly what it says on a
|
|
540
|
+
// multi-dim one — extra value fields with their own option lists — so a
|
|
541
|
+
// consumer that only sends ids can offer Position/Duration/Intensity
|
|
542
|
+
// without composing the clauses itself. The rich picker UI still renders
|
|
543
|
+
// only `options`; these fields are secondary controls beside it.
|
|
544
|
+
dimensions: perFieldDims([
|
|
545
|
+
["position", TRANSITION_POSITIONS],
|
|
546
|
+
["duration", TRANSITION_DURATIONS],
|
|
547
|
+
["intensity", TRANSITION_INTENSITIES],
|
|
548
|
+
]),
|
|
525
549
|
},
|
|
526
550
|
{
|
|
527
551
|
nodeType: "character-fx",
|
|
@@ -816,10 +840,11 @@ export function summarizePickerCatalogs(): readonly PickerCatalogSummary[] {
|
|
|
816
840
|
kind: c.kind,
|
|
817
841
|
valueField: c.valueField,
|
|
818
842
|
fields: c.fields,
|
|
843
|
+
// Every option the detail call will return, whichever kind — a single-dim
|
|
844
|
+
// catalog with secondary dimensions carries both.
|
|
819
845
|
optionCount:
|
|
820
|
-
c.
|
|
821
|
-
|
|
822
|
-
: (c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
|
|
846
|
+
(c.options?.length ?? 0) +
|
|
847
|
+
(c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
|
|
823
848
|
}))
|
|
824
849
|
}
|
|
825
850
|
|
|
@@ -876,22 +901,29 @@ export function projectPickerCatalog(
|
|
|
876
901
|
categoryLabels: c.categoryLabels,
|
|
877
902
|
detail,
|
|
878
903
|
}
|
|
904
|
+
const projectDims = (dims: readonly PickerDimension[]) =>
|
|
905
|
+
dims.map((d) => ({
|
|
906
|
+
field: d.field,
|
|
907
|
+
label: d.label,
|
|
908
|
+
options: d.options.map((o) => projectOption(o, detail)),
|
|
909
|
+
}))
|
|
910
|
+
|
|
879
911
|
if (c.kind === "single") {
|
|
880
912
|
let options = c.options ?? []
|
|
881
913
|
if (opts.category) options = options.filter((o) => o.category === opts.category)
|
|
882
|
-
|
|
914
|
+
const projected = { ...base, options: options.map((o) => projectOption(o, detail)) }
|
|
915
|
+
// A single-dim catalog may still carry secondary parameter dimensions
|
|
916
|
+
// (transition's position/duration/intensity). They are additive — a
|
|
917
|
+
// consumer reading only `options` is unaffected — but dropping them here
|
|
918
|
+
// would hide the one thing an id-only client needs to offer those controls.
|
|
919
|
+
if (!c.dimensions) return projected
|
|
920
|
+
let dims = c.dimensions
|
|
921
|
+
if (opts.field) dims = dims.filter((d) => d.field === opts.field)
|
|
922
|
+
return { ...projected, dimensions: projectDims(dims) }
|
|
883
923
|
}
|
|
884
924
|
let dims = c.dimensions ?? []
|
|
885
925
|
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
|
-
}
|
|
926
|
+
return { ...base, fields: c.fields, dimensions: projectDims(dims) }
|
|
895
927
|
}
|
|
896
928
|
|
|
897
929
|
/**
|
package/src/picker-wiring.ts
CHANGED
|
@@ -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
|
-
|
|
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) },
|
package/src/resolve-prompt.ts
CHANGED
|
@@ -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
|
}
|
package/src/transitions.ts
CHANGED
|
@@ -42,9 +42,23 @@ export interface Transition {
|
|
|
42
42
|
readonly term?: string
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The three timing scales, each derived from the catalog that defines it (see
|
|
47
|
+
* `TRANSITION_POSITIONS` and friends below).
|
|
48
|
+
*
|
|
49
|
+
* The direction matters. These used to be hand-written unions with the clause
|
|
50
|
+
* tables written out separately beside them, so the two could disagree: add a
|
|
51
|
+
* step to the union, forget the clause, and the composer indexed a missing key
|
|
52
|
+
* — pushing `undefined` into the parts list, which `join(", ")` renders as a
|
|
53
|
+
* dangling separator on a prompt that then ships to a provider with the user's
|
|
54
|
+
* chosen parameter silently dropped. Deriving the union FROM the catalog makes
|
|
55
|
+
* that unrepresentable: one array is the source of the type, the option list
|
|
56
|
+
* the API serves, and the clause table, so a new step reaches all three or
|
|
57
|
+
* none. The exact id sets are pinned by `transition-timing-catalogs.test.ts`.
|
|
58
|
+
*/
|
|
59
|
+
export type TransitionPosition = (typeof TRANSITION_POSITIONS)[number]["id"]
|
|
60
|
+
export type TransitionDuration = (typeof TRANSITION_DURATIONS)[number]["id"]
|
|
61
|
+
export type TransitionIntensity = (typeof TRANSITION_INTENSITIES)[number]["id"]
|
|
48
62
|
|
|
49
63
|
export interface TransitionTiming {
|
|
50
64
|
position?: TransitionPosition
|
|
@@ -303,27 +317,73 @@ export const TRANSITION_IDS: ReadonlyArray<string> = TRANSITIONS.map((t) => t.id
|
|
|
303
317
|
// Graph-aware composer — start/end input handles + timing fields + multi-pick
|
|
304
318
|
// ---------------------------------------------------------------------------
|
|
305
319
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
320
|
+
/**
|
|
321
|
+
* The transition node's three timing parameters, as catalogs.
|
|
322
|
+
*
|
|
323
|
+
* These are graded scales, not free numbers — the same shape as
|
|
324
|
+
* `exposure-settings`' aperture or `temporal`'s speed — so a consumer that can
|
|
325
|
+
* only send ids (Studio, the SDK, MCP) can offer them without composing prompt
|
|
326
|
+
* text of its own. `auto` is the no-op head of each scale: an empty
|
|
327
|
+
* `promptHint`, so an unset parameter contributes nothing and the model is left
|
|
328
|
+
* to decide, exactly as before these were enumerable.
|
|
329
|
+
*
|
|
330
|
+
* `POSITION_CLAUSES` / `DURATION_CLAUSES` / `INTENSITY_CLAUSES` below are
|
|
331
|
+
* DERIVED from these arrays, so the clause the composer injects and the hint
|
|
332
|
+
* the catalog advertises are the same string by construction and cannot drift.
|
|
333
|
+
*/
|
|
334
|
+
export interface TransitionTimingOption {
|
|
335
|
+
readonly id: string
|
|
336
|
+
readonly label: string
|
|
337
|
+
readonly description: string
|
|
338
|
+
readonly promptHint: string
|
|
339
|
+
readonly term?: string
|
|
311
340
|
}
|
|
312
341
|
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
342
|
+
export const TRANSITION_POSITIONS = [
|
|
343
|
+
{ id: "auto", label: "Auto", description: "Let the model place it", promptHint: "", term: "" },
|
|
344
|
+
{ id: "start", label: "Start", description: "At the opening of the clip", promptHint: "the transition occurs at the opening of the clip", term: "at the opening of the clip" },
|
|
345
|
+
{ id: "middle", label: "Middle", description: "In the middle of the clip", promptHint: "the transition occurs in the middle of the clip", term: "mid-clip" },
|
|
346
|
+
{ id: "end", label: "End", description: "At the end of the clip", promptHint: "the transition occurs at the end of the clip", term: "at the end of the clip" },
|
|
347
|
+
{ id: "full", label: "Full", description: "Spans the entire clip", promptHint: "the transition spans the entire clip", term: "across the whole clip" },
|
|
348
|
+
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
349
|
+
|
|
350
|
+
export const TRANSITION_DURATIONS = [
|
|
351
|
+
{ id: "auto", label: "Auto", description: "Let the model time it", promptHint: "", term: "" },
|
|
352
|
+
{ id: "instant", label: "Instant", description: "No perceptible duration", promptHint: "occurring instantaneously", term: "instantaneous" },
|
|
353
|
+
{ id: "short", label: "Short (~1s)", description: "Approximately 1 second", promptHint: "lasting approximately 1 second", term: "about 1 second" },
|
|
354
|
+
{ id: "medium", label: "Medium (~2s)", description: "Approximately 2 seconds", promptHint: "lasting approximately 2 seconds", term: "about 2 seconds" },
|
|
355
|
+
{ id: "long", label: "Long (~3s)", description: "Approximately 3 seconds", promptHint: "lasting approximately 3 seconds", term: "about 3 seconds" },
|
|
356
|
+
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
357
|
+
|
|
358
|
+
export const TRANSITION_INTENSITIES = [
|
|
359
|
+
{ id: "auto", label: "Auto", description: "Let the model judge it", promptHint: "", term: "" },
|
|
360
|
+
{ id: "subtle", label: "Subtle", description: "Restrained, minimal flourish", promptHint: "with subtle restrained energy and minimal flourish", term: "subtly" },
|
|
361
|
+
{ id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural unhurried timing", term: "at a natural pace" },
|
|
362
|
+
{ id: "dynamic", label: "Dynamic", description: "Assertive, energetic", promptHint: "with dynamic energy and assertive flourish", term: "energetically" },
|
|
363
|
+
{ id: "crazy", label: "Crazy", description: "Extreme, wild, distorted", promptHint: "with extreme exaggerated energy, wild flourishes, and dramatic distortion", term: "wildly exaggerated" },
|
|
364
|
+
] as const satisfies ReadonlyArray<TransitionTimingOption>
|
|
319
365
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Index a timing catalog into the `Record<value, clause>` the composer reads.
|
|
368
|
+
*
|
|
369
|
+
* The key type is derived from the SAME array, so the record is total over the
|
|
370
|
+
* catalog by construction. That matters: the composer indexes these records
|
|
371
|
+
* without a fallback, and a missing key would push `undefined` into the parts
|
|
372
|
+
* list, which `join(", ")` renders as a dangling separator — a malformed prompt
|
|
373
|
+
* shipped to a provider with the user's chosen parameter silently dropped.
|
|
374
|
+
*/
|
|
375
|
+
function clausesOf<T extends TransitionTimingOption>(
|
|
376
|
+
options: ReadonlyArray<T>,
|
|
377
|
+
): Record<Exclude<T["id"], "auto">, string> {
|
|
378
|
+
return Object.fromEntries(
|
|
379
|
+
options.filter((o) => o.id !== "auto").map((o) => [o.id, o.promptHint]),
|
|
380
|
+
) as Record<Exclude<T["id"], "auto">, string>
|
|
325
381
|
}
|
|
326
382
|
|
|
383
|
+
const POSITION_CLAUSES = clausesOf(TRANSITION_POSITIONS)
|
|
384
|
+
const DURATION_CLAUSES = clausesOf(TRANSITION_DURATIONS)
|
|
385
|
+
const INTENSITY_CLAUSES = clausesOf(TRANSITION_INTENSITIES)
|
|
386
|
+
|
|
327
387
|
/**
|
|
328
388
|
* Compose a structural prompt-hint sentence from a transition id (or array
|
|
329
389
|
* of 1-2 ids for multi-pick) plus optional start-state/end-state hints
|