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