@nodaro/prompts 1.10.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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `composeVideoPromptText` — the video twin of `assemble-image-input.ts`'s
3
+ * `composePromptText`: fold cinematic-direction picker IDS into the prompt BODY,
4
+ * server-side, at the model call.
5
+ *
6
+ * WHY THIS EXISTS: `/v1/generate-video` had no structured direction channel, so
7
+ * every client baked the hint TEXT itself. A copied scene then carried stale
8
+ * catalog wording forever, a re-generate double-baked it, and each client
9
+ * re-implemented the fold with its own separator and its own order. The wire
10
+ * now carries ids; the platform renders the clauses.
11
+ *
12
+ * WHERE IT RUNS (load-bearing): on the prompt BODY, BEFORE
13
+ * `resolveVideoReferenceCore`. That resolver FRAMES the body — legacy prepends
14
+ * its `Use these characters:` block, hybrid prepends the lock lines and APPENDS
15
+ * the canonical role phrases and extras. Folding afterwards would push the
16
+ * scene/look description PAST the identity directives, a worse version of the
17
+ * bug this channel exists to fix. The image side is structurally identical
18
+ * (`assembleImageInput` = `composePromptText` → `buildImagePrompt`).
19
+ *
20
+ * THE VERBOSITY POLICY LIVES HERE, NOT IN THE CLIENT: motion dimensions render
21
+ * their compact professional term, look dimensions their full clause
22
+ * (`VIDEO_HINT_MODE_DEFAULT`, resolved per row's `family` by the registry).
23
+ * It is a threaded PARAMETER with a pure default — never deployment state:
24
+ * `__tests__/content-free-contract.test.ts` hard-fails any environment read
25
+ * under `packages/prompts/src`, and this module has nothing to read anyway.
26
+ *
27
+ * EXACT NO-OP CONTRACT: with no direction and no structured fields the caller's
28
+ * `userPrompt` comes back VERBATIM AND UNTRIMMED — `undefined` included, since
29
+ * a video prompt is optional on the route. That is what keeps every existing
30
+ * caller byte-identical (the "backward-compatible: no connectedReferences →
31
+ * prompt + flat refs pass through unchanged" oracle in
32
+ * `backend/src/routes/__tests__/generate-video.test.ts`, restated locally in
33
+ * `__tests__/assemble-video-input.test.ts`).
34
+ *
35
+ * WHAT IS DELIBERATELY NOT HERE: the dimension table, the fold order, the
36
+ * dedupe and the surface filter all live in `direction-registry.ts` — ONE
37
+ * renderer serves both surfaces, so the image and video folds cannot drift.
38
+ * Clients render their "will inject into prompt" preview by importing
39
+ * `renderDirectionHints` + `joinPromptHints` directly.
40
+ */
41
+ import {
42
+ renderDirectionHints,
43
+ VIDEO_HINT_MODE_DEFAULT,
44
+ type DirectionFields,
45
+ type DirectionHintMode,
46
+ } from "./direction-registry.js"
47
+ import { joinPromptHints } from "./prompt-hint-join.js"
48
+ import {
49
+ renderStructuredFields,
50
+ type StructuredPromptFields,
51
+ } from "./prompt-builder-structured-fields.js"
52
+
53
+ /**
54
+ * Fold a video run's cinematic-direction ids (and optional structured fields)
55
+ * into its prompt body.
56
+ *
57
+ * The direction hints land first, in the registry's canonical table order
58
+ * (camera motion leads), and the structured fragment lands LAST — the same
59
+ * ordering `composePromptText` uses for stills.
60
+ *
61
+ * @param userPrompt The user's prompt. Optional: an image-to-video run may
62
+ * legitimately have none, and it is returned as-is when nothing folds.
63
+ * @param direction Flat catalog ids. Unknown keys, off-surface keys (an
64
+ * image-only dimension sent to a video run) and unknown ids all contribute
65
+ * nothing — never a throw.
66
+ * @param structured Path-1 structured fields. Not a `/v1/generate-video` wire
67
+ * field today; the canvas orchestrator passes it directly.
68
+ * @param opts.hintMode Override the verbosity policy (a whole-fold
69
+ * `PickerHintMode`, or a `{ look, motion }` split).
70
+ */
71
+ export function composeVideoPromptText(
72
+ userPrompt: string | undefined,
73
+ direction: DirectionFields | undefined,
74
+ structured?: StructuredPromptFields,
75
+ opts?: { readonly hintMode?: DirectionHintMode },
76
+ ): string | undefined {
77
+ const hints = [
78
+ ...renderDirectionHints(direction, {
79
+ surface: "video",
80
+ mode: opts?.hintMode ?? VIDEO_HINT_MODE_DEFAULT,
81
+ }),
82
+ structured ? renderStructuredFields(structured) : "",
83
+ ].filter((p) => p.length > 0)
84
+ // Nothing to fold → the caller's value straight back, `undefined` included.
85
+ // Do NOT collapse this into `joinPromptHints(userPrompt ?? "", hints)`: that
86
+ // would turn an absent prompt into `""` and break the no-op contract above.
87
+ if (hints.length === 0) return userPrompt
88
+ return joinPromptHints(userPrompt ?? "", hints)
89
+ }
@@ -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"