@nodaro/prompts 1.10.0 → 1.12.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.
Files changed (35) hide show
  1. package/dist/index.cjs +693 -53
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1084 -27
  4. package/dist/index.d.ts +1084 -27
  5. package/dist/index.js +664 -55
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
  11. package/src/__tests__/assemble-image-input.test.ts +93 -3
  12. package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
  13. package/src/__tests__/assemble-video-input.test.ts +301 -0
  14. package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
  15. package/src/__tests__/direction-registry.test.ts +393 -0
  16. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  17. package/src/__tests__/image-convergence-image.test.ts +370 -0
  18. package/src/__tests__/location-convergence-image.test.ts +29 -1
  19. package/src/__tests__/location-default-role-image.test.ts +166 -0
  20. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  21. package/src/__tests__/read-node-direction.test.ts +154 -0
  22. package/src/__tests__/read-node-subject.test.ts +140 -0
  23. package/src/__tests__/subject-fold.test.ts +232 -0
  24. package/src/__tests__/subject-registry.test.ts +312 -0
  25. package/src/assemble-image-input.ts +160 -58
  26. package/src/assemble-video-input.ts +244 -0
  27. package/src/direction-registry.ts +371 -0
  28. package/src/hint-shedding.ts +68 -0
  29. package/src/index.ts +10 -2
  30. package/src/parameter-prompt-hint.ts +8 -7
  31. package/src/picker-catalogs.ts +14 -7
  32. package/src/prompt-builder.ts +728 -58
  33. package/src/prompt-hint-join.ts +30 -0
  34. package/src/read-node-direction.ts +233 -0
  35. package/src/subject-registry.ts +464 -0
@@ -0,0 +1,371 @@
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. It now exists: `subject-registry.ts`, same table-driven shape, its
36
+ * key set DISJOINT from this one (pinned by a test) so nothing folds twice.
37
+ *
38
+ * PACK BLINDNESS (parity, not a regression): `get*PromptHint` reads the frozen
39
+ * base arrays, so ids added by a deployment-registered catalog pack resolve to
40
+ * `""` and contribute no clause. This is identical to the behavior of the five
41
+ * keys that shipped before this table; routing through `getPickerCatalog` is a
42
+ * deliberate non-goal.
43
+ */
44
+ import type { PickerHintMode } from "./term.js"
45
+ import { getFramingPromptHint, getFramingTerm } from "./framing.js"
46
+ import { getLightingPromptHint, getLightingTerm } from "./lighting.js"
47
+ import { getLensPromptHint, getLensTerm } from "./lens.js"
48
+ import { getCameraFormatPromptHint, getCameraFormatTerm } from "./camera-format.js"
49
+ import { getCameraMotionPromptHint, getCameraMotionTerm } from "./camera-motions.js"
50
+ import { getPosePromptHint, getPoseTerm } from "./pose.js"
51
+ import {
52
+ getCompositionEffectPromptHint,
53
+ getCompositionEffectTerm,
54
+ } from "./composition-effects.js"
55
+ import { getExposurePromptHint, getExposureTerm } from "./exposure-settings.js"
56
+ import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
57
+ import { buildAtmosphereHints } from "./atmosphere.js"
58
+ import { buildPostProcessHints } from "./post-process-effects.js"
59
+ import { getStylePromptHint, getStyleTerm } from "./style.js"
60
+ import { buildMoodHints } from "./mood.js"
61
+ import { buildAestheticHints } from "./aesthetic.js"
62
+ import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
63
+ import { buildPhotographerHints } from "./photographer.js"
64
+ import { getRenderQualityPromptHint, getRenderQualityTerm } from "./render-quality.js"
65
+ import { getSettingPromptHint, getSettingTerm } from "./setting.js"
66
+ import { getEraPromptHint, getEraTerm } from "./era.js"
67
+ import { getBackdropPromptHint, getBackdropTerm } from "./backdrop.js"
68
+ import { buildActionFxHints } from "./action-fx.js"
69
+ import { getTemporalPromptHint, getTemporalTerm } from "./temporal.js"
70
+ import { getTransitionPromptHint, getTransitionTerm } from "./transitions.js"
71
+ import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
72
+
73
+ /** Which generation stages fold a dimension. */
74
+ export type DirectionSurface = "image" | "video" | "both"
75
+ /** Verbosity family — the video policy folds `motion` compact, `look` full. */
76
+ export type DirectionFamily = "look" | "motion"
77
+
78
+ export interface DirectionFieldSpec {
79
+ /**
80
+ * Wire key — also the `DirectionFields` property name and the canvas
81
+ * node-data field name for the same catalog.
82
+ */
83
+ readonly key: string
84
+ /**
85
+ * Which generation stages fold this dimension. Filtering happens in the
86
+ * RENDERER, never in the wire schema: an image-only key sent to
87
+ * `/v1/generate-video` is accepted and simply contributes no hint.
88
+ */
89
+ readonly surface: DirectionSurface
90
+ /** Verbosity family. The video policy folds `motion` compact, `look` full. */
91
+ readonly family: DirectionFamily
92
+ /** Ids honored per dimension. Extras are SLICED at render, never a 400. */
93
+ readonly maxPicks: number
94
+ /**
95
+ * Render selected ids in this catalog's own doctrine: per-id for independent
96
+ * catalogs, ONE blended clause for Mood / Aesthetic / Photographer, the
97
+ * catalog's own multi builder where it has one. `[]` when nothing resolves.
98
+ */
99
+ readonly render: (ids: readonly string[], mode: PickerHintMode) => string[]
100
+ }
101
+
102
+ // ── Render adapters (module-private) ────────────────────────────────────────
103
+
104
+ /**
105
+ * Independent per-id emission — the default doctrine.
106
+ *
107
+ * Only ever passes NON-EMPTY strings (the normalizer drops empties), which is
108
+ * what makes `getLoopSubjectPromptHint(id: string)`'s required-string signature
109
+ * safe here alongside the `string | undefined | null` getters.
110
+ */
111
+ const perId =
112
+ (full: (id: string) => string, compact: (id: string) => string) =>
113
+ (ids: readonly string[], mode: PickerHintMode): string[] => {
114
+ const out: string[] = []
115
+ for (const id of ids) {
116
+ const frag = mode === "compact" ? compact(id) : full(id)
117
+ if (frag.length > 0) out.push(frag)
118
+ }
119
+ return out
120
+ }
121
+
122
+ /** Catalogs whose own builder returns a LIST (atmosphere, post-process, action FX). */
123
+ const viaListBuilder =
124
+ (build: (v: unknown, mode: PickerHintMode) => string[]) =>
125
+ (ids: readonly string[], mode: PickerHintMode): string[] =>
126
+ build(ids.length === 1 ? ids[0] : [...ids], mode).filter((s) => s.length > 0)
127
+
128
+ /** Catalogs whose own builder returns ONE blended clause (aesthetic, photographer). */
129
+ const viaStringBuilder =
130
+ (build: (v: unknown, mode: PickerHintMode) => string) =>
131
+ (ids: readonly string[], mode: PickerHintMode): string[] => {
132
+ const s = build(ids.length === 1 ? ids[0] : [...ids], mode)
133
+ return s.length > 0 ? [s] : []
134
+ }
135
+
136
+ /** Mood's builder takes a RECORD and returns a list — and blends multi picks. */
137
+ const viaMood = (ids: readonly string[], mode: PickerHintMode): string[] =>
138
+ buildMoodHints({ mood: ids.length === 1 ? ids[0] : [...ids] }, mode).filter(
139
+ (s) => s.length > 0,
140
+ )
141
+
142
+ const framing = perId(getFramingPromptHint, getFramingTerm)
143
+ const lighting = perId(getLightingPromptHint, getLightingTerm)
144
+ const exposure = perId(getExposurePromptHint, getExposureTerm)
145
+ const temporal = perId(getTemporalPromptHint, getTemporalTerm)
146
+
147
+ /**
148
+ * THE TABLE. Declaration order IS fold order (`DIRECTION_KEYS`), pinned by
149
+ * `__tests__/direction-registry.test.ts`.
150
+ *
151
+ * Group order: camera motion leads (matching the video hint order the canvas
152
+ * and Studio both emit today), then composition → camera → exposure → light →
153
+ * style → scene → motion, then the LEGACY BLOCK last.
154
+ *
155
+ * THE LEGACY BLOCK (the last five rows) is the pre-registry published
156
+ * `DirectionFields`, placed LAST in today's exact `composePromptText` order so
157
+ * every existing caller's fold is byte-identical. They are WHOLE-CATALOG keys —
158
+ * `framingId` accepts ANY `FRAMINGS` id and `lightingId` ANY `LIGHTINGS` id —
159
+ * so they are NOT aliases of `shotSize` / `lightingStyle`, and an alias table
160
+ * would wrongly suppress a legal second selection. Overlap is handled instead
161
+ * by the exact-string dedupe in `renderDirectionHints`.
162
+ *
163
+ * SECOND MEANING OF POSITION: BOTH cap-aware assemblers — `assembleImageInput`
164
+ * (stills) and `composeVideoPromptText` (video) — shed hint clauses from the
165
+ * TAIL of this order when a provider's prompt cap overflows, through the one
166
+ * shared arithmetic in `hint-shedding.ts`. So a row's position is also its
167
+ * survival order under the cap on EVERY surface: reordering rows for one
168
+ * surface silently changes what the other drops first, and the row a
169
+ * video-surface reorder would most likely touch (`cameraMotion`) leads the
170
+ * fold. That is a consequence of reusing the fold order, not a ranking — this
171
+ * table stays a compatibility order; anything that needs a real importance
172
+ * ranking should add an explicit priority column rather than reorder these rows.
173
+ *
174
+ * WHERE THIS TABLE SITS IN THE COMBINED ORDER: both assemblers fold the SUBJECT
175
+ * channel (`subject-registry.ts`) BEFORE this one and shed the combined list
176
+ * tail-first, so every direction row here is dropped before any subject clause.
177
+ * Deliberate — see `hint-shedding.ts` for the argument.
178
+ */
179
+ export const DIRECTION_FIELDS = [
180
+ { key: "cameraMotion", surface: "video", family: "motion", maxPicks: 1, render: perId(getCameraMotionPromptHint, getCameraMotionTerm) },
181
+
182
+ // Composition (the Framing catalog, one row per category).
183
+ { key: "shotSize", surface: "both", family: "look", maxPicks: 1, render: framing },
184
+ { key: "angle", surface: "both", family: "look", maxPicks: 1, render: framing },
185
+ { key: "coverage", surface: "both", family: "look", maxPicks: 1, render: framing },
186
+ { key: "composition", surface: "both", family: "look", maxPicks: 2, render: framing },
187
+ { key: "vantage", surface: "both", family: "look", maxPicks: 1, render: framing },
188
+ { key: "pose", surface: "both", family: "look", maxPicks: 1, render: perId(getPosePromptHint, getPoseTerm) },
189
+ { key: "compositionEffect", surface: "both", family: "look", maxPicks: 1, render: perId(getCompositionEffectPromptHint, getCompositionEffectTerm) },
190
+
191
+ // Camera.
192
+ { key: "cameraFormat", surface: "both", family: "look", maxPicks: 1, render: perId(getCameraFormatPromptHint, getCameraFormatTerm) },
193
+ { key: "lens", surface: "both", family: "look", maxPicks: 1, render: perId(getLensPromptHint, getLensTerm) },
194
+
195
+ // Exposure (stills only — a video's exposure rides its own temporal levers).
196
+ { key: "aperture", surface: "image", family: "look", maxPicks: 1, render: exposure },
197
+ { key: "shutterSpeed", surface: "image", family: "look", maxPicks: 1, render: exposure },
198
+ { key: "isoValue", surface: "image", family: "look", maxPicks: 1, render: exposure },
199
+
200
+ // Light (the Lighting catalog, one row per category).
201
+ { key: "timeOfDay", surface: "both", family: "look", maxPicks: 1, render: lighting },
202
+ { key: "lightingStyle", surface: "both", family: "look", maxPicks: 2, render: lighting },
203
+ { key: "lightingDirection", surface: "both", family: "look", maxPicks: 1, render: lighting },
204
+ { key: "lightingRatio", surface: "both", family: "look", maxPicks: 1, render: lighting },
205
+ { key: "colorTemperature", surface: "both", family: "look", maxPicks: 1, render: lighting },
206
+ { key: "colorLook", surface: "both", family: "look", maxPicks: 1, render: perId(getColorLookPromptHint, getColorLookTerm) },
207
+ { key: "atmosphere", surface: "both", family: "look", maxPicks: 2, render: viaListBuilder(buildAtmosphereHints) },
208
+ { key: "postProcess", surface: "image", family: "look", maxPicks: 2, render: viaListBuilder(buildPostProcessHints) },
209
+
210
+ // Style.
211
+ { key: "style", surface: "both", family: "look", maxPicks: 1, render: perId(getStylePromptHint, getStyleTerm) },
212
+ { key: "mood", surface: "both", family: "look", maxPicks: 2, render: viaMood },
213
+ { key: "aesthetic", surface: "both", family: "look", maxPicks: 2, render: viaStringBuilder(buildAestheticHints) },
214
+ { key: "photoGenre", surface: "image", family: "look", maxPicks: 1, render: perId(getPhotoGenrePromptHint, getPhotoGenreTerm) },
215
+ { key: "photographer", surface: "image", family: "look", maxPicks: 2, render: viaStringBuilder(buildPhotographerHints) },
216
+ { key: "renderQuality", surface: "image", family: "look", maxPicks: 1, render: perId(getRenderQualityPromptHint, getRenderQualityTerm) },
217
+
218
+ // Scene.
219
+ { key: "setting", surface: "both", family: "look", maxPicks: 1, render: perId(getSettingPromptHint, getSettingTerm) },
220
+ { key: "era", surface: "both", family: "look", maxPicks: 1, render: perId(getEraPromptHint, getEraTerm) },
221
+ { key: "backdrop", surface: "both", family: "look", maxPicks: 1, render: perId(getBackdropPromptHint, getBackdropTerm) },
222
+
223
+ // Motion & time.
224
+ { key: "actionFx", surface: "video", family: "motion", maxPicks: 2, render: viaListBuilder(buildActionFxHints) },
225
+ { key: "temporalSpeed", surface: "video", family: "motion", maxPicks: 1, render: temporal },
226
+ { key: "temporalFreeze", surface: "video", family: "motion", maxPicks: 1, render: temporal },
227
+ { key: "temporalDirection", surface: "video", family: "motion", maxPicks: 1, render: temporal },
228
+ { key: "temporalShutter", surface: "video", family: "motion", maxPicks: 1, render: temporal },
229
+ { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: perId(getTransitionPromptHint, getTransitionTerm) },
230
+ { key: "loopSubject", surface: "video", family: "motion", maxPicks: 1, render: perId(getLoopSubjectPromptHint, getLoopSubjectTerm) },
231
+
232
+ // ── LEGACY BLOCK — see the table doc above. Placed LAST, in today's exact
233
+ // `composePromptText` order, so every pre-registry caller is byte-identical.
234
+ { key: "framingId", surface: "both", family: "look", maxPicks: 1, render: framing },
235
+ { key: "framingAngleId", surface: "both", family: "look", maxPicks: 1, render: framing },
236
+ { key: "lightingId", surface: "both", family: "look", maxPicks: 1, render: lighting },
237
+ { key: "lensId", surface: "both", family: "look", maxPicks: 1, render: perId(getLensPromptHint, getLensTerm) },
238
+ { key: "cameraFormatId", surface: "both", family: "look", maxPicks: 1, render: perId(getCameraFormatPromptHint, getCameraFormatTerm) },
239
+ ] as const satisfies ReadonlyArray<DirectionFieldSpec>
240
+
241
+ export type DirectionFieldRow = (typeof DIRECTION_FIELDS)[number]
242
+ export type DirectionKey = DirectionFieldRow["key"]
243
+ export type ImageDirectionKey = Extract<DirectionFieldRow, { surface: "image" | "both" }>["key"]
244
+ export type VideoDirectionKey = Extract<DirectionFieldRow, { surface: "video" | "both" }>["key"]
245
+
246
+ /**
247
+ * Flat cinematic-direction ids (Studio / MCP / canvas node data).
248
+ *
249
+ * Every key accepts a single id OR an array: multi-pick dimensions always carry
250
+ * an array, and a single-pick key may legitimately carry one (a client's
251
+ * partition writer preserving legacy out-of-catalog ids). Absent ≠ empty — a
252
+ * missing key means "no hint", never a default.
253
+ */
254
+ export type DirectionFields = { readonly [K in DirectionKey]?: string | readonly string[] }
255
+
256
+ /**
257
+ * Table order — THE canonical fold order. Exported so a client's "will inject
258
+ * into prompt" preview folds in the exact order the server does instead of
259
+ * re-deriving one.
260
+ */
261
+ export const DIRECTION_KEYS: ReadonlyArray<DirectionKey> = DIRECTION_FIELDS.map((f) => f.key)
262
+
263
+ /** Verbosity for a whole fold, or split per family. */
264
+ export type DirectionHintMode =
265
+ | PickerHintMode
266
+ | { readonly look: PickerHintMode; readonly motion: PickerHintMode }
267
+
268
+ /** Image policy: full clause for every dimension (today's behavior). */
269
+ export const IMAGE_HINT_MODE_DEFAULT: DirectionHintMode = "full"
270
+
271
+ /** Video policy: full look clauses, compact motion terms. */
272
+ export const VIDEO_HINT_MODE_DEFAULT: DirectionHintMode = { look: "full", motion: "compact" }
273
+
274
+ export function modeForFamily(mode: DirectionHintMode, family: DirectionFamily): PickerHintMode {
275
+ return typeof mode === "string" ? mode : family === "motion" ? mode.motion : mode.look
276
+ }
277
+
278
+ /**
279
+ * Wire tolerance ceiling for an array-valued direction key. The SEMANTIC cap is
280
+ * the per-row render-time slice (`maxPicks`) — this is only the point past
281
+ * which a body is malformed rather than merely over-generous.
282
+ */
283
+ export const DIRECTION_ARRAY_CEILING = 8
284
+
285
+ /**
286
+ * Tolerance ceiling for the LENGTH of a single direction id. Catalog ids are
287
+ * short slugs (<= ~40 chars); 100 is generous and closes an otherwise unbounded
288
+ * string channel that lands verbatim in `jobs.input_data`.
289
+ *
290
+ * ONE literal for BOTH doors into the fold — the route's `directionSchema`
291
+ * (`backend/src/lib/direction-schema.ts`) and the persisted-node reader
292
+ * (`read-node-direction.ts`) — so the wire and the canvas cannot start
293
+ * disagreeing about which strings are ids at all.
294
+ */
295
+ export const DIRECTION_ID_MAX_CHARS = 100
296
+
297
+ /**
298
+ * `string | string[]` → deduped, non-empty, capped id list.
299
+ *
300
+ * The `ids.length >= maxPicks` bail is load-bearing, not an optimization: the
301
+ * dedupe is an `includes` scan, so without it the cost is quadratic in the
302
+ * CALLER's array length — and one caller (`readDirectionFields`) reads
303
+ * untrusted persisted JSONB. Bailing is semantics-preserving: once `maxPicks`
304
+ * unique ids exist, no later entry can change the sliced result (and
305
+ * `maxPicks = 0` still yields `[]`).
306
+ */
307
+ function normalizeDirectionIds(value: unknown, maxPicks: number): string[] {
308
+ const ids: string[] = []
309
+ if (typeof value === "string") {
310
+ if (value) ids.push(value)
311
+ } else if (Array.isArray(value)) {
312
+ for (const v of value) {
313
+ if (ids.length >= maxPicks) break
314
+ if (typeof v === "string" && v && !ids.includes(v)) ids.push(v)
315
+ }
316
+ }
317
+ return ids.slice(0, maxPicks)
318
+ }
319
+
320
+ /** The rows a given generation stage folds, in table order. */
321
+ export function directionFieldsForSurface(
322
+ surface: "image" | "video",
323
+ ): ReadonlyArray<DirectionFieldSpec> {
324
+ return DIRECTION_FIELDS.filter((f) => f.surface === "both" || f.surface === surface)
325
+ }
326
+
327
+ /**
328
+ * Every clause the `direction` channel injects, in canonical table order.
329
+ *
330
+ * Iterates the TABLE (never the caller's object), so unknown wire keys are
331
+ * ignored, the order is platform-owned, and off-surface dimensions are inert.
332
+ * Unknown IDS are silently skipped too — every `get*PromptHint` returns `""` on
333
+ * a miss, so a retired or pack-only id contributes no clause rather than a 400.
334
+ *
335
+ * DEDUPE: the result is de-duplicated by exact clause string, FIRST OCCURRENCE
336
+ * WINS (order-preserving). This is what lets the five legacy whole-catalog keys
337
+ * (`framingId`, `lightingId`, …) coexist with their canonical counterparts
338
+ * without an alias table: a caller sending `framingId` and `shotSize` with the
339
+ * SAME id emits the clause once, while `lightingId: "golden-hour"` alongside
340
+ * `lightingStyle: "rembrandt"` correctly emits BOTH (they are different ids in
341
+ * the same catalog — an alias table would have wrongly suppressed one).
342
+ * A caller sending two DIFFERENT ids for the same dimension gets both clauses,
343
+ * exactly as two wired picker nodes of one family behave today.
344
+ *
345
+ * Exported so a client's "will inject into prompt" preview renders the exact
346
+ * server output instead of re-implementing the fold.
347
+ */
348
+ export function renderDirectionHints(
349
+ direction: DirectionFields | undefined,
350
+ opts: { surface: "image" | "video"; mode?: DirectionHintMode },
351
+ ): string[] {
352
+ if (!direction) return []
353
+ const mode = opts.mode ?? "full"
354
+ const out: string[] = []
355
+ const seen = new Set<string>()
356
+ for (const spec of DIRECTION_FIELDS) {
357
+ if (spec.surface !== "both" && spec.surface !== opts.surface) continue
358
+ const ids = normalizeDirectionIds(
359
+ (direction as Record<string, unknown>)[spec.key],
360
+ spec.maxPicks,
361
+ )
362
+ if (ids.length === 0) continue
363
+ for (const hint of spec.render(ids, modeForFamily(mode, spec.family))) {
364
+ if (hint.length > 0 && !seen.has(hint)) {
365
+ seen.add(hint)
366
+ out.push(hint)
367
+ }
368
+ }
369
+ }
370
+ return out
371
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The shed arithmetic shared by the image (`assembleImageInput`) and video
3
+ * (`composeVideoPromptText`) cap-aware assemblers, so the two surfaces cannot
4
+ * drift in WHICH clause goes first when a provider's prompt cap overflows.
5
+ *
6
+ * Only the arithmetic lives here. Each surface keeps its own loop, because what
7
+ * they MEASURE differs: the image side reads `buildImagePrompt`'s
8
+ * `overflowChars` (the cap clamp reports how much it cut), while the video side
9
+ * measures the resolver-FRAMED body against the route's effective ceiling. Both
10
+ * hand this function the same question — "how many of the first `kept` clauses
11
+ * may stay if `deficit` characters have to leave the body?" — and both re-assemble
12
+ * and re-check afterwards.
13
+ *
14
+ * WHAT COUNTS AS A SHEDDABLE CLAUSE (both surfaces, one answer): every clause
15
+ * the platform RENDERED from catalog ids — the SUBJECT fold and the cinematic
16
+ * DIRECTION fold alike. They are decoration of the same class, so exempting
17
+ * either would not save it: the overflow would simply land in the provider's
18
+ * order-blind tail clamp, severing reference bindings or the end of the user's
19
+ * prose instead — precisely the bug this machinery exists to prevent. Never
20
+ * sheddable: the user's prose, the bound references and the framing text the
21
+ * reference resolver adds, and the structured fragment (user CONTENT).
22
+ */
23
+ import { PROMPT_HINT_SEPARATOR } from "./prompt-hint-join.js"
24
+
25
+ /**
26
+ * How many of the first `kept` hint clauses may STAY if `deficit`
27
+ * characters have to leave the body. Walks the fold order from the TAIL,
28
+ * subtracting each clause plus the separator it brought, and stops as soon as
29
+ * enough has been reclaimed.
30
+ *
31
+ * The name is historical (direction was the first and for a while the only
32
+ * channel); the list both callers pass is now the COMBINED fold —
33
+ * `[...subject, ...direction]` on both surfaces — so the shed order is that
34
+ * combined order REVERSED: the direction block empties first, then the subject
35
+ * block. Deliberate, and the reason the two folds share one list: a fully
36
+ * specified person renders ~30 clauses, so a subject fold left unsheddable
37
+ * would be the single biggest way to push an overflow into the order-blind
38
+ * clamp, while a decorative grade or ISO value survives.
39
+ *
40
+ * Within the direction block the order is `DIRECTION_FIELDS` order REVERSED
41
+ * (and within the subject block, `SUBJECT_FIELDS` reversed). Note what that
42
+ * is and is not: each table's order is a COMPATIBILITY order (grouped by family,
43
+ * with the legacy `DirectionFields` block pinned last so every pre-registry
44
+ * caller's fold stays byte-identical) — it is NOT a ranking of how load-bearing
45
+ * a dimension is, and this function does not claim one. Tail-first is chosen
46
+ * because it is deterministic, matches the fold order the API documents, and
47
+ * needs no second ordering to drift out of sync with the table. A caller mixing
48
+ * legacy keys with the newer ones can therefore lose e.g. `lightingId` before a
49
+ * decorative `isoValue` clause; if that ever matters, the fix is an explicit
50
+ * priority column on `DIRECTION_FIELDS`, not a second hand-kept list here.
51
+ *
52
+ * Deliberately approximate (assembly is not perfectly additive); the caller
53
+ * re-assembles and re-checks, and this function strictly decreases `kept`
54
+ * whenever `deficit > 0`, so that loop terminates.
55
+ */
56
+ export function keepableDirectionHints(
57
+ hintClauses: readonly string[],
58
+ kept: number,
59
+ deficit: number,
60
+ ): number {
61
+ let remaining = deficit
62
+ let next = kept
63
+ while (next > 0 && remaining > 0) {
64
+ next -= 1
65
+ remaining -= hintClauses[next]!.length + PROMPT_HINT_SEPARATOR.length
66
+ }
67
+ return next
68
+ }
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,16 @@ 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 "./subject-registry.js"
23
+ export * from "./prompt-hint-join.js"
24
+ export * from "./hint-shedding.js"
19
25
  export * from "./video-reference-resolver.js"
20
26
  export * from "./sound-aggregator.js"
21
27
  export * from "./assemble-suno-input.js"
22
28
  export * from "./assemble-image-input.js"
29
+ export * from "./assemble-video-input.js"
30
+ export * from "./read-node-direction.js"
23
31
  export * from "./seedance-2-inputs.js"
24
32
  export * from "./gemini-omni-inputs.js"
25
33
  export * from "./veo-i2v-inputs.js"
@@ -38,7 +38,7 @@ import { composeCameraMotionHintFromConnections } from "./camera-motions.js"
38
38
  import { composeTransitionHintFromConnections, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
39
39
  import { composeCharacterFxHintFromConnections, type CharacterFxDuration, type CharacterFxIntensity, type CharacterFxPosition, type CharacterFxTiming } from "./character-fx.js"
40
40
  import { buildMaterialHints } from "./materials.js"
41
- import { getAnimal } from "@nodaro/shared"
41
+ import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
42
42
  import { getVehicle } from "@nodaro/shared"
43
43
  import { getWeapon } from "@nodaro/shared"
44
44
  import { getFurniture } from "@nodaro/shared"
@@ -322,15 +322,16 @@ function resolveBaseHint(
322
322
  return withCustomText(data, byMode(mode, getLoopSubjectPromptHint, getLoopSubjectTerm)(asStr(data.loopSubject)))
323
323
  case "material":
324
324
  return withCustomText(data, buildMaterialHints(data.material, mode))
325
- case "animal": {
326
- const animal = getAnimal(asStr(data.animal))
325
+ // Animal is the one Object-entity catalog whose phrasing has a single
326
+ // owner: `@nodaro/shared`'s `getAnimalPromptHint` / `getAnimalTerm`, which
327
+ // the picker-catalog funnel calls too. Both getters already return "" on a
328
+ // miss, so the entry lookup and the `animal ? … : ""` guard are the
329
+ // getters' job now, not this switch's.
330
+ case "animal":
327
331
  return withCustomText(
328
332
  data,
329
- animal
330
- ? byMode(mode, `featuring a ${animal.label.toLowerCase()}, ${animal.description}`, objectEntityTerm(animal))
331
- : "",
333
+ byMode(mode, getAnimalPromptHint, getAnimalTerm)(asStr(data.animal)),
332
334
  )
333
- }
334
335
  case "vehicle": {
335
336
  const vehicle = getVehicle(asStr(data.vehicle))
336
337
  return withCustomText(
@@ -69,7 +69,7 @@ import {
69
69
  } from "./character-fx.js"
70
70
  import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
71
71
  import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
72
- import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
72
+ import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER, getAnimalPromptHint } from "@nodaro/shared"
73
73
  import { VEHICLES, VEHICLE_SUBCATEGORY_LABELS, VEHICLE_SUBCATEGORY_ORDER } from "@nodaro/shared"
74
74
  import { WEAPONS, WEAPON_SUBCATEGORY_LABELS, WEAPON_SUBCATEGORY_ORDER } from "@nodaro/shared"
75
75
  import { FURNITURE, FURNITURE_SUBCATEGORY_LABELS, FURNITURE_SUBCATEGORY_ORDER } from "@nodaro/shared"
@@ -193,6 +193,13 @@ function toOptions<T extends BaseCatalogEntry>(
193
193
  * label + description. Reproduce the exact phrasing from
194
194
  * `getParameterPromptHint` so the registry stays faithful + every option's
195
195
  * `promptHint` is non-empty.
196
+ *
197
+ * ANIMALS is the exception, and the direction of travel for the other three:
198
+ * its phrasing now has ONE owner, `@nodaro/shared`'s `getAnimalPromptHint`,
199
+ * which this funnel and `getParameterPromptHint` both call instead of
200
+ * re-authoring the sentence. `phrase` therefore takes the ENTRY (not
201
+ * label+description), so a catalog whose phrasing has moved to a getter can be
202
+ * pointed at it.
196
203
  */
197
204
  interface ObjectCatalogEntry {
198
205
  readonly id: string
@@ -204,14 +211,14 @@ interface ObjectCatalogEntry {
204
211
  }
205
212
  function objectOptions(
206
213
  arr: ReadonlyArray<ObjectCatalogEntry>,
207
- phrase: (label: string, description: string) => string,
214
+ phrase: (entry: ObjectCatalogEntry) => string,
208
215
  ): ReadonlyArray<PickerOption> {
209
216
  return arr.map((e) => ({
210
217
  id: e.id,
211
218
  label: e.label,
212
219
  description: e.description,
213
220
  category: e.subcategory,
214
- promptHint: phrase(e.label.toLowerCase(), e.description),
221
+ promptHint: phrase(e),
215
222
  // Object entities have no `promptHint` field of their own (it is
216
223
  // synthesized above), so the term cannot come from `resolveTerm`'s
217
224
  // empty-hint rule: an authored `term` wins, and otherwise the label IS the
@@ -608,7 +615,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
608
615
  defaultValue: "dog-golden-retriever",
609
616
  categoryOrder: ANIMAL_SUBCATEGORY_ORDER,
610
617
  categoryLabels: ANIMAL_SUBCATEGORY_LABELS,
611
- options: objectOptions(ANIMALS, (label, description) => `featuring a ${label}, ${description}`),
618
+ options: objectOptions(ANIMALS, (e) => getAnimalPromptHint(e.id)),
612
619
  },
613
620
  {
614
621
  nodeType: "vehicle",
@@ -619,7 +626,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
619
626
  defaultValue: "sedan",
620
627
  categoryOrder: VEHICLE_SUBCATEGORY_ORDER,
621
628
  categoryLabels: VEHICLE_SUBCATEGORY_LABELS,
622
- options: objectOptions(VEHICLES, (label, description) => `featuring a ${label}, ${description}`),
629
+ options: objectOptions(VEHICLES, (e) => `featuring a ${e.label.toLowerCase()}, ${e.description}`),
623
630
  },
624
631
  {
625
632
  nodeType: "weapon",
@@ -630,7 +637,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
630
637
  defaultValue: "katana",
631
638
  categoryOrder: WEAPON_SUBCATEGORY_ORDER,
632
639
  categoryLabels: WEAPON_SUBCATEGORY_LABELS,
633
- options: objectOptions(WEAPONS, (label, description) => `with a ${label}, ${description}`),
640
+ options: objectOptions(WEAPONS, (e) => `with a ${e.label.toLowerCase()}, ${e.description}`),
634
641
  },
635
642
  {
636
643
  nodeType: "furniture",
@@ -641,7 +648,7 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
641
648
  defaultValue: "sofa",
642
649
  categoryOrder: FURNITURE_SUBCATEGORY_ORDER,
643
650
  categoryLabels: FURNITURE_SUBCATEGORY_LABELS,
644
- options: objectOptions(FURNITURE, (label, description) => `including a ${label}, ${description}`),
651
+ options: objectOptions(FURNITURE, (e) => `including a ${e.label.toLowerCase()}, ${e.description}`),
645
652
  },
646
653
  {
647
654
  nodeType: "held-prop",