@nodaro/prompts 1.9.0 → 1.11.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.
@@ -37,9 +37,23 @@ export interface CharacterFx {
37
37
  readonly term?: string
38
38
  }
39
39
 
40
- export type CharacterFxPosition = "auto" | "start" | "middle" | "end" | "full"
41
- export type CharacterFxDuration = "auto" | "instant" | "short" | "medium" | "long"
42
- export type CharacterFxIntensity = "auto" | "subtle" | "natural" | "dynamic" | "crazy"
40
+ /**
41
+ * The three timing scales, each derived from the catalog that defines it (see
42
+ * `CHARACTER_FX_POSITIONS` and friends below).
43
+ *
44
+ * The direction matters. These used to be hand-written unions with the clause
45
+ * tables written out separately beside them, so the two could disagree: add a
46
+ * step to the union, forget the clause, and the composer indexed a missing key
47
+ * — pushing `undefined` into the parts list, which `join(", ")` renders as a
48
+ * dangling separator on a prompt that then ships to a provider with the user's
49
+ * chosen parameter silently dropped. Deriving the union FROM the catalog makes
50
+ * that unrepresentable: one array is the source of the type, the option list
51
+ * the API serves, and the clause table, so a new step reaches all three or
52
+ * none. The exact id sets are pinned by `character-fx-timing-catalogs.test.ts`.
53
+ */
54
+ export type CharacterFxPosition = (typeof CHARACTER_FX_POSITIONS)[number]["id"]
55
+ export type CharacterFxDuration = (typeof CHARACTER_FX_DURATIONS)[number]["id"]
56
+ export type CharacterFxIntensity = (typeof CHARACTER_FX_INTENSITIES)[number]["id"]
43
57
 
44
58
  export interface CharacterFxTiming {
45
59
  position?: CharacterFxPosition
@@ -266,27 +280,96 @@ export const CHARACTER_FX_IDS: ReadonlyArray<string> = CHARACTER_FX.map((c) => c
266
280
  // Graph-aware composer — target input handle + timing fields + multi-pick
267
281
  // ---------------------------------------------------------------------------
268
282
 
269
- const POSITION_CLAUSES: Record<Exclude<CharacterFxPosition, "auto">, string> = {
270
- start: "the effect occurs at the opening of the clip",
271
- middle: "the effect occurs in the middle of the clip",
272
- end: "the effect occurs at the end of the clip",
273
- full: "the effect persists for the entire clip",
283
+ /**
284
+ * The character-fx node's three timing parameters, as catalogs.
285
+ *
286
+ * Graded scales in the standard option shape, so a consumer that can only send
287
+ * ids (Studio, the SDK, MCP) can offer Position / Duration / Intensity without
288
+ * composing prompt text of its own. `auto` is the no-op head of each scale: an
289
+ * empty `promptHint`, so an unset parameter contributes nothing and the model
290
+ * is left to decide, exactly as before these were enumerable.
291
+ *
292
+ * These are NOT the transition node's scales, even though the ids match. The
293
+ * wording is deliberately different — a transition OCCURS and SPANS the clip,
294
+ * an effect MANIFESTS and PERSISTS — and the three intensity clauses coincide
295
+ * by accident, not by shared definition. Keep the two catalogs separate; do
296
+ * not fold one into the other.
297
+ *
298
+ * `POSITION_CLAUSES` / `DURATION_CLAUSES` / `INTENSITY_CLAUSES` below are
299
+ * DERIVED from these arrays, so the clause the composer injects and the hint
300
+ * the catalog advertises are the same string by construction and cannot drift.
301
+ */
302
+ export interface CharacterFxTimingOption {
303
+ readonly id: string
304
+ readonly label: string
305
+ readonly description: string
306
+ readonly promptHint: string
307
+ readonly term?: string
274
308
  }
275
309
 
276
- const DURATION_CLAUSES: Record<Exclude<CharacterFxDuration, "auto">, string> = {
277
- instant: "manifesting instantaneously",
278
- short: "manifesting over approximately 1 second",
279
- medium: "manifesting over approximately 2 seconds",
280
- long: "manifesting over approximately 3 seconds",
281
- }
310
+ export const CHARACTER_FX_POSITIONS = [
311
+ { id: "auto", label: "Auto", description: "Let the model place the effect", promptHint: "", term: "" },
312
+ { id: "start", label: "Start", description: "Occurs at the opening of the clip", promptHint: "the effect occurs at the opening of the clip", term: "at the opening of the clip" },
313
+ { id: "middle", label: "Middle", description: "Occurs in the middle of the clip", promptHint: "the effect occurs in the middle of the clip", term: "mid-clip" },
314
+ { id: "end", label: "End", description: "Occurs at the end of the clip", promptHint: "the effect occurs at the end of the clip", term: "at the end of the clip" },
315
+ { id: "full", label: "Full", description: "Persists for the entire clip", promptHint: "the effect persists for the entire clip", term: "persisting for the whole clip" },
316
+ ] as const satisfies ReadonlyArray<CharacterFxTimingOption>
317
+
318
+ export const CHARACTER_FX_DURATIONS = [
319
+ { id: "auto", label: "Auto", description: "Let the model time the effect", promptHint: "", term: "" },
320
+ { id: "instant", label: "Instant", description: "Manifests instantaneously", promptHint: "manifesting instantaneously", term: "manifesting instantly" },
321
+ { id: "short", label: "Short (~1s)", description: "Manifests over approximately 1 second", promptHint: "manifesting over approximately 1 second", term: "manifesting over about 1 second" },
322
+ { id: "medium", label: "Medium (~2s)", description: "Manifests over approximately 2 seconds", promptHint: "manifesting over approximately 2 seconds", term: "manifesting over about 2 seconds" },
323
+ { id: "long", label: "Long (~3s)", description: "Manifests over approximately 3 seconds", promptHint: "manifesting over approximately 3 seconds", term: "manifesting over about 3 seconds" },
324
+ ] as const satisfies ReadonlyArray<CharacterFxTimingOption>
282
325
 
283
- const INTENSITY_CLAUSES: Record<Exclude<CharacterFxIntensity, "auto">, string> = {
284
- subtle: "with subtle restrained energy and minimal flourish",
285
- natural: "with natural unhurried timing",
286
- dynamic: "with dynamic energy and assertive flourish",
287
- crazy: "with extreme exaggerated energy, wild flourishes, and dramatic distortion",
326
+ export const CHARACTER_FX_INTENSITIES = [
327
+ { id: "auto", label: "Auto", description: "Let the model judge the effect's energy", promptHint: "", term: "" },
328
+ { id: "subtle", label: "Subtle", description: "Restrained, minimal flourish", promptHint: "with subtle restrained energy and minimal flourish", term: "subtly" },
329
+ { id: "natural", label: "Natural", description: "Unhurried, unforced timing", promptHint: "with natural unhurried timing", term: "at a natural pace" },
330
+ { id: "dynamic", label: "Dynamic", description: "Assertive, energetic", promptHint: "with dynamic energy and assertive flourish", term: "energetically" },
331
+ { id: "crazy", label: "Crazy", description: "Extreme, wild, distorted", promptHint: "with extreme exaggerated energy, wild flourishes, and dramatic distortion", term: "wildly exaggerated" },
332
+ ] as const satisfies ReadonlyArray<CharacterFxTimingOption>
333
+
334
+ /**
335
+ * Index a timing catalog into the `Record<value, clause>` the composer reads.
336
+ *
337
+ * The key type is derived from the SAME array, so the record is total over the
338
+ * catalog by construction. That matters: the composer indexes these records
339
+ * without a fallback, and a missing key would push `undefined` into the parts
340
+ * list, which `join(", ")` renders as a dangling separator — a malformed prompt
341
+ * shipped to a provider with the user's chosen parameter silently dropped.
342
+ *
343
+ * Deliberately a private twin of the helper in `transitions.ts` rather than a
344
+ * shared import: the two nodes' timing catalogs must stay independent.
345
+ */
346
+ function clausesOf<T extends CharacterFxTimingOption>(
347
+ options: ReadonlyArray<T>,
348
+ ): Record<Exclude<T["id"], "auto">, string> {
349
+ return Object.fromEntries(
350
+ options.filter((o) => o.id !== "auto").map((o) => [o.id, o.promptHint]),
351
+ ) as Record<Exclude<T["id"], "auto">, string>
288
352
  }
289
353
 
354
+ const POSITION_CLAUSES = clausesOf(CHARACTER_FX_POSITIONS)
355
+ const DURATION_CLAUSES = clausesOf(CHARACTER_FX_DURATIONS)
356
+ const INTENSITY_CLAUSES = clausesOf(CHARACTER_FX_INTENSITIES)
357
+
358
+ /**
359
+ * Widening guard. `as const` on the three arrays above is load-bearing: drop
360
+ * it and every id widens to `string`, the derived unions stop constraining
361
+ * anything, and the clause records degrade to `Record<string, string>` — the
362
+ * totality guarantee is gone with no runtime symptom. This assignment stops
363
+ * compiling the moment that happens (tsup's DTS build runs the type checker).
364
+ */
365
+ type NarrowIds<T> = string extends T ? never : true
366
+ const _timingIdsStayNarrow: [
367
+ NarrowIds<CharacterFxPosition>,
368
+ NarrowIds<CharacterFxDuration>,
369
+ NarrowIds<CharacterFxIntensity>,
370
+ ] = [true, true, true]
371
+ void _timingIdsStayNarrow
372
+
290
373
  /**
291
374
  * Compose a character-fx prompt-hint sentence from an effect id (or array
292
375
  * of 1-2 ids for multi-pick) plus target-ref display names (from upstream
@@ -0,0 +1,354 @@
1
+ /**
2
+ * THE DIRECTION REGISTRY — the single, ordered table of every cinematic
3
+ * dimension the flat `direction` channel carries, plus the one renderer that
4
+ * folds it into prompt text.
5
+ *
6
+ * WHY IT EXISTS: before this module, five of the ~35 cinematic dimensions rode
7
+ * the `direction` wire channel and every other dimension was folded into prompt
8
+ * TEXT by the client. A production copied between clients therefore froze stale
9
+ * catalog wording, and each client re-implemented the fold. The table below is
10
+ * the platform-owned replacement: the wire carries ids, the platform renders
11
+ * the clauses, and the fold ORDER is exported so a client preview cannot drift
12
+ * from what the server actually emits.
13
+ *
14
+ * WIRE VOCABULARY: the keys are the platform's OWN node-data field names — the
15
+ * ones `SINGLE_PICKER_WIRING[].valueField` and the four `*_FIELD_BY_CATEGORY`
16
+ * maps already use (`shotSize`, `lightingStyle`, `isoValue`, `style`, `mood`,
17
+ * `cameraMotion`, …). A Studio-emitted graph and a hand-built canvas node
18
+ * therefore speak ONE vocabulary for one catalog, and every `build*Hints`
19
+ * already consumes these names.
20
+ *
21
+ * IMPORT RULE (hard): this module imports only `get*PromptHint` / `get*Term` /
22
+ * `build*Hints` FUNCTIONS — never a raw UPPERCASE catalog array.
23
+ * `catalog-funnel-ratchet.test.ts` derives its watch set from
24
+ * `picker-catalogs.ts`'s uppercase value imports and can only SHRINK, so a raw
25
+ * array import here would be a new offender. Nothing here reads an environment
26
+ * variable either (`content-free-contract.test.ts`) — verbosity and surface are
27
+ * threaded parameters, never deployment state.
28
+ *
29
+ * DELIBERATELY EXCLUDED, so neither is filed as a gap:
30
+ * - `characterFx` — a per-shot composer with catalog timing levers
31
+ * (`composeCharacterFxHintFromConnections`) positioned at an in-prose effect
32
+ * token, not a bare id. A single-id channel cannot carry it.
33
+ * - Subject / Styling / prop dimensions (`animal`, `heldProp`, `material`,
34
+ * Person, Styling) — a separate `subject` channel, deliberately out of scope
35
+ * here.
36
+ *
37
+ * PACK BLINDNESS (parity, not a regression): `get*PromptHint` reads the frozen
38
+ * base arrays, so ids added by a deployment-registered catalog pack resolve to
39
+ * `""` and contribute no clause. This is identical to the behavior of the five
40
+ * keys that shipped before this table; routing through `getPickerCatalog` is a
41
+ * deliberate non-goal.
42
+ */
43
+ import type { PickerHintMode } from "./term.js"
44
+ import { getFramingPromptHint, getFramingTerm } from "./framing.js"
45
+ import { getLightingPromptHint, getLightingTerm } from "./lighting.js"
46
+ import { getLensPromptHint, getLensTerm } from "./lens.js"
47
+ import { getCameraFormatPromptHint, getCameraFormatTerm } from "./camera-format.js"
48
+ import { getCameraMotionPromptHint, getCameraMotionTerm } from "./camera-motions.js"
49
+ import { getPosePromptHint, getPoseTerm } from "./pose.js"
50
+ import {
51
+ getCompositionEffectPromptHint,
52
+ getCompositionEffectTerm,
53
+ } from "./composition-effects.js"
54
+ import { getExposurePromptHint, getExposureTerm } from "./exposure-settings.js"
55
+ import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
56
+ import { buildAtmosphereHints } from "./atmosphere.js"
57
+ import { buildPostProcessHints } from "./post-process-effects.js"
58
+ import { getStylePromptHint, getStyleTerm } from "./style.js"
59
+ import { buildMoodHints } from "./mood.js"
60
+ import { buildAestheticHints } from "./aesthetic.js"
61
+ import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
62
+ import { buildPhotographerHints } from "./photographer.js"
63
+ import { getRenderQualityPromptHint, getRenderQualityTerm } from "./render-quality.js"
64
+ import { getSettingPromptHint, getSettingTerm } from "./setting.js"
65
+ import { getEraPromptHint, getEraTerm } from "./era.js"
66
+ import { getBackdropPromptHint, getBackdropTerm } from "./backdrop.js"
67
+ import { buildActionFxHints } from "./action-fx.js"
68
+ import { getTemporalPromptHint, getTemporalTerm } from "./temporal.js"
69
+ import { getTransitionPromptHint, getTransitionTerm } from "./transitions.js"
70
+ import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
71
+
72
+ /** Which generation stages fold a dimension. */
73
+ export type DirectionSurface = "image" | "video" | "both"
74
+ /** Verbosity family — the video policy folds `motion` compact, `look` full. */
75
+ export type DirectionFamily = "look" | "motion"
76
+
77
+ export interface DirectionFieldSpec {
78
+ /**
79
+ * Wire key — also the `DirectionFields` property name and the canvas
80
+ * node-data field name for the same catalog.
81
+ */
82
+ readonly key: string
83
+ /**
84
+ * Which generation stages fold this dimension. Filtering happens in the
85
+ * RENDERER, never in the wire schema: an image-only key sent to
86
+ * `/v1/generate-video` is accepted and simply contributes no hint.
87
+ */
88
+ readonly surface: DirectionSurface
89
+ /** Verbosity family. The video policy folds `motion` compact, `look` full. */
90
+ readonly family: DirectionFamily
91
+ /** Ids honored per dimension. Extras are SLICED at render, never a 400. */
92
+ readonly maxPicks: number
93
+ /**
94
+ * Render selected ids in this catalog's own doctrine: per-id for independent
95
+ * catalogs, ONE blended clause for Mood / Aesthetic / Photographer, the
96
+ * catalog's own multi builder where it has one. `[]` when nothing resolves.
97
+ */
98
+ readonly render: (ids: readonly string[], mode: PickerHintMode) => string[]
99
+ }
100
+
101
+ // ── Render adapters (module-private) ────────────────────────────────────────
102
+
103
+ /**
104
+ * Independent per-id emission — the default doctrine.
105
+ *
106
+ * Only ever passes NON-EMPTY strings (the normalizer drops empties), which is
107
+ * what makes `getLoopSubjectPromptHint(id: string)`'s required-string signature
108
+ * safe here alongside the `string | undefined | null` getters.
109
+ */
110
+ const perId =
111
+ (full: (id: string) => string, compact: (id: string) => string) =>
112
+ (ids: readonly string[], mode: PickerHintMode): string[] => {
113
+ const out: string[] = []
114
+ for (const id of ids) {
115
+ const frag = mode === "compact" ? compact(id) : full(id)
116
+ if (frag.length > 0) out.push(frag)
117
+ }
118
+ return out
119
+ }
120
+
121
+ /** Catalogs whose own builder returns a LIST (atmosphere, post-process, action FX). */
122
+ const viaListBuilder =
123
+ (build: (v: unknown, mode: PickerHintMode) => string[]) =>
124
+ (ids: readonly string[], mode: PickerHintMode): string[] =>
125
+ build(ids.length === 1 ? ids[0] : [...ids], mode).filter((s) => s.length > 0)
126
+
127
+ /** Catalogs whose own builder returns ONE blended clause (aesthetic, photographer). */
128
+ const viaStringBuilder =
129
+ (build: (v: unknown, mode: PickerHintMode) => string) =>
130
+ (ids: readonly string[], mode: PickerHintMode): string[] => {
131
+ const s = build(ids.length === 1 ? ids[0] : [...ids], mode)
132
+ return s.length > 0 ? [s] : []
133
+ }
134
+
135
+ /** Mood's builder takes a RECORD and returns a list — and blends multi picks. */
136
+ const viaMood = (ids: readonly string[], mode: PickerHintMode): string[] =>
137
+ buildMoodHints({ mood: ids.length === 1 ? ids[0] : [...ids] }, mode).filter(
138
+ (s) => s.length > 0,
139
+ )
140
+
141
+ const framing = perId(getFramingPromptHint, getFramingTerm)
142
+ const lighting = perId(getLightingPromptHint, getLightingTerm)
143
+ const exposure = perId(getExposurePromptHint, getExposureTerm)
144
+ const temporal = perId(getTemporalPromptHint, getTemporalTerm)
145
+
146
+ /**
147
+ * THE TABLE. Declaration order IS fold order (`DIRECTION_KEYS`), pinned by
148
+ * `__tests__/direction-registry.test.ts`.
149
+ *
150
+ * Group order: camera motion leads (matching the video hint order the canvas
151
+ * and Studio both emit today), then composition → camera → exposure → light →
152
+ * style → scene → motion, then the LEGACY BLOCK last.
153
+ *
154
+ * THE LEGACY BLOCK (the last five rows) is the pre-registry published
155
+ * `DirectionFields`, placed LAST in today's exact `composePromptText` order so
156
+ * every existing caller's fold is byte-identical. They are WHOLE-CATALOG keys —
157
+ * `framingId` accepts ANY `FRAMINGS` id and `lightingId` ANY `LIGHTINGS` id —
158
+ * so they are NOT aliases of `shotSize` / `lightingStyle`, and an alias table
159
+ * would wrongly suppress a legal second selection. Overlap is handled instead
160
+ * by the exact-string dedupe in `renderDirectionHints`.
161
+ */
162
+ export const DIRECTION_FIELDS = [
163
+ { key: "cameraMotion", surface: "video", family: "motion", maxPicks: 1, render: perId(getCameraMotionPromptHint, getCameraMotionTerm) },
164
+
165
+ // Composition (the Framing catalog, one row per category).
166
+ { key: "shotSize", surface: "both", family: "look", maxPicks: 1, render: framing },
167
+ { key: "angle", surface: "both", family: "look", maxPicks: 1, render: framing },
168
+ { key: "coverage", surface: "both", family: "look", maxPicks: 1, render: framing },
169
+ { key: "composition", surface: "both", family: "look", maxPicks: 2, render: framing },
170
+ { key: "vantage", surface: "both", family: "look", maxPicks: 1, render: framing },
171
+ { key: "pose", surface: "both", family: "look", maxPicks: 1, render: perId(getPosePromptHint, getPoseTerm) },
172
+ { key: "compositionEffect", surface: "both", family: "look", maxPicks: 1, render: perId(getCompositionEffectPromptHint, getCompositionEffectTerm) },
173
+
174
+ // Camera.
175
+ { key: "cameraFormat", surface: "both", family: "look", maxPicks: 1, render: perId(getCameraFormatPromptHint, getCameraFormatTerm) },
176
+ { key: "lens", surface: "both", family: "look", maxPicks: 1, render: perId(getLensPromptHint, getLensTerm) },
177
+
178
+ // Exposure (stills only — a video's exposure rides its own temporal levers).
179
+ { key: "aperture", surface: "image", family: "look", maxPicks: 1, render: exposure },
180
+ { key: "shutterSpeed", surface: "image", family: "look", maxPicks: 1, render: exposure },
181
+ { key: "isoValue", surface: "image", family: "look", maxPicks: 1, render: exposure },
182
+
183
+ // Light (the Lighting catalog, one row per category).
184
+ { key: "timeOfDay", surface: "both", family: "look", maxPicks: 1, render: lighting },
185
+ { key: "lightingStyle", surface: "both", family: "look", maxPicks: 2, render: lighting },
186
+ { key: "lightingDirection", surface: "both", family: "look", maxPicks: 1, render: lighting },
187
+ { key: "lightingRatio", surface: "both", family: "look", maxPicks: 1, render: lighting },
188
+ { key: "colorTemperature", surface: "both", family: "look", maxPicks: 1, render: lighting },
189
+ { key: "colorLook", surface: "both", family: "look", maxPicks: 1, render: perId(getColorLookPromptHint, getColorLookTerm) },
190
+ { key: "atmosphere", surface: "both", family: "look", maxPicks: 2, render: viaListBuilder(buildAtmosphereHints) },
191
+ { key: "postProcess", surface: "image", family: "look", maxPicks: 2, render: viaListBuilder(buildPostProcessHints) },
192
+
193
+ // Style.
194
+ { key: "style", surface: "both", family: "look", maxPicks: 1, render: perId(getStylePromptHint, getStyleTerm) },
195
+ { key: "mood", surface: "both", family: "look", maxPicks: 2, render: viaMood },
196
+ { key: "aesthetic", surface: "both", family: "look", maxPicks: 2, render: viaStringBuilder(buildAestheticHints) },
197
+ { key: "photoGenre", surface: "image", family: "look", maxPicks: 1, render: perId(getPhotoGenrePromptHint, getPhotoGenreTerm) },
198
+ { key: "photographer", surface: "image", family: "look", maxPicks: 2, render: viaStringBuilder(buildPhotographerHints) },
199
+ { key: "renderQuality", surface: "image", family: "look", maxPicks: 1, render: perId(getRenderQualityPromptHint, getRenderQualityTerm) },
200
+
201
+ // Scene.
202
+ { key: "setting", surface: "both", family: "look", maxPicks: 1, render: perId(getSettingPromptHint, getSettingTerm) },
203
+ { key: "era", surface: "both", family: "look", maxPicks: 1, render: perId(getEraPromptHint, getEraTerm) },
204
+ { key: "backdrop", surface: "both", family: "look", maxPicks: 1, render: perId(getBackdropPromptHint, getBackdropTerm) },
205
+
206
+ // Motion & time.
207
+ { key: "actionFx", surface: "video", family: "motion", maxPicks: 2, render: viaListBuilder(buildActionFxHints) },
208
+ { key: "temporalSpeed", surface: "video", family: "motion", maxPicks: 1, render: temporal },
209
+ { key: "temporalFreeze", surface: "video", family: "motion", maxPicks: 1, render: temporal },
210
+ { key: "temporalDirection", surface: "video", family: "motion", maxPicks: 1, render: temporal },
211
+ { key: "temporalShutter", surface: "video", family: "motion", maxPicks: 1, render: temporal },
212
+ { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: perId(getTransitionPromptHint, getTransitionTerm) },
213
+ { key: "loopSubject", surface: "video", family: "motion", maxPicks: 1, render: perId(getLoopSubjectPromptHint, getLoopSubjectTerm) },
214
+
215
+ // ── LEGACY BLOCK — see the table doc above. Placed LAST, in today's exact
216
+ // `composePromptText` order, so every pre-registry caller is byte-identical.
217
+ { key: "framingId", surface: "both", family: "look", maxPicks: 1, render: framing },
218
+ { key: "framingAngleId", surface: "both", family: "look", maxPicks: 1, render: framing },
219
+ { key: "lightingId", surface: "both", family: "look", maxPicks: 1, render: lighting },
220
+ { key: "lensId", surface: "both", family: "look", maxPicks: 1, render: perId(getLensPromptHint, getLensTerm) },
221
+ { key: "cameraFormatId", surface: "both", family: "look", maxPicks: 1, render: perId(getCameraFormatPromptHint, getCameraFormatTerm) },
222
+ ] as const satisfies ReadonlyArray<DirectionFieldSpec>
223
+
224
+ export type DirectionFieldRow = (typeof DIRECTION_FIELDS)[number]
225
+ export type DirectionKey = DirectionFieldRow["key"]
226
+ export type ImageDirectionKey = Extract<DirectionFieldRow, { surface: "image" | "both" }>["key"]
227
+ export type VideoDirectionKey = Extract<DirectionFieldRow, { surface: "video" | "both" }>["key"]
228
+
229
+ /**
230
+ * Flat cinematic-direction ids (Studio / MCP / canvas node data).
231
+ *
232
+ * Every key accepts a single id OR an array: multi-pick dimensions always carry
233
+ * an array, and a single-pick key may legitimately carry one (a client's
234
+ * partition writer preserving legacy out-of-catalog ids). Absent ≠ empty — a
235
+ * missing key means "no hint", never a default.
236
+ */
237
+ export type DirectionFields = { readonly [K in DirectionKey]?: string | readonly string[] }
238
+
239
+ /**
240
+ * Table order — THE canonical fold order. Exported so a client's "will inject
241
+ * into prompt" preview folds in the exact order the server does instead of
242
+ * re-deriving one.
243
+ */
244
+ export const DIRECTION_KEYS: ReadonlyArray<DirectionKey> = DIRECTION_FIELDS.map((f) => f.key)
245
+
246
+ /** Verbosity for a whole fold, or split per family. */
247
+ export type DirectionHintMode =
248
+ | PickerHintMode
249
+ | { readonly look: PickerHintMode; readonly motion: PickerHintMode }
250
+
251
+ /** Image policy: full clause for every dimension (today's behavior). */
252
+ export const IMAGE_HINT_MODE_DEFAULT: DirectionHintMode = "full"
253
+
254
+ /** Video policy: full look clauses, compact motion terms. */
255
+ export const VIDEO_HINT_MODE_DEFAULT: DirectionHintMode = { look: "full", motion: "compact" }
256
+
257
+ export function modeForFamily(mode: DirectionHintMode, family: DirectionFamily): PickerHintMode {
258
+ return typeof mode === "string" ? mode : family === "motion" ? mode.motion : mode.look
259
+ }
260
+
261
+ /**
262
+ * Wire tolerance ceiling for an array-valued direction key. The SEMANTIC cap is
263
+ * the per-row render-time slice (`maxPicks`) — this is only the point past
264
+ * which a body is malformed rather than merely over-generous.
265
+ */
266
+ export const DIRECTION_ARRAY_CEILING = 8
267
+
268
+ /**
269
+ * Tolerance ceiling for the LENGTH of a single direction id. Catalog ids are
270
+ * short slugs (<= ~40 chars); 100 is generous and closes an otherwise unbounded
271
+ * string channel that lands verbatim in `jobs.input_data`.
272
+ *
273
+ * ONE literal for BOTH doors into the fold — the route's `directionSchema`
274
+ * (`backend/src/lib/direction-schema.ts`) and the persisted-node reader
275
+ * (`read-node-direction.ts`) — so the wire and the canvas cannot start
276
+ * disagreeing about which strings are ids at all.
277
+ */
278
+ export const DIRECTION_ID_MAX_CHARS = 100
279
+
280
+ /**
281
+ * `string | string[]` → deduped, non-empty, capped id list.
282
+ *
283
+ * The `ids.length >= maxPicks` bail is load-bearing, not an optimization: the
284
+ * dedupe is an `includes` scan, so without it the cost is quadratic in the
285
+ * CALLER's array length — and one caller (`readDirectionFields`) reads
286
+ * untrusted persisted JSONB. Bailing is semantics-preserving: once `maxPicks`
287
+ * unique ids exist, no later entry can change the sliced result (and
288
+ * `maxPicks = 0` still yields `[]`).
289
+ */
290
+ function normalizeDirectionIds(value: unknown, maxPicks: number): string[] {
291
+ const ids: string[] = []
292
+ if (typeof value === "string") {
293
+ if (value) ids.push(value)
294
+ } else if (Array.isArray(value)) {
295
+ for (const v of value) {
296
+ if (ids.length >= maxPicks) break
297
+ if (typeof v === "string" && v && !ids.includes(v)) ids.push(v)
298
+ }
299
+ }
300
+ return ids.slice(0, maxPicks)
301
+ }
302
+
303
+ /** The rows a given generation stage folds, in table order. */
304
+ export function directionFieldsForSurface(
305
+ surface: "image" | "video",
306
+ ): ReadonlyArray<DirectionFieldSpec> {
307
+ return DIRECTION_FIELDS.filter((f) => f.surface === "both" || f.surface === surface)
308
+ }
309
+
310
+ /**
311
+ * Every clause the `direction` channel injects, in canonical table order.
312
+ *
313
+ * Iterates the TABLE (never the caller's object), so unknown wire keys are
314
+ * ignored, the order is platform-owned, and off-surface dimensions are inert.
315
+ * Unknown IDS are silently skipped too — every `get*PromptHint` returns `""` on
316
+ * a miss, so a retired or pack-only id contributes no clause rather than a 400.
317
+ *
318
+ * DEDUPE: the result is de-duplicated by exact clause string, FIRST OCCURRENCE
319
+ * WINS (order-preserving). This is what lets the five legacy whole-catalog keys
320
+ * (`framingId`, `lightingId`, …) coexist with their canonical counterparts
321
+ * without an alias table: a caller sending `framingId` and `shotSize` with the
322
+ * SAME id emits the clause once, while `lightingId: "golden-hour"` alongside
323
+ * `lightingStyle: "rembrandt"` correctly emits BOTH (they are different ids in
324
+ * the same catalog — an alias table would have wrongly suppressed one).
325
+ * A caller sending two DIFFERENT ids for the same dimension gets both clauses,
326
+ * exactly as two wired picker nodes of one family behave today.
327
+ *
328
+ * Exported so a client's "will inject into prompt" preview renders the exact
329
+ * server output instead of re-implementing the fold.
330
+ */
331
+ export function renderDirectionHints(
332
+ direction: DirectionFields | undefined,
333
+ opts: { surface: "image" | "video"; mode?: DirectionHintMode },
334
+ ): string[] {
335
+ if (!direction) return []
336
+ const mode = opts.mode ?? "full"
337
+ const out: string[] = []
338
+ const seen = new Set<string>()
339
+ for (const spec of DIRECTION_FIELDS) {
340
+ if (spec.surface !== "both" && spec.surface !== opts.surface) continue
341
+ const ids = normalizeDirectionIds(
342
+ (direction as Record<string, unknown>)[spec.key],
343
+ spec.maxPicks,
344
+ )
345
+ if (ids.length === 0) continue
346
+ for (const hint of spec.render(ids, modeForFamily(mode, spec.family))) {
347
+ if (hint.length > 0 && !seen.has(hint)) {
348
+ seen.add(hint)
349
+ out.push(hint)
350
+ }
351
+ }
352
+ }
353
+ return out
354
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * @nodaro/prompts — creative/prompt IP shared across backend and
3
3
  * frontend but deliberately EXCLUDED from the published Apache packages.
4
- * Never published to npm ("private": true); licensed with the repository
5
- * core under the root Nodaro Sustainable Use License.
4
+ * Published to npm (`publishConfig.access: public`) so first-party clients
5
+ * such as Studio can fold identically, under FSL-1.1-Apache-2.0 (free
6
+ * non-competing use) — see `packages/prompts/LICENSE`. NOT Apache-2.0 like
7
+ * `packages/{shared,client,cli}`; never merge prompt content back into those.
6
8
  *
7
9
  * Placement rule (root CLAUDE.md): new prompt engineering, catalogs,
8
10
  * doctrine, and presets default to backend/ or here — packages/shared gets
@@ -16,10 +18,14 @@ export * from "./entity-prompts.js"
16
18
  export * from "./brand-tokens.js"
17
19
  export * from "./prompt-builder.js"
18
20
  export * from "./prompt-builder-structured-fields.js"
21
+ export * from "./direction-registry.js"
22
+ export * from "./prompt-hint-join.js"
19
23
  export * from "./video-reference-resolver.js"
20
24
  export * from "./sound-aggregator.js"
21
25
  export * from "./assemble-suno-input.js"
22
26
  export * from "./assemble-image-input.js"
27
+ export * from "./assemble-video-input.js"
28
+ export * from "./read-node-direction.js"
23
29
  export * from "./seedance-2-inputs.js"
24
30
  export * from "./gemini-omni-inputs.js"
25
31
  export * from "./veo-i2v-inputs.js"
@@ -59,7 +59,14 @@ import {
59
59
  TRANSITION_DURATIONS,
60
60
  TRANSITION_INTENSITIES,
61
61
  } from "./transitions.js"
62
- import { CHARACTER_FX, CHARACTER_FX_CATEGORY_LABELS, CHARACTER_FX_CATEGORY_ORDER } from "./character-fx.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"
63
70
  import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
64
71
  import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
65
72
  import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
@@ -557,6 +564,16 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
557
564
  categoryOrder: CHARACTER_FX_CATEGORY_ORDER,
558
565
  categoryLabels: CHARACTER_FX_CATEGORY_LABELS,
559
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
+ ]),
560
577
  },
561
578
 
562
579
  // -------- "Subject / Object" family --------