@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.
- package/dist/index.cjs +693 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1084 -27
- package/dist/index.d.ts +1084 -27
- package/dist/index.js +664 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/subject-fold.test.ts +232 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +160 -58
- package/src/assemble-video-input.ts +244 -0
- package/src/direction-registry.ts +371 -0
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +10 -2
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +728 -58
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +233 -0
- 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
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
326
|
-
|
|
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(
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -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: (
|
|
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
|
|
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, (
|
|
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, (
|
|
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, (
|
|
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, (
|
|
651
|
+
options: objectOptions(FURNITURE, (e) => `including a ${e.label.toLowerCase()}, ${e.description}`),
|
|
645
652
|
},
|
|
646
653
|
{
|
|
647
654
|
nodeType: "held-prop",
|