@nodaro/prompts 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/index.cjs +9770 -1344
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +187 -7
  4. package/dist/index.d.ts +187 -7
  5. package/dist/index.js +9098 -684
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +18 -1
  9. package/src/__tests__/character-motion-docs-example.test.ts +26 -0
  10. package/src/__tests__/character-motion-timing-catalogs.test.ts +76 -0
  11. package/src/__tests__/character-motion.test.ts +207 -0
  12. package/src/__tests__/fixtures/parameter-hint-golden.json +136 -0
  13. package/src/__tests__/parameter-hint-mode.test.ts +28 -0
  14. package/src/__tests__/parameter-prompt-hint.test.ts +159 -0
  15. package/src/__tests__/parameter-registry-sync.test.ts +2 -0
  16. package/src/__tests__/picker-analyzer-registry.test.ts +1 -1
  17. package/src/__tests__/transition-timing-catalogs.test.ts +1 -1
  18. package/src/__tests__/video-reference-features.test.ts +10 -1
  19. package/src/age-floor.ts +1 -1
  20. package/src/character-motion/animals-pets.ts +89 -0
  21. package/src/character-motion/athletic-stunts.ts +218 -0
  22. package/src/character-motion/camera-interaction.ts +137 -0
  23. package/src/character-motion/combat-weapons.ts +206 -0
  24. package/src/character-motion/dance.ts +173 -0
  25. package/src/character-motion/entrances-exits.ts +113 -0
  26. package/src/character-motion/evasive-falls.ts +134 -0
  27. package/src/character-motion/everyday-actions.ts +278 -0
  28. package/src/character-motion/face-expression.ts +182 -0
  29. package/src/character-motion/gestures.ts +308 -0
  30. package/src/character-motion/head-gestures.ts +98 -0
  31. package/src/character-motion/idle-ambient.ts +128 -0
  32. package/src/character-motion/posture-shifts.ts +143 -0
  33. package/src/character-motion/runway.ts +101 -0
  34. package/src/character-motion/stage-performance.ts +131 -0
  35. package/src/character-motion/turns-looks.ts +116 -0
  36. package/src/character-motion/two-person.ts +233 -0
  37. package/src/character-motion/types.ts +42 -0
  38. package/src/character-motion/unnatural-horror.ts +95 -0
  39. package/src/character-motion/vehicles-mounts.ts +101 -0
  40. package/src/character-motion/walks-runs.ts +125 -0
  41. package/src/character-motion.ts +286 -0
  42. package/src/index.ts +1 -0
  43. package/src/parameter-prompt-hint.ts +65 -0
  44. package/src/picker-analyzer-registry.ts +9 -5
  45. package/src/picker-catalogs.ts +24 -0
  46. package/src/picker-wiring.ts +2 -0
  47. package/src/prompt-wizard-categories.ts +0 -2
  48. package/src/provider-prompt-doctrine.ts +1 -1
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Canonical catalog of CHARACTER MOTION choices — what the subject DOES across
3
+ * a video clip: walks in, turns to camera, breaks into a smile, ducks, draws and
4
+ * fires, runway-walks, dances. Temporal by definition.
5
+ *
6
+ * Distinct from:
7
+ * - Pose — a static SNAPSHOT of body position (serves still images too).
8
+ * - Character FX — supernatural change to the subject's body (werewolf, fire breath).
9
+ * - Action FX — a scene event (explosion, lightning); motion is the BODY acting.
10
+ * - Camera Motion — the camera moves, never the subject.
11
+ *
12
+ * Shared between the picker UI, the standalone Character Motion parameter node,
13
+ * and the prompt-hint injection on both the frontend DAG executor and the
14
+ * backend orchestrator. Video-only: never injected into still-image consumers.
15
+ *
16
+ * Every non-empty `promptHint` references "the subject" at least once — the
17
+ * composer does a global regex replace of "the subject" with the wired target's
18
+ * display name. Two-person moves ALSO contain the literal words "the partner",
19
+ * replaced with the wired partner's name, or "another person" when unwired.
20
+ *
21
+ * Multi-pick is an ORDERED SEQUENCE: value field accepts `string | string[]`
22
+ * (cap 3), joined with ", then " in BOTH hint modes (a movement happens after
23
+ * the previous one; Character FX joins with ", and " because effects coincide).
24
+ *
25
+ * `adultOnly` marks the W1-a minor-age floor. The composer drops flagged ids
26
+ * when its caller reports a minor subject (`floor.subjectMinor`); the AI Fill
27
+ * strip drops them from analyzer output. Hand-curated; the `adult-only-ratchet`
28
+ * test only ratchets. Never set on neutral movement or the `auto` / `none` heads.
29
+ */
30
+
31
+ import { resolveTerm, type PickerHintMode } from "./term.js"
32
+ import { overlayEntry } from "./catalog-overlay.js"
33
+ export type { CharacterMotion, CharacterMotionCategory } from "./character-motion/types.js"
34
+ import type { CharacterMotion, CharacterMotionCategory } from "./character-motion/types.js"
35
+ import { ENTRANCES_EXITS_MOTIONS } from "./character-motion/entrances-exits.js"
36
+ import { TURNS_LOOKS_MOTIONS } from "./character-motion/turns-looks.js"
37
+ import { HEAD_GESTURES_MOTIONS } from "./character-motion/head-gestures.js"
38
+ import { WALKS_RUNS_MOTIONS } from "./character-motion/walks-runs.js"
39
+ import { RUNWAY_MOTIONS } from "./character-motion/runway.js"
40
+ import { DANCE_MOTIONS } from "./character-motion/dance.js"
41
+ import { FACE_EXPRESSION_MOTIONS } from "./character-motion/face-expression.js"
42
+ import { GESTURES_MOTIONS } from "./character-motion/gestures.js"
43
+ import { CAMERA_INTERACTION_MOTIONS } from "./character-motion/camera-interaction.js"
44
+ import { COMBAT_WEAPONS_MOTIONS } from "./character-motion/combat-weapons.js"
45
+ import { ATHLETIC_STUNTS_MOTIONS } from "./character-motion/athletic-stunts.js"
46
+ import { EVASIVE_FALLS_MOTIONS } from "./character-motion/evasive-falls.js"
47
+ import { POSTURE_SHIFTS_MOTIONS } from "./character-motion/posture-shifts.js"
48
+ import { EVERYDAY_ACTIONS_MOTIONS } from "./character-motion/everyday-actions.js"
49
+ import { VEHICLES_MOUNTS_MOTIONS } from "./character-motion/vehicles-mounts.js"
50
+ import { ANIMALS_PETS_MOTIONS } from "./character-motion/animals-pets.js"
51
+ import { TWO_PERSON_MOTIONS } from "./character-motion/two-person.js"
52
+ import { IDLE_AMBIENT_MOTIONS } from "./character-motion/idle-ambient.js"
53
+ import { STAGE_PERFORMANCE_MOTIONS } from "./character-motion/stage-performance.js"
54
+ import { UNNATURAL_HORROR_MOTIONS } from "./character-motion/unnatural-horror.js"
55
+
56
+ export const CHARACTER_MOTION_CATEGORY_ORDER: ReadonlyArray<CharacterMotionCategory> = [
57
+ "entrances-exits",
58
+ "turns-looks",
59
+ "head-gestures",
60
+ "walks-runs",
61
+ "runway",
62
+ "dance",
63
+ "face-expression",
64
+ "gestures",
65
+ "camera-interaction",
66
+ "combat-weapons",
67
+ "athletic-stunts",
68
+ "evasive-falls",
69
+ "posture-shifts",
70
+ "everyday-actions",
71
+ "vehicles-mounts",
72
+ "animals-pets",
73
+ "two-person",
74
+ "idle-ambient",
75
+ "stage-performance",
76
+ "unnatural-horror",
77
+ ] as const
78
+
79
+ export const CHARACTER_MOTION_CATEGORY_LABELS: Readonly<Record<CharacterMotionCategory, string>> = {
80
+ "entrances-exits": "Entrances & Exits",
81
+ "turns-looks": "Turns & Looks",
82
+ "head-gestures": "Head Gestures",
83
+ "walks-runs": "Walks & Runs",
84
+ "runway": "Runway & Model",
85
+ "dance": "Dance",
86
+ "face-expression": "Face & Expression",
87
+ "gestures": "Gestures",
88
+ "camera-interaction": "Camera Interaction",
89
+ "combat-weapons": "Combat & Weapons",
90
+ "athletic-stunts": "Athletic & Stunts",
91
+ "evasive-falls": "Evasive & Falls",
92
+ "posture-shifts": "Posture Shifts",
93
+ "everyday-actions": "Everyday Actions",
94
+ "vehicles-mounts": "Vehicles & Mounts",
95
+ "animals-pets": "Animals & Pets",
96
+ "two-person": "Two-Person",
97
+ "idle-ambient": "Idle & Ambient",
98
+ "stage-performance": "Stage & Performance",
99
+ "unnatural-horror": "Unnatural & Horror",
100
+ } as const
101
+
102
+ /**
103
+ * The catalog: two no-op heads + every category array, in category order.
104
+ * 1003 injecting entries across 20 categories.
105
+ */
106
+ export const CHARACTER_MOTIONS: ReadonlyArray<CharacterMotion> = [
107
+ // Defaults — both inject nothing (empty promptHint ⇒ empty term).
108
+ { id: "auto", label: "Auto", category: "entrances-exits", description: "Let the model choose the movement", promptHint: "" },
109
+ { id: "none", label: "None", category: "entrances-exits", description: "No scripted character movement", promptHint: "" },
110
+ ...ENTRANCES_EXITS_MOTIONS,
111
+ ...TURNS_LOOKS_MOTIONS,
112
+ ...HEAD_GESTURES_MOTIONS,
113
+ ...WALKS_RUNS_MOTIONS,
114
+ ...RUNWAY_MOTIONS,
115
+ ...DANCE_MOTIONS,
116
+ ...FACE_EXPRESSION_MOTIONS,
117
+ ...GESTURES_MOTIONS,
118
+ ...CAMERA_INTERACTION_MOTIONS,
119
+ ...COMBAT_WEAPONS_MOTIONS,
120
+ ...ATHLETIC_STUNTS_MOTIONS,
121
+ ...EVASIVE_FALLS_MOTIONS,
122
+ ...POSTURE_SHIFTS_MOTIONS,
123
+ ...EVERYDAY_ACTIONS_MOTIONS,
124
+ ...VEHICLES_MOUNTS_MOTIONS,
125
+ ...ANIMALS_PETS_MOTIONS,
126
+ ...TWO_PERSON_MOTIONS,
127
+ ...IDLE_AMBIENT_MOTIONS,
128
+ ...STAGE_PERFORMANCE_MOTIONS,
129
+ ...UNNATURAL_HORROR_MOTIONS,
130
+ ]
131
+
132
+ const characterMotionById = new Map<string, CharacterMotion>(
133
+ CHARACTER_MOTIONS.map((m) => [m.id, m]),
134
+ )
135
+
136
+ export function getCharacterMotion(id: string | undefined | null): CharacterMotion | undefined {
137
+ if (!id) return undefined
138
+ return overlayEntry("character-motion", id, characterMotionById.get(id))
139
+ }
140
+
141
+ export function getCharacterMotionLabel(id: string | undefined | null, fallback?: string): string {
142
+ const m = getCharacterMotion(id)
143
+ if (m) return m.label
144
+ if (fallback !== undefined) return fallback
145
+ return (id ?? "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
146
+ }
147
+
148
+ export function getCharacterMotionPromptHint(id: string | undefined | null): string {
149
+ return getCharacterMotion(id)?.promptHint ?? ""
150
+ }
151
+
152
+ /** Compact counterpart of `getCharacterMotionPromptHint` — same lookup, same
153
+ * empty-string-on-miss, and the no-op `auto` / `none` heads inject nothing. */
154
+ export function getCharacterMotionTerm(id: string | undefined | null): string {
155
+ return resolveTerm(getCharacterMotion(id))
156
+ }
157
+
158
+ export const CHARACTER_MOTION_IDS: ReadonlyArray<string> = CHARACTER_MOTIONS.map((m) => m.id)
159
+
160
+ /** Cap on ordered picks. Shared by the composer (`.slice(0, N)`), the picker
161
+ * (`maxSelected`) and the config panel — the three must agree. */
162
+ export const CHARACTER_MOTION_MAX_PICKS = 3
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Timing scales — Position + Pace. Own wording; NOT the character-fx or the
166
+ // transition rows (an effect manifests, a transition occurs, a MOVEMENT is
167
+ // performed). `POSITION_CLAUSES` / `PACE_CLAUSES` are DERIVED from these arrays
168
+ // so the clause the composer injects and the hint the catalog advertises are
169
+ // the same string by construction.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface CharacterMotionTimingOption {
173
+ readonly id: string
174
+ readonly label: string
175
+ readonly description: string
176
+ readonly promptHint: string
177
+ readonly term?: string
178
+ }
179
+
180
+ export const CHARACTER_MOTION_POSITIONS = [
181
+ { id: "auto", label: "Auto", description: "Let the model place the movement", promptHint: "", term: "" },
182
+ { id: "start", label: "Start", description: "Begins at the opening of the clip", promptHint: "the movement begins at the opening of the clip", term: "starting as the clip opens" },
183
+ { id: "middle", label: "Middle", description: "Begins midway through the clip", promptHint: "the movement begins midway through the clip", term: "starting midway through the clip" },
184
+ { id: "end", label: "End", description: "Happens in the closing moments of the clip", promptHint: "the movement happens in the closing moments of the clip", term: "in the closing moments of the clip" },
185
+ { id: "full", label: "Full", description: "Plays out across the entire clip", promptHint: "the movement plays out across the entire clip", term: "playing out across the whole clip" },
186
+ ] as const satisfies ReadonlyArray<CharacterMotionTimingOption>
187
+
188
+ export const CHARACTER_MOTION_PACES = [
189
+ { id: "auto", label: "Auto", description: "Let the model set the tempo", promptHint: "", term: "" },
190
+ { id: "slow-motion", label: "Slow Motion", description: "The action itself is rendered in slow motion", promptHint: "the action is rendered in slow motion, every phase of the movement stretched and drawn out", term: "in slow motion" },
191
+ { id: "slow", label: "Slow", description: "Performed slowly and deliberately", promptHint: "performed slowly and deliberately, each phase of the movement given its full time", term: "slow and deliberate" },
192
+ { id: "natural", label: "Natural", description: "Performed at an everyday tempo", promptHint: "performed at a natural everyday tempo, neither rushed nor drawn out", term: "at a natural everyday tempo" },
193
+ { id: "fast", label: "Fast", description: "Performed quickly, brisk and urgent", promptHint: "performed quickly, with brisk urgent tempo and sharp transitions between phases", term: "fast and brisk" },
194
+ { id: "explosive", label: "Explosive", description: "A sudden burst from stillness into full-speed motion", promptHint: "performed with an explosive burst of energy, snapping from stillness into full-speed motion", term: "with an explosive burst" },
195
+ ] as const satisfies ReadonlyArray<CharacterMotionTimingOption>
196
+
197
+ export type CharacterMotionPosition = (typeof CHARACTER_MOTION_POSITIONS)[number]["id"]
198
+ export type CharacterMotionPace = (typeof CHARACTER_MOTION_PACES)[number]["id"]
199
+
200
+ export interface CharacterMotionTiming {
201
+ position?: CharacterMotionPosition
202
+ pace?: CharacterMotionPace
203
+ }
204
+
205
+ /** Private twin of the helper in character-fx.ts / transitions.ts — the
206
+ * catalogs must stay independent. Total over the array by construction. */
207
+ function clausesOf<T extends CharacterMotionTimingOption>(
208
+ options: ReadonlyArray<T>,
209
+ ): Record<Exclude<T["id"], "auto">, string> {
210
+ return Object.fromEntries(
211
+ options.filter((o) => o.id !== "auto").map((o) => [o.id, o.promptHint]),
212
+ ) as Record<Exclude<T["id"], "auto">, string>
213
+ }
214
+
215
+ const POSITION_CLAUSES = clausesOf(CHARACTER_MOTION_POSITIONS)
216
+ const PACE_CLAUSES = clausesOf(CHARACTER_MOTION_PACES)
217
+
218
+ /** Widening guard — `as const` on the two arrays is load-bearing (see character-fx.ts). */
219
+ type NarrowIds<T> = string extends T ? never : true
220
+ const _timingIdsStayNarrow: [NarrowIds<CharacterMotionPosition>, NarrowIds<CharacterMotionPace>] = [true, true]
221
+ void _timingIdsStayNarrow
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Graph-aware composer — target + partner handles, ordered multi-pick, timing
225
+ // ---------------------------------------------------------------------------
226
+
227
+ const PARTNER_FALLBACK = "another person"
228
+
229
+ /** What the caller knows about the wired target. `subjectMinor: true` drops
230
+ * every `adultOnly` pick after the cap; otherwise the output is unchanged. */
231
+ export interface CharacterMotionFloor {
232
+ readonly subjectMinor?: boolean
233
+ }
234
+
235
+ /**
236
+ * Compose a character-motion prompt fragment from an ORDERED list of 1–3 move
237
+ * ids, the display names wired to the `target` and `partner` handles, and the
238
+ * optional Position / Pace timing.
239
+ *
240
+ * Full mode: each hint has "the subject" rewritten to the target name(s) (only
241
+ * when a target is wired) and "the partner" rewritten to the partner name (or
242
+ * "another person" — ALWAYS, so the literal words "the partner" never ship);
243
+ * both BEFORE the ", then " join. Compact mode: terms joined with ", then ",
244
+ * prefixed `"{target}: "` when a target is wired. A term never contains "the
245
+ * subject" (the prefix names the target) but a two-person term always contains
246
+ * "the partner", which is substituted exactly as in full mode.
247
+ * Timing clauses follow in the fixed order position, pace, in both modes.
248
+ * `floor.subjectMinor` drops adult-only picks after the cap; none left ⇒ "".
249
+ */
250
+ export function composeCharacterMotionHintFromConnections(
251
+ motionId: string | ReadonlyArray<string> | undefined | null,
252
+ targetHints: ReadonlyArray<string>,
253
+ partnerHints: ReadonlyArray<string>,
254
+ timing?: CharacterMotionTiming,
255
+ mode: PickerHintMode = "full",
256
+ floor?: CharacterMotionFloor,
257
+ ): string {
258
+ const picked = Array.isArray(motionId)
259
+ ? Array.from(new Set(motionId as ReadonlyArray<string>)).slice(0, CHARACTER_MOTION_MAX_PICKS)
260
+ : motionId ? [motionId as string] : []
261
+ // Cap the user's picks FIRST, then floor: a dropped pick never lets a later one backfill.
262
+ const ids = floor?.subjectMinor === true
263
+ ? picked.filter((id) => getCharacterMotion(id)?.adultOnly !== true)
264
+ : picked
265
+ const resolveBase = mode === "compact" ? getCharacterMotionTerm : getCharacterMotionPromptHint
266
+ const entries = ids.map((id) => ({ id, base: resolveBase(id) })).filter((e) => e.base.length > 0)
267
+ if (entries.length === 0) return ""
268
+
269
+ const targetClause = targetHints.filter((h) => h && h.length > 0).join(" and ")
270
+ const partnerClause = partnerHints.filter((h) => h && h.length > 0).join(" and ")
271
+ const partnerName = partnerClause || PARTNER_FALLBACK
272
+
273
+ const substituted = entries.map(({ base }) => {
274
+ const named = mode !== "compact" && targetClause
275
+ ? base.replace(/\bthe subject\b/g, targetClause)
276
+ : base
277
+ return named.replace(/\bthe partner\b/g, partnerName)
278
+ })
279
+
280
+ const joined = substituted.join(", then ")
281
+ const combinedBase = mode === "compact" && targetClause ? `${targetClause}: ${joined}` : joined
282
+ const parts: string[] = [combinedBase]
283
+ if (timing?.position && timing.position !== "auto") parts.push(POSITION_CLAUSES[timing.position])
284
+ if (timing?.pace && timing.pace !== "auto") parts.push(PACE_CLAUSES[timing.pace])
285
+ return parts.join(", ")
286
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./camera-format.js"
51
51
  export * from "./camera-motions.js"
52
52
  export * from "./hint-join.js"
53
53
  export * from "./character-fx.js"
54
+ export * from "./character-motion.js"
54
55
  export * from "./color-look.js"
55
56
  export * from "./composition-effects.js"
56
57
  export * from "./era.js"
@@ -38,6 +38,8 @@ import { composeCameraMotionHintFromConnections } from "./camera-motions.js"
38
38
  import { joinHintFragments } from "./hint-join.js"
39
39
  import { composeTransitionHintFromConnections, type TransitionDuration, type TransitionIntensity, type TransitionPosition, type TransitionTiming } from "./transitions.js"
40
40
  import { composeCharacterFxHintFromConnections, type CharacterFxDuration, type CharacterFxIntensity, type CharacterFxPosition, type CharacterFxTiming } from "./character-fx.js"
41
+ import { composeCharacterMotionHintFromConnections, type CharacterMotionPace, type CharacterMotionPosition, type CharacterMotionTiming } from "./character-motion.js"
42
+ import { containsMinorAgeHint, isMinorAge } from "./age-floor.js"
41
43
  import { buildMaterialHints } from "./materials.js"
42
44
  import { curatedAnimalPromptHint, curatedAnimalTerm, curatedVehicleText, curatedWeaponText, curatedFurnitureText } from "./shared-catalog-overlay.js"
43
45
  import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
@@ -73,6 +75,33 @@ function extractCharacterRefName(node: HintNodeLike): string | undefined {
73
75
  return undefined
74
76
  }
75
77
 
78
+ /** Character Motion's name read: the shared character / face / object / location
79
+ * read, then a creature's `creatureName`. Local to Character Motion so the
80
+ * Character FX prompt, which shares `extractCharacterRefName`, stays unchanged. */
81
+ function extractCharacterMotionRefName(node: HintNodeLike): string | undefined {
82
+ const name = extractCharacterRefName(node)
83
+ if (name) return name
84
+ const creatureName = asStr(((node.data ?? {}) as Record<string, unknown>).creatureName).trim()
85
+ return creatureName.length > 0 ? creatureName : undefined
86
+ }
87
+
88
+ /** Free-text fields a character / face / object / location ref node describes its subject in.
89
+ * A creature ref uses the same `description` / `canonicalDescription` names. */
90
+ const REF_DESCRIPTION_FIELDS = ["description", "seedPrompt", "canonicalDescription"] as const
91
+
92
+ /** True when a character / face / object / location ref node describes a minor:
93
+ * its structured `person` age (the primary signal) or an age phrase in its
94
+ * free-text description. Feeds the Character Motion floor (adult-only moves
95
+ * dropped) — errs toward flooring, never toward letting an adult-only move through. */
96
+ function extractCharacterRefMinor(node: HintNodeLike): boolean {
97
+ const d = (node.data ?? {}) as Record<string, unknown>
98
+ const person = d.person
99
+ if (person && typeof person === "object" && !Array.isArray(person) && isMinorAge(person as Parameters<typeof isMinorAge>[0])) {
100
+ return true
101
+ }
102
+ return REF_DESCRIPTION_FIELDS.some((field) => containsMinorAgeHint(asStr(d[field])))
103
+ }
104
+
76
105
  /** Compose `[preText, mainHint, postText]` into a comma-joined string,
77
106
  * honoring the user's free-text fragments around the structured hint.
78
107
  * Helpers like `getStylePromptHint` that already include preText/postText
@@ -219,6 +248,42 @@ function resolveParameterHint(
219
248
  return withCustomText(data, composeCharacterFxHintFromConnections(effectId, targetNames, timing, mode))
220
249
  }
221
250
 
251
+ if (node.type === "character-motion") {
252
+ const raw = data.characterMotion
253
+ const motionId: string | string[] | undefined =
254
+ Array.isArray(raw)
255
+ ? raw.filter((s): s is string => typeof s === "string" && s.length > 0)
256
+ : (asStr(raw) || undefined)
257
+ const timing: CharacterMotionTiming = {
258
+ position: asStr(data.position) as CharacterMotionPosition | undefined,
259
+ pace: asStr(data.pace) as CharacterMotionPace | undefined,
260
+ }
261
+ if (!ctx) {
262
+ return withCustomText(data, composeCharacterMotionHintFromConnections(motionId, [], [], timing, mode))
263
+ }
264
+ const targetNames: string[] = []
265
+ const partnerNames: string[] = []
266
+ // Minor-age floor: ANY ref wired to target or partner that describes a
267
+ // minor drops every adultOnly move from the composed sequence.
268
+ let subjectMinor = false
269
+ for (const edge of ctx.edges) {
270
+ if (edge.target !== node.id) continue
271
+ if (edge.targetHandle !== "target" && edge.targetHandle !== "partner") continue
272
+ const src = ctx.nodes.find((n) => n.id === edge.source)
273
+ if (!src) continue
274
+ // Before the name check: an unnamed ref that describes a minor still floors.
275
+ if (!subjectMinor && extractCharacterRefMinor(src)) subjectMinor = true
276
+ const name = extractCharacterMotionRefName(src)
277
+ if (!name) continue
278
+ if (edge.targetHandle === "target") targetNames.push(name)
279
+ else partnerNames.push(name)
280
+ }
281
+ return withCustomText(
282
+ data,
283
+ composeCharacterMotionHintFromConnections(motionId, targetNames, partnerNames, timing, mode, { subjectMinor }),
284
+ )
285
+ }
286
+
222
287
  const base = resolveBaseHint(node.type, data, mode)
223
288
  if (base) return base
224
289
  // Pack-extend fallback: a single-dim pack entry the per-catalog getter (which
@@ -29,6 +29,7 @@ import { ACTION_FX } from "./action-fx.js"
29
29
  import { LOOP_SUBJECTS } from "./loop-subject.js"
30
30
  import { TRANSITIONS } from "./transitions.js"
31
31
  import { CHARACTER_FX } from "./character-fx.js"
32
+ import { CHARACTER_MOTIONS } from "./character-motion.js"
32
33
  import { POSES } from "./pose.js"
33
34
  import { MATERIALS } from "./materials.js"
34
35
  import { HELD_PROPS } from "./held-prop.js"
@@ -183,6 +184,7 @@ export const PICKER_ANALYZER_REGISTRY = {
183
184
  "loop-subject": { kind: "flat", toolName: "emit_loop_subject", field: "loopSubject", label: "Loop Subject", entries: LOOP_SUBJECTS as ReadonlyArray<AnalyzerEntry> },
184
185
  transition: { kind: "flat", toolName: "emit_transition", field: "transition", label: "Transition", entries: TRANSITIONS as ReadonlyArray<AnalyzerEntry> },
185
186
  "character-fx": { kind: "flat", toolName: "emit_character_fx", field: "characterFx", label: "Character FX", entries: CHARACTER_FX as ReadonlyArray<AnalyzerEntry> },
187
+ "character-motion": { kind: "flat", toolName: "emit_character_motion", field: "characterMotion", label: "Character Motion", entries: CHARACTER_MOTIONS as ReadonlyArray<AnalyzerEntry> },
186
188
  pose: { kind: "flat", toolName: "emit_pose", field: "pose", label: "Pose", entries: POSES as ReadonlyArray<AnalyzerEntry> },
187
189
  material: { kind: "flat", toolName: "emit_material", field: "material", label: "Material", entries: MATERIALS as ReadonlyArray<AnalyzerEntry> },
188
190
  "held-prop": { kind: "flat", toolName: "emit_held_prop", field: "heldProp", label: "Held Prop", entries: HELD_PROPS as ReadonlyArray<AnalyzerEntry> },
@@ -301,18 +303,20 @@ export type PickerType = keyof typeof PICKER_ANALYZER_REGISTRY
301
303
  export const PICKER_TYPES = Object.keys(PICKER_ANALYZER_REGISTRY) as PickerType[]
302
304
 
303
305
  /**
304
- * Family grouping for BATCHED analysis. A single call across all 38 catalogs
305
- * carries a ~211k-char legend (~53k tokens — measured 2026-08-09, the
306
+ * Family grouping for BATCHED analysis. A single call across the first 38
307
+ * catalogs carried a ~211k-char legend (~53k tokens — measured 2026-08-09, the
306
308
  * measure-first probe from the text-to-picker spec), which is slow, costly,
307
309
  * and dilutes per-section accuracy. The text-to-picker route fans out one
308
- * structured call per family (6-15k tokens each) and merges. Mirrors the
309
- * build-brief's §5 UI grouping so Cine can reuse the same partition.
310
+ * structured call per family (6-15k tokens each) and merges. The character
311
+ * family is the exception: Character Motion's 1005-id legend alone is ~82k
312
+ * chars (~46k input tokens measured 2026-09-15), making it the heaviest call.
313
+ * Mirrors the build-brief's §5 UI grouping so Cine can reuse the same partition.
310
314
  */
311
315
  export const PICKER_ANALYZER_FAMILIES: Readonly<Record<string, ReadonlyArray<PickerType>>> = {
312
316
  scene: ["setting", "atmosphere", "backdrop", "era", "temporal"],
313
317
  look: ["style", "color-look", "mood", "aesthetic", "photographer", "photo-genre", "render-quality", "composition-effects", "post-process-effects"],
314
318
  camera: ["framing", "camera-motion", "lens", "camera-format", "lighting", "exposure-settings"],
315
- character: ["person", "styling", "pose", "character-fx"],
319
+ character: ["person", "styling", "pose", "character-fx", "character-motion"],
316
320
  elements: ["animal", "vehicle", "weapon", "furniture", "held-prop", "material", "action-fx", "loop-subject", "transition"],
317
321
  audio: ["music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery"],
318
322
  }
@@ -67,6 +67,13 @@ import {
67
67
  CHARACTER_FX_DURATIONS,
68
68
  CHARACTER_FX_INTENSITIES,
69
69
  } from "./character-fx.js"
70
+ import {
71
+ CHARACTER_MOTIONS,
72
+ CHARACTER_MOTION_CATEGORY_LABELS,
73
+ CHARACTER_MOTION_CATEGORY_ORDER,
74
+ CHARACTER_MOTION_POSITIONS,
75
+ CHARACTER_MOTION_PACES,
76
+ } from "./character-motion.js"
70
77
  import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
71
78
  import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
72
79
  import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER, getAnimalPromptHint } from "@nodaro/shared"
@@ -588,6 +595,23 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
588
595
  ["intensity", CHARACTER_FX_INTENSITIES],
589
596
  ]),
590
597
  },
598
+ {
599
+ nodeType: "character-motion",
600
+ label: "Character Motion",
601
+ catalogId: "character-motion",
602
+ kind: "single",
603
+ valueField: "characterMotion",
604
+ defaultValue: "auto",
605
+ categoryOrder: CHARACTER_MOTION_CATEGORY_ORDER,
606
+ categoryLabels: CHARACTER_MOTION_CATEGORY_LABELS,
607
+ options: toOptions(CHARACTER_MOTIONS, "category"),
608
+ // Position + Pace, this node's own wording (a movement begins / plays out;
609
+ // an effect manifests; a transition occurs) — never the sibling rows.
610
+ dimensions: perFieldDims([
611
+ ["position", CHARACTER_MOTION_POSITIONS],
612
+ ["pace", CHARACTER_MOTION_PACES],
613
+ ]),
614
+ },
591
615
 
592
616
  // -------- "Subject / Object" family --------
593
617
  {
@@ -51,6 +51,7 @@ import { ACTION_FX, ACTION_FX_CATEGORY_LABELS, ACTION_FX_CATEGORY_ORDER } from "
51
51
  import { LOOP_SUBJECTS, LOOP_SUBJECT_CATEGORY_LABELS, LOOP_SUBJECT_CATEGORY_ORDER } from "./loop-subject.js"
52
52
  import { TRANSITIONS, TRANSITION_CATEGORY_LABELS, TRANSITION_CATEGORY_ORDER } from "./transitions.js"
53
53
  import { CHARACTER_FX, CHARACTER_FX_CATEGORY_LABELS, CHARACTER_FX_CATEGORY_ORDER } from "./character-fx.js"
54
+ import { CHARACTER_MOTIONS, CHARACTER_MOTION_CATEGORY_LABELS, CHARACTER_MOTION_CATEGORY_ORDER } from "./character-motion.js"
54
55
  import { FRAMINGS, FRAMING_CATEGORY_ORDER, FRAMING_FIELD_BY_CATEGORY } from "./framing.js"
55
56
  import { LIGHTINGS, LIGHTING_CATEGORY_ORDER, LIGHTING_FIELD_BY_CATEGORY } from "./lighting.js"
56
57
  import { STYLINGS, STYLING_DIMENSION_ORDER, STYLING_FIELD_BY_DIMENSION } from "./styling.js"
@@ -159,6 +160,7 @@ export const SINGLE_PICKER_WIRING: ReadonlyArray<SingleDimPickerWiring> = [
159
160
  { kind: "single", nodeType: "camera-format", label: "Camera / Film", valueField: "cameraFormat", defaultValue: "35mm-film", catalogId: "camera-format", entries: mapCat(CAMERA_FORMATS) },
160
161
  { kind: "single", nodeType: "transition", label: "Transition", valueField: "transition", defaultValue: "auto", catalogId: "transitions", entries: mapCat(TRANSITIONS, "category"), groupOrder: TRANSITION_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: TRANSITION_CATEGORY_LABELS },
161
162
  { kind: "single", nodeType: "character-fx", label: "Character FX", valueField: "characterFx", defaultValue: "auto", catalogId: "character-fx", entries: mapCat(CHARACTER_FX, "category"), groupOrder: CHARACTER_FX_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: CHARACTER_FX_CATEGORY_LABELS },
163
+ { kind: "single", nodeType: "character-motion", label: "Character Motion", valueField: "characterMotion", defaultValue: "auto", catalogId: "character-motion", entries: mapCat(CHARACTER_MOTIONS, "category"), groupOrder: CHARACTER_MOTION_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: CHARACTER_MOTION_CATEGORY_LABELS },
162
164
 
163
165
  // -------- "Subject / Object" family --------
164
166
  { kind: "single", nodeType: "pose", label: "Pose", valueField: "pose", defaultValue: "standing-upright", catalogId: "pose", entries: mapCat(POSES, "category"), groupOrder: POSE_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: POSE_CATEGORY_LABELS },
@@ -196,7 +196,6 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
196
196
  "gpt-image-2-5-sunburst-i2i": "Precision GPT Image 2.5 edits — up to 16 sources, tightest control on demanding retouches; slower",
197
197
  "ideogram-edit": "Instruction-based editing with text preservation",
198
198
  "ideogram-remix": "Style remixing while preserving structure",
199
- "ideogram-reframe": "Aspect ratio changes with AI fill",
200
199
  "qwen-i2i": "Versatile transformation",
201
200
  "qwen-edit": "Instruction-based editing",
202
201
  "seedream-edit": "Artistic style editing",
@@ -221,7 +220,6 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
221
220
  "gpt-image-2-5-sunburst-i2i": "Precision GPT Image 2.5 edits — up to 16 sources, tightest control on demanding retouches; slower",
222
221
  "ideogram-edit": "Instruction-based editing with text preservation",
223
222
  "ideogram-remix": "Style remixing while preserving structure",
224
- "ideogram-reframe": "Aspect ratio changes with AI fill",
225
223
  "qwen-i2i": "Versatile transformation",
226
224
  "qwen-edit": "Instruction-based editing",
227
225
  "seedream-edit": "Artistic style editing",
@@ -227,7 +227,7 @@ Example: "Medium shot, a tired corporate worker, rubbing his temples in exhausti
227
227
 
228
228
  **Frames & references**
229
229
  - Start + end frame: wire both (imageUrls [start, end]) and describe the camera path between them — "a smooth 180-degree arc shot, starting front-facing and circling to end on the POV from behind her".
230
- - Reference images (ingredients): attach character/object/scene refs and name them in the prompt ("using the provided images for the detective and the office, …"). Reference runs DO generate audio.
230
+ - Reference images (ingredients) — **Fast (veo3.1) and Lite (veo3_lite) only**; the Quality SKU (veo3) has no reference-to-video mode at the provider, so wire it with frames alone. Where they are available: attach character/object/scene refs and name them in the prompt ("using the provided images for the detective and the office, …"). Reference runs DO generate audio.
231
231
 
232
232
  **Constraints**
233
233
  - Negative prompting works by positive description: write "a desolate landscape with no buildings or roads", not "no buildings".