@nodaro/prompts 1.8.0 → 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.
@@ -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,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 { TRANSITIONS, TRANSITION_CATEGORY_LABELS, TRANSITION_CATEGORY_ORDER } from "./transitions.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"
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
- /** multi-dim: one self-describing entry per dimension field, in `fields` order. */
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
- defaultValue: "bursting-through-frame",
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.kind === "single"
821
- ? (c.options?.length ?? 0)
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
- return { ...base, options: options.map((o) => projectOption(o, detail)) }
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
  /**
@@ -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) },
@@ -10,6 +10,10 @@
10
10
  * Seedance 2.0:
11
11
  * - Official BytePlus ModelArk "Dreamina Seedance 2.0 series prompt guide"
12
12
  * https://docs.byteplus.com/en/docs/ModelArk/2222480
13
+ * - Official BytePlus ModelArk "Dreamina Seedance 2.5 prompt guide"
14
+ * https://docs.byteplus.com/en/docs/ModelArk/2607689 — its "Differences
15
+ * from Seedance 2.0" section is the authority for every 2.0-vs-2.5 split
16
+ * below (timestamps, multi-view references, aspect ratios, MOV output).
13
17
  * - Official launch post https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0
14
18
  * - KIE API docs https://docs.kie.ai/market/bytedance/seedance-2
15
19
  * Kling:
@@ -41,19 +45,19 @@ const SEEDANCE_2_DOCTRINE: ProviderPromptDoctrine = {
41
45
  providers: ["seedance-2", "seedance-2-fast", "seedance-2-mini", "seedance-2-5"],
42
46
  heading: "Seedance 2 (seedance-2, seedance-2-fast, seedance-2-mini, seedance-2-5)",
43
47
  tips: [
44
- "Storyboard complex videos as 'Shot 1: … Shot 2: …' WITHOUT timestamps — timed shots like '(0-3s)' are officially unstable and can break generation.",
48
+ "Storyboard as 'Shot 1: … Shot 2: …'. The 2.0 SKUs ignore timestamps and '(0-3s)' shots are officially unstable there; seedance-2-5 honours integer-second timestamps ('0-3s: …', 'at the 5-second mark').",
45
49
  "One camera movement per shot; describe actions per body part with degree ('slowly raises a hand'); express emotion as physical detail, never abstract words.",
46
50
  "Native multi-track audio — cue it inline: (background music), <sound effects>, and quoted dialogue.",
47
- "References go by ordinal (@Image 1, Video 2) in attachment order; earlier = higher priority. Identity = ONE headshot + ONE full-body (multi-view sheets cause ID drift). 4-5 assets total beats maxing the 9/3/3 caps.",
51
+ "References go by ordinal (@Image 1, Video 2) in attachment order; earlier = higher priority. Identity = ONE headshot + ONE full-body (multi-view sheets drift on 2.0; 2.5 accepts them). 4-5 assets beats maxing 9/3/3.",
48
52
  "No negative-prompt parameter — put constraints in the prompt: 'keep it subtitle-free, do not generate a watermark, do not generate a logo'.",
49
- "seedance-2-5 only: one shot runs to 30s (the 2.0 SKUs stop at 15s), so storyboard a whole beat instead of planning a stitch. Ref caps are wider (30/10/10), but 4-5 assets still gives the best identity fidelity.",
53
+ "seedance-2-5 only: one shot runs to 30s (2.0 stops at 15s) — storyboard a whole beat, not a stitch; timestamps work (gap-free, 1s units, don't overpack); refs 30/10/10 but 4-5 assets still gives the best identity.",
50
54
  "Auto-path formula: Subject → Action → Environment → Camera → Style → Constraints in 60-100 words; ONE camera instruction (chain with 'then'); separate camera motion from subject motion; always add one lighting phrase.",
51
55
  ],
52
56
  doctrine: `Prompt structure (front-load what matters most):
53
57
  precise subject → action details → scene/environment → lighting & color tone → camera movement → visual style → image quality → constraints.
54
58
 
55
59
  **Shots & pacing**
56
- - Storyboard complex videos as "Shot 1: … Shot 2: … Shot 3: …" in event order. Do NOT attach timestamps (e.g. "(0-3s)") — precise-timing support is officially unstable and forcing durations can break generation; let the model pace naturally.
60
+ - Storyboard complex videos as "Shot 1: … Shot 2: … Shot 3: …" in event order. TIMESTAMPS ARE VERSION-SPLIT: the 2.0 SKUs (seedance-2 / -fast / -mini) respond to shot numbers only — do NOT attach timestamps there (e.g. "(0-3s)"; precise timing is officially unstable on 2.0 and forcing durations can break generation, so let the model pace). seedance-2-5 honours integer-second timestamps — the forms and limits are under "Generation differences" below.
57
61
  - Per shot cover, in order: camera move or transition, subject action + expression, spatial/position change, audio for that shot.
58
62
  - One camera movement type per shot — never ask for push + pan + orbit at once (image instability).
59
63
  - Prefer slow, gentle, continuous movements over high-burst action (sprints, big jumps, violent rolls morph). Describe actions per body part with quantified degree: "slowly raises a hand", "pushes hard off the ground". Chain actions with inertia: "uses the momentum of the turn to naturally raise an arm".
@@ -64,11 +68,14 @@ precise subject → action details → scene/environment → lighting & color to
64
68
  - 2.5 also takes far more reference material (30 images / 10 videos / 10 audio vs 9/3/3). Treat that as room for COVERAGE — more distinct characters, locations and props in one shot — not as licence to pile refs onto one identity. The "ONE headshot + ONE full-body, 4-5 assets total" rule above still produces the best likeness on 2.5.
65
69
  - 2.5 renders at 480p/720p/1080p (1080p since 2026-08-17): there is no 4K tier, so route a job that needs 4K to seedance-2 (which has it) or upscale afterwards.
66
70
  - With a start frame, 2.5 always derives the output aspect from that frame — an explicit aspect ratio is rejected outright, so compose the frame at the ratio you want.
71
+ - Timestamps (official 2.5 guide, "Differences from Seedance 2.0"): 2.0 does not respond to them; 2.5 supports integer-second timestamps in three forms — gap-free intervals ("0-3s: … 3-7s: … 7-15s: …" or "[1s-4s] … [4s-8s] …"; never leave a hole like "0-3s … 5-6s"), time-point control ("At the 5-second mark, …"), and relative time ("After 3 seconds, …"). Use 1-second units. Too little content in a range lets the model improvise; too much packs in extra cuts or drops beats — budget the seconds. Never use timestamps to drive high-frequency actions ("shake three times per second").
72
+ - Multi-view subject images: not recommended on 2.0 (the views read as separate people → twins); supported on 2.5. ONE headshot + ONE full-body remains the safest default on both.
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.
67
74
 
68
75
  **References (when reference media is attached)**
69
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.)
70
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".
71
- - Character identity: ONE close-up headshot + ONE full-body image is ideal. Do NOT attach multi-view/three-view character sheets — the model reads the views as separate people, causing identity drift and twin duplicates.
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").
72
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.
73
80
  - Editing/extension instructions name clips directly: "Extend Video 1 backward…", "Remove the chair from Video 1". Saying "reference Video 1" flips the model into reference mode and breaks the edit. Track completion: "Video 1 + [transition description] + followed by Video 2" (≤3 clips, ≤15s total).
74
81
 
@@ -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
  }
@@ -42,9 +42,23 @@ export interface Transition {
42
42
  readonly term?: string
43
43
  }
44
44
 
45
- export type TransitionPosition = "auto" | "start" | "middle" | "end" | "full"
46
- export type TransitionDuration = "auto" | "instant" | "short" | "medium" | "long"
47
- export type TransitionIntensity = "auto" | "subtle" | "natural" | "dynamic" | "crazy"
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
- const POSITION_CLAUSES: Record<Exclude<TransitionPosition, "auto">, string> = {
307
- start: "the transition occurs at the opening of the clip",
308
- middle: "the transition occurs in the middle of the clip",
309
- end: "the transition occurs at the end of the clip",
310
- full: "the transition spans the entire clip",
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 DURATION_CLAUSES: Record<Exclude<TransitionDuration, "auto">, string> = {
314
- instant: "occurring instantaneously",
315
- short: "lasting approximately 1 second",
316
- medium: "lasting approximately 2 seconds",
317
- long: "lasting approximately 3 seconds",
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
- const INTENSITY_CLAUSES: Record<Exclude<TransitionIntensity, "auto">, string> = {
321
- subtle: "with subtle restrained energy and minimal flourish",
322
- natural: "with natural unhurried timing",
323
- dynamic: "with dynamic energy and assertive flourish",
324
- crazy: "with extreme exaggerated energy, wild flourishes, and dramatic distortion",
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