@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.
- package/dist/index.cjs +318 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +594 -25
- package/dist/index.d.ts +594 -25
- package/dist/index.js +306 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- 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__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/assemble-image-input.ts +45 -46
- package/src/assemble-video-input.ts +89 -0
- package/src/direction-registry.ts +354 -0
- package/src/index.ts +8 -2
- package/src/prompt-builder.ts +188 -1
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +174 -0
|
@@ -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
|
-
*
|
|
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,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"
|