@nodaro/prompts 1.19.0 → 1.21.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 (55) hide show
  1. package/dist/index.cjs +11175 -1797
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +280 -41
  4. package/dist/index.d.ts +280 -41
  5. package/dist/index.js +10049 -686
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +26 -1
  9. package/src/__tests__/character-motion-audit.test.ts +77 -0
  10. package/src/__tests__/character-motion-docs-example.test.ts +26 -0
  11. package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
  12. package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
  13. package/src/__tests__/character-motion-timing-catalogs.test.ts +76 -0
  14. package/src/__tests__/character-motion.test.ts +214 -0
  15. package/src/__tests__/fixtures/parameter-hint-golden.json +136 -0
  16. package/src/__tests__/frame-delivery.test.ts +93 -0
  17. package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
  18. package/src/__tests__/parameter-hint-mode.test.ts +28 -0
  19. package/src/__tests__/parameter-prompt-hint.test.ts +165 -0
  20. package/src/__tests__/parameter-registry-sync.test.ts +2 -0
  21. package/src/__tests__/picker-analyzer-registry.test.ts +1 -1
  22. package/src/__tests__/transition-timing-catalogs.test.ts +1 -1
  23. package/src/__tests__/video-reference-features.test.ts +10 -1
  24. package/src/age-floor.ts +1 -1
  25. package/src/character-motion/animals-pets.ts +116 -0
  26. package/src/character-motion/athletic-stunts.ts +159 -0
  27. package/src/character-motion/camera-interaction.ts +96 -0
  28. package/src/character-motion/combat-weapons.ts +147 -0
  29. package/src/character-motion/dance.ts +125 -0
  30. package/src/character-motion/entrances-exits.ts +92 -0
  31. package/src/character-motion/evasive-falls.ts +92 -0
  32. package/src/character-motion/everyday-actions.ts +237 -0
  33. package/src/character-motion/face-expression.ts +129 -0
  34. package/src/character-motion/gestures.ts +215 -0
  35. package/src/character-motion/head-gestures.ts +70 -0
  36. package/src/character-motion/idle-ambient.ts +89 -0
  37. package/src/character-motion/posture-shifts.ts +120 -0
  38. package/src/character-motion/runway.ts +77 -0
  39. package/src/character-motion/stage-performance.ts +96 -0
  40. package/src/character-motion/turns-looks.ts +90 -0
  41. package/src/character-motion/two-person.ts +245 -0
  42. package/src/character-motion/types.ts +45 -0
  43. package/src/character-motion/unnatural-horror.ts +57 -0
  44. package/src/character-motion/vehicles-mounts.ts +105 -0
  45. package/src/character-motion/walks-runs.ts +97 -0
  46. package/src/character-motion-diagnostics.ts +49 -0
  47. package/src/character-motion.ts +362 -0
  48. package/src/frame-delivery.ts +106 -0
  49. package/src/index.ts +5 -0
  50. package/src/parameter-prompt-hint.ts +71 -0
  51. package/src/picker-analyzer-registry.ts +9 -5
  52. package/src/picker-catalogs.ts +33 -1
  53. package/src/picker-wiring.ts +2 -0
  54. package/src/prompt-wizard-categories.ts +0 -2
  55. package/src/provider-prompt-doctrine.ts +1 -1
@@ -0,0 +1,97 @@
1
+ import type { CharacterMotion } from "./types.js"
2
+
3
+ /** walks-runs — authored motion fragments. Stable ids survive copy revisions. */
4
+ export const WALKS_RUNS_MOTIONS: ReadonlyArray<CharacterMotion> = [
5
+ { "id": "stroll-casually", "label": "Casual stroll", "category": "walks-runs", "description": "Relaxed unhurried walk, loose arms, easy steps",
6
+ "promptHint": "the subject strolls forward with short relaxed steps, weight rolling easily heel to toe, arms swinging loosely at the sides, shoulders down and head drifting to look around", "term": "strolls casually, arms loose" },
7
+ { "id": "walk-briskly", "label": "Brisk walk", "category": "walks-runs", "description": "Purposeful clipped steps, arms swinging",
8
+ "promptHint": "the subject walks briskly with clipped purposeful steps, heels striking firmly, arms swinging in a tight rhythm, torso upright and eyes fixed on a point ahead", "term": "walks briskly with purpose" },
9
+ { "id": "power-walk", "label": "Power walk", "category": "walks-runs", "description": "Fitness walk, elbows bent, arms pumping hard",
10
+ "promptHint": "the subject power-walks with elbows bent at ninety degrees and arms pumping hard, pelvis driving each long stride, heels planting and toes pushing off with visible effort", "term": "power-walks, arms pumping" },
11
+ { "id": "stride-confidently", "label": "Confident stride", "category": "walks-runs", "description": "Long even strides, chest up, shoulders back",
12
+ "promptHint": "the subject strides forward with long even steps, chest lifted and shoulders back, arms swinging in a full relaxed arc, each foot landing squarely with the weight committed", "term": "strides forward confidently" },
13
+ { "id": "jog-forward", "label": "Jog", "category": "walks-runs", "description": "Light steady run, elbows bent, gentle bounce",
14
+ "promptHint": "the subject jogs forward, elbows bent and hands loose near the waist, short strides landing midfoot, a gentle bounce in each step and breathing even", "term": "jogs forward, elbows bent" },
15
+ { "id": "sprint-flat-out", "label": "Sprint", "category": "walks-runs", "description": "All-out run, knees driving, arms pumping",
16
+ "promptHint": "the subject sprints flat out, torso leaning forward, knees driving high, arms pumping in sharp opposition, feet striking on the balls and pushing off hard behind", "term": "sprints flat out" },
17
+ { "id": "tiptoe-forward", "label": "Tiptoe", "category": "walks-runs", "description": "Heels lifted, small careful steps on the toes",
18
+ "promptHint": "the subject rises onto the balls of the feet and tiptoes forward with heels lifted, small careful steps, arms held slightly out for balance and shoulders raised", "term": "tiptoes forward, heels lifted" },
19
+ { "id": "sneak-forward", "label": "Sneak", "category": "walks-runs", "description": "Low crouch, soft toe-first steps, eyes scanning",
20
+ "promptHint": "the subject sneaks forward in a low crouch, knees bent, placing each foot down softly toe first, arms drawn in close to the body, head turning to check both sides", "term": "sneaks forward in a crouch" },
21
+ { "id": "stagger-unsteadily", "label": "Stagger", "category": "walks-runs", "description": "Weaving unsteady walk, arms out for balance",
22
+ "promptHint": "the subject staggers forward unsteadily, weight lurching from one foot to the other, path weaving side to side, arms floating out to catch balance and head lolling", "term": "staggers unsteadily, weaving" },
23
+ { "id": "limp-forward", "label": "Limp", "category": "walks-runs", "description": "Favoring one leg, uneven dipping gait",
24
+ "promptHint": "the subject limps forward favoring one leg, the stiff leg taking a short guarded step while the other swings through, the body dipping on each weighted step", "term": "limps, favoring one leg" },
25
+ { "id": "march-in-cadence", "label": "March", "category": "walks-runs", "description": "Military cadence, high knees, stiff arm swing",
26
+ "promptHint": "the subject marches forward in strict military cadence, knees lifting high, legs snapping straight, arms swinging stiffly shoulder-high in opposition, spine rigid and chin level", "term": "marches in military cadence" },
27
+ { "id": "walk-backwards", "label": "Walk backwards", "category": "walks-runs", "description": "Stepping backward, glancing over one shoulder",
28
+ "promptHint": "the subject walks backwards, each foot reaching behind and landing toe first before the heel settles, weight shifting carefully, glancing over one shoulder to check the path", "term": "walks backwards" },
29
+ { "id": "skip-along", "label": "Skip", "category": "walks-runs", "description": "Step-hop bounce on alternating feet",
30
+ "promptHint": "the subject skips forward in a step-hop rhythm, alternating feet, bouncing up off each hop with arms swinging freely and a light springy lift through the whole body", "term": "skips along" },
31
+ { "id": "hop-on-one-foot", "label": "Hop", "category": "walks-runs", "description": "Bounding forward on one foot",
32
+ "promptHint": "the subject hops forward on one foot, the other leg bent and lifted, arms pumping with each push-off, landing and springing again in a chain of small bounds", "term": "hops on one foot" },
33
+ { "id": "crawl-on-all-fours", "label": "Crawl", "category": "walks-runs", "description": "Hands and knees, opposite limbs advancing",
34
+ "promptHint": "the subject crawls forward on hands and knees, opposite hand and knee advancing together, back level, head lifted to look ahead as the weight rocks from limb to limb", "term": "crawls on hands and knees" },
35
+ { "id": "army-crawl-forward", "label": "Army crawl", "category": "walks-runs", "description": "Flat on the belly, pulling forward on elbows",
36
+ "promptHint": "the subject drops flat and army-crawls forward on the belly, dragging the body along with alternating elbows and knees, head kept low and chest pressed to the ground", "term": "army-crawls on the belly" },
37
+ { "id": "wade-through-water", "label": "Wade", "category": "walks-runs", "description": "High deliberate steps against the water's drag",
38
+ "promptHint": "the subject wades forward through the water, lifting each knee high and pushing it through against the drag, arms raised clear of the surface, torso leaning slightly into each step as the weight commits late", "term": "wades through water" },
39
+ { "id": "shuffle-forward", "label": "Shuffle", "category": "walks-runs", "description": "Feet barely leaving the ground, slumped",
40
+ "promptHint": "the subject shuffles forward with feet barely leaving the ground, short scuffing steps, shoulders slumped, arms hanging still and the head bowed", "term": "shuffles, feet scuffing the ground" },
41
+ { "id": "scurry-forward", "label": "Scurry", "category": "walks-runs", "description": "Hurried tiny steps, hunched and leaning ahead",
42
+ "promptHint": "the subject scurries forward in hurried tiny steps, shoulders hunched, elbows tucked, upper body leaning ahead of the feet as if late, eyes darting", "term": "scurries with tiny hunched steps" },
43
+ { "id": "stroll-hands-in-pockets", "label": "Hands in pockets stroll", "category": "walks-runs", "description": "Elbows out, loose rolling steps, slight forward lean",
44
+ "promptHint": "the subject strolls forward with both hands tucked into pockets, elbows angled out, shoulders relaxed, loose rolling steps and a slight forward lean of the head", "term": "strolls with hands in pockets" },
45
+ { "id": "walk-while-talking", "label": "Walk & talk", "category": "walks-runs", "description": "Steady walk with hands gesturing mid-speech",
46
+ "promptHint": "the subject walks while talking, steps steady and unbroken, mouth moving in speech, hands gesturing in time with the words, head turning aside mid-sentence and back to the path ahead", "term": "walks and talks, gesturing" },
47
+ { "id": "trudge-wearily", "label": "Trudge", "category": "walks-runs", "description": "Heavy weary steps, head down, feet dragging",
48
+ "promptHint": "the subject trudges forward with heavy weary steps, feet dragging and landing flat, shoulders sagging under the effort, head down and arms hanging limp at the sides", "term": "trudges wearily" },
49
+ { "id": "prance-forward", "label": "Prance", "category": "walks-runs", "description": "Springy high-knee steps, toes pointed",
50
+ "promptHint": "the subject prances forward with springy high-knee steps, toes pointed on each lift, chest up and arms held out lightly, bouncing playfully off the balls of the feet", "term": "prances with high knees" },
51
+ { "id": "strut-boldly", "label": "Strut", "category": "walks-runs", "description": "Chest out, exaggerated arm swing, bold stamps",
52
+ "promptHint": "the subject struts forward with chest pushed out, chin raised, exaggerated arm swing and long bold steps that land with a deliberate stamp, shoulders squared", "term": "struts, chest out" },
53
+ { "id": "swagger-forward", "label": "Swagger", "category": "walks-runs", "description": "Wide lazy stride, shoulders rolling",
54
+ "promptHint": "the subject swaggers forward with a wide lazy stride, shoulders rolling side to side with each step, arms swinging loose and low, weight sinking into every landing", "term": "swaggers, shoulders rolling" },
55
+ { "id": "sashay-forward", "label": "Sashay", "category": "walks-runs", "description": "Hip-swaying walk, feet crossing on one line",
56
+ "promptHint": "the subject sashays forward with hips swaying side to side, each foot crossing in front of the other along a single line, shoulders back and one hand trailing at the hip", "term": "sashays with swaying hips" },
57
+ { "id": "run-in-panic", "label": "Panicked run", "category": "walks-runs", "description": "Frantic escape run, glancing back",
58
+ "promptHint": "the subject runs in panic, arms flailing loosely, strides uneven and frantic, torso twisting to glance back over a shoulder before facing forward and pushing harder", "term": "runs in panic, glancing back" },
59
+ { "id": "run-crouched", "label": "Crouched run", "category": "walks-runs", "description": "Low tactical run, knees bent, head ducked",
60
+ "promptHint": "the subject runs low in a crouch, knees deeply bent and torso folded forward, head ducked, arms tucked in, feet moving in short driving steps that keep the body down", "term": "runs low in a crouch" },
61
+ { "id": "climb-stairs", "label": "Climb stairs", "category": "walks-runs", "description": "Knee lifts to each step, hand sliding up the rail",
62
+ "promptHint": "the subject climbs a flight of stairs, lifting each knee to plant the whole foot on the next step, weight driving up through the leading leg, one hand sliding along the rail and torso tipped slightly forward", "term": "climbs the stairs" },
63
+ { "id": "descend-stairs", "label": "Descend stairs", "category": "walks-runs", "description": "Toe-first steps down, weight lowered under control",
64
+ "promptHint": "the subject descends a flight of stairs, each foot reaching down to the next step toe first before the heel settles, weight lowering under control, chin level and one hand trailing on the rail", "term": "descends the stairs" },
65
+ { "id": "push-through-crowd", "label": "Push through crowd", "category": "walks-runs", "description": "Shoulder wedged forward, forearm parting the way",
66
+ "promptHint": "the subject pushes through the crowd, turning one shoulder forward to wedge between bodies, forearm raised to part the way, pelvis twisting sideways and short driving steps forcing a path ahead", "term": "shoulders through the crowd" },
67
+ { "id": "sidle-along-wall", "label": "Sidle along wall", "category": "walks-runs", "description": "Back and palms flat to the wall, side-stepping",
68
+ "promptHint": "the subject sidles along the wall with back and palms pressed flat against it, stepping sideways one foot after the other, knees soft, head turned to watch the direction of travel", "term": "sidles along the wall, back pressed" },
69
+ { "id": "stomp-off-angrily", "label": "Stomp off", "category": "walks-runs", "description": "Storms away, flat heavy stamps, fists clenched",
70
+ "promptHint": "the subject turns sharply and stomps off, each foot slamming down flat and heavy, fists clenched and arms swinging stiffly, shoulders squared and head held rigidly forward without looking back", "term": "stomps off, fists clenched" },
71
+ { "id": "zombie-shamble", "label": "Zombie shamble", "category": "walks-runs", "description": "Stiff outstretched arms, pitched torso, one foot dragging",
72
+ "promptHint": "the subject shambles forward with both arms held stiffly outstretched, torso pitched forward off balance, one leg dragging behind as the other lurches ahead, head hanging to one side and jaw slack", "term": "shambles, arms stiffly outstretched" },
73
+ { "id": "sleepwalk-forward", "label": "Sleepwalk", "category": "walks-runs", "description": "Eyes half-closed, arms out ahead, flat-footed drift",
74
+ "promptHint": "the subject sleepwalks forward with eyes half-closed and face slack, both arms extended straight ahead, flat-footed steps drifting in a straight line, the body upright and not reacting to anything around it", "term": "sleepwalks, arms held out ahead" },
75
+ { "id": "run-in-circles", "label": "Run in circles", "category": "walks-runs", "description": "Tight looping run, body leaning into the turn",
76
+ "promptHint": "the subject runs in tight circles, body leaning into the turn, inside foot pivoting on each stride while the outside leg pushes wide, arms held out and head whipping around to keep looking ahead", "term": "runs in tight circles" },
77
+ { "id": "brace-against-wind", "label": "Brace against wind", "category": "walks-runs", "description": "Torso pitched forward, forearm shielding the face",
78
+ "promptHint": "the subject leans hard into the wind, torso pitched forward with one shoulder dropped, forearm raised to shield the face, feet planting wide in short braced steps that fight for every stride", "term": "leans into the wind, forearm up" },
79
+ { "id": "walk-with-cane", "label": "Walk with cane", "category": "walks-runs", "description": "Cane plants in time with the opposite foot",
80
+ "promptHint": "the subject walks forward with a cane, planting its tip ahead in time with the opposite foot, weight leaning briefly onto the cane hand as the other leg swings through, steps short and even", "term": "walks with a cane" },
81
+ { "id": "roll-forward-in-wheelchair", "label": "Wheelchair roll", "category": "walks-runs", "description": "Both hands drive the push rims in matched strokes",
82
+ "promptHint": "the subject rolls forward in a wheelchair, both hands gripping the push rims and driving them forward in matched strokes, shoulders rounding on each push and releasing on the return, chin up and looking ahead", "term": "rolls forward in a wheelchair" },
83
+ { "id": "pull-rolling-suitcase", "label": "Pull rolling suitcase", "category": "walks-runs", "description": "One arm trailing back on the handle, case on its wheels",
84
+ "promptHint": "the subject walks forward pulling a rolling suitcase, one arm trailing straight back to the extended handle, the case tilted onto its wheels behind, torso angled slightly toward the free side and the other arm swinging", "term": "walks pulling a rolling suitcase" },
85
+ { "id": "step-left", "label": "Step to subject’s left", "category": "walks-runs", "description": "Step to subject’s left.",
86
+ "term": "takes one step to their own left", "promptHint": "the subject takes one lateral step to the subject’s left and stops",
87
+ "startPose": "standing", "endPose": "standing", "aliases": ["sidestep left"] },
88
+ { "id": "step-right", "label": "Step to subject’s right", "category": "walks-runs", "description": "Step to subject’s right.",
89
+ "term": "takes one step to their own right", "promptHint": "the subject takes one lateral step to the subject’s right and stops",
90
+ "startPose": "standing", "endPose": "standing", "aliases": ["sidestep right"] },
91
+ { "id": "walk-with-crutches", "label": "Walk with crutches", "category": "walks-runs", "description": "Walk with crutches.",
92
+ "term": "advances with a pair of crutches", "promptHint": "the subject places both crutches ahead, shifts weight through the handles and advances using the established supported gait",
93
+ "requires": ["crutches"], "startPose": "standing", "endPose": "standing", "aliases": ["mobility aid"] },
94
+ { "id": "walk-with-walker", "label": "Walk with walker", "category": "walks-runs", "description": "Walk with walker.",
95
+ "term": "advances with a walker", "promptHint": "the subject moves the walker a short distance ahead and takes small supported steps into it, repeating steadily",
96
+ "requires": ["walker"], "startPose": "standing", "endPose": "standing", "aliases": ["mobility aid"] },
97
+ ]
@@ -0,0 +1,49 @@
1
+ import { getCharacterMotion, CHARACTER_MOTION_MAX_PICKS, type CharacterMotionTiming } from "./character-motion.js"
2
+
3
+ export interface CharacterMotionDiagnostic {
4
+ readonly code: "unknown" | "omitted" | "retired" | "pose" | "visibility" | "hands" | "pace" | "requirements" | "compound" | "capacity" | "multiple-targets" | "roles"
5
+ readonly severity: "error" | "warning" | "info"
6
+ readonly ids: readonly string[]
7
+ readonly message: string
8
+ }
9
+ export interface CharacterMotionBindings {
10
+ readonly selfPairing?: boolean
11
+ readonly subjectMinor?: boolean
12
+ readonly partnerNames?: readonly string[]
13
+ readonly targetNames?: readonly string[]
14
+ }
15
+
16
+ /** Shared by editor, API clients and local review. Missing metadata means
17
+ * unknown; this never certifies a sequence or predicts clip duration. */
18
+ export function getCharacterMotionDiagnostics(
19
+ value: string | readonly string[] | null | undefined,
20
+ timing?: CharacterMotionTiming,
21
+ bindings: CharacterMotionBindings = {},
22
+ ): readonly CharacterMotionDiagnostic[] {
23
+ const requested = typeof value === "string" ? [value] : Array.isArray(value) ? [...new Set(value)] : []
24
+ const result: CharacterMotionDiagnostic[] = []
25
+ const add = (code: CharacterMotionDiagnostic["code"], severity: CharacterMotionDiagnostic["severity"], ids: readonly string[], message: string) => result.push({ code, severity, ids, message })
26
+ if (requested.length > CHARACTER_MOTION_MAX_PICKS) add("capacity", "warning", requested, "Only the first three selections contribute. Remove extra selections.")
27
+ const picks = requested.slice(0, CHARACTER_MOTION_MAX_PICKS)
28
+ for (const id of picks) if (!getCharacterMotion(id)) add("unknown", "error", [id], `Motion “${id}” is unavailable and contributes no fragment. Remove or replace it.`)
29
+ const entries = picks.flatMap(id => { const entry = getCharacterMotion(id); return entry?.promptHint ? [entry] : [] })
30
+ const active = entries.filter(e => !bindings.subjectMinor || !e.adultOnly)
31
+ if (bindings.selfPairing && active.some(e => e.twoPerson || e.counterpart)) add("roles", "error", active.filter(e => e.twoPerson || e.counterpart).map(e => e.id), "The same reference is connected as Target and Partner. Connect distinct participants before running this interaction.")
32
+ const omitted = entries.filter(e => bindings.subjectMinor && e.adultOnly)
33
+ if (omitted.length) add("omitted", active.length ? "warning" : "error", omitted.map(e => e.id), `${omitted.map(e => e.label).join(", ")} omitted for a connected minor. ${active.length ? "Replace these selections." : "No selected motion contributes a fragment."}`)
34
+ if ((bindings.targetNames?.length ?? 0) > 1) add("multiple-targets", "warning", picks, "Each target performs a separate copy of the sequence. Use separate motion nodes for coordinated choreography.")
35
+ for (const entry of active) {
36
+ if (entry.deprecated) add("retired", "warning", [entry.id], `${entry.label} is retired from new choices but still resolves in saved workflows.${entry.replacementId ? ` Use ${getCharacterMotion(entry.replacementId)?.label ?? entry.replacementId}.` : ""}`)
37
+ if (entry.fixedPace && timing?.pace && timing.pace !== "auto") add("pace", "warning", [entry.id], `${entry.label} sets its own timing. Set Pace to Auto or replace the motion.`)
38
+ if (entry.requires?.length) add("requirements", "info", [entry.id], `${entry.label} requires: ${entry.requires.join(", ")}.${entry.twoPerson || entry.counterpart ? bindings.partnerNames?.length ? ` Partner reference: ${bindings.partnerNames.join(", ")}; confirm it matches the required recipient.` : " Connect a Partner reference to identify the recipient; otherwise the model chooses it." : " Establish these in the scene or prompt; the fragment does not supply reference media."}`)
39
+ }
40
+ for (let i = 1; i < active.length; i++) {
41
+ const before = active[i - 1]!, after = active[i]!, ids = [before.id, after.id]
42
+ if (before.endVisibility === "out-of-frame") add("visibility", "warning", ids, `${before.label} ends out of view before ${after.label}. Reorder or add an entrance.`)
43
+ if (before.endPose && after.startPose && before.endPose !== "any" && after.startPose !== "any" && before.endPose !== after.startPose) add("pose", "warning", ids, `${before.label} ends ${before.endPose}; ${after.label} starts ${after.startPose}. Add a transition or replace a motion.`)
44
+ if (before.handsAfter && before.handsAfter !== "free" && after.needsFreeHands) add("hands", "warning", ids, `${before.label} leaves hands occupied; ${after.label} needs free hands. Add a release first.`)
45
+ }
46
+ const compound = active.filter(e => e.kind === "compound")
47
+ if (compound.length) add("compound", "warning", compound.map(e => e.id), `${compound.length} selection${compound.length === 1 ? " is" : "s are"} compound choreography. Short clips may not fit every phase; test the sequence before adding more actions.`)
48
+ return result
49
+ }
@@ -0,0 +1,362 @@
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 on every occurrence. With nothing
20
+ * wired to the partner handle, the fallback "another person" INTRODUCES the
21
+ * referent at its first occurrence in THAT TARGET's clauses and every later
22
+ * occurrence of that target reads "that same person" — a hint that names the
23
+ * partner five times still describes one person, and a sequence of picks keeps
24
+ * that one person throughout. Multiple wired targets each perform a separate
25
+ * copy of the sequence, so each copy introduces its own partner rather than
26
+ * sharing one between two performers. See `referenceTo` in the composer.
27
+ *
28
+ * Multi-pick is an ORDERED SEQUENCE: value field accepts `string | string[]`
29
+ * (cap 3), joined with ", then " in BOTH hint modes (a movement happens after
30
+ * the previous one; Character FX joins with ", and " because effects coincide).
31
+ *
32
+ * `adultOnly` marks the W1-a minor-age floor. The composer drops flagged ids
33
+ * when its caller reports a minor subject (`floor.subjectMinor`); the AI Fill
34
+ * strip drops them from analyzer output. Hand-curated; the `adult-only-ratchet`
35
+ * test only ratchets. Never set on neutral movement or the `auto` / `none` heads.
36
+ */
37
+
38
+ import { resolveTerm, type PickerHintMode } from "./term.js"
39
+ import { overlayEntry } from "./catalog-overlay.js"
40
+ export type { CharacterMotion, CharacterMotionCategory, CharacterMotionMetadata } from "./character-motion/types.js"
41
+ import type { CharacterMotion, CharacterMotionCategory } from "./character-motion/types.js"
42
+ import { ENTRANCES_EXITS_MOTIONS } from "./character-motion/entrances-exits.js"
43
+ import { TURNS_LOOKS_MOTIONS } from "./character-motion/turns-looks.js"
44
+ import { HEAD_GESTURES_MOTIONS } from "./character-motion/head-gestures.js"
45
+ import { WALKS_RUNS_MOTIONS } from "./character-motion/walks-runs.js"
46
+ import { RUNWAY_MOTIONS } from "./character-motion/runway.js"
47
+ import { DANCE_MOTIONS } from "./character-motion/dance.js"
48
+ import { FACE_EXPRESSION_MOTIONS } from "./character-motion/face-expression.js"
49
+ import { GESTURES_MOTIONS } from "./character-motion/gestures.js"
50
+ import { CAMERA_INTERACTION_MOTIONS } from "./character-motion/camera-interaction.js"
51
+ import { COMBAT_WEAPONS_MOTIONS } from "./character-motion/combat-weapons.js"
52
+ import { ATHLETIC_STUNTS_MOTIONS } from "./character-motion/athletic-stunts.js"
53
+ import { EVASIVE_FALLS_MOTIONS } from "./character-motion/evasive-falls.js"
54
+ import { POSTURE_SHIFTS_MOTIONS } from "./character-motion/posture-shifts.js"
55
+ import { EVERYDAY_ACTIONS_MOTIONS } from "./character-motion/everyday-actions.js"
56
+ import { VEHICLES_MOUNTS_MOTIONS } from "./character-motion/vehicles-mounts.js"
57
+ import { ANIMALS_PETS_MOTIONS } from "./character-motion/animals-pets.js"
58
+ import { TWO_PERSON_MOTIONS } from "./character-motion/two-person.js"
59
+ import { IDLE_AMBIENT_MOTIONS } from "./character-motion/idle-ambient.js"
60
+ import { STAGE_PERFORMANCE_MOTIONS } from "./character-motion/stage-performance.js"
61
+ import { UNNATURAL_HORROR_MOTIONS } from "./character-motion/unnatural-horror.js"
62
+
63
+ export const CHARACTER_MOTION_CATEGORY_ORDER: ReadonlyArray<CharacterMotionCategory> = [
64
+ "entrances-exits",
65
+ "turns-looks",
66
+ "head-gestures",
67
+ "walks-runs",
68
+ "runway",
69
+ "dance",
70
+ "face-expression",
71
+ "gestures",
72
+ "camera-interaction",
73
+ "combat-weapons",
74
+ "athletic-stunts",
75
+ "evasive-falls",
76
+ "posture-shifts",
77
+ "everyday-actions",
78
+ "vehicles-mounts",
79
+ "animals-pets",
80
+ "two-person",
81
+ "idle-ambient",
82
+ "stage-performance",
83
+ "unnatural-horror",
84
+ ] as const
85
+
86
+ export const CHARACTER_MOTION_CATEGORY_LABELS: Readonly<Record<CharacterMotionCategory, string>> = {
87
+ "entrances-exits": "Entrances & Exits",
88
+ "turns-looks": "Turns & Looks",
89
+ "head-gestures": "Head Gestures",
90
+ "walks-runs": "Walks & Runs",
91
+ "runway": "Runway & Model",
92
+ "dance": "Dance",
93
+ "face-expression": "Face & Expression",
94
+ "gestures": "Gestures",
95
+ "camera-interaction": "Camera Interaction",
96
+ "combat-weapons": "Combat & Weapons",
97
+ "athletic-stunts": "Athletic & Stunts",
98
+ "evasive-falls": "Evasive & Falls",
99
+ "posture-shifts": "Posture Shifts",
100
+ "everyday-actions": "Everyday Actions",
101
+ "vehicles-mounts": "Vehicles & Mounts",
102
+ "animals-pets": "Animals & Pets",
103
+ "two-person": "Two-Person",
104
+ "idle-ambient": "Idle & Ambient",
105
+ "stage-performance": "Stage & Performance",
106
+ "unnatural-horror": "Unnatural & Horror",
107
+ } as const
108
+
109
+ /**
110
+ * The catalog: two no-op heads + every category array, in category order.
111
+ * Authored injecting entries across 20 categories.
112
+ */
113
+ export const CHARACTER_MOTIONS: ReadonlyArray<CharacterMotion> = [
114
+ // Defaults — both inject nothing (empty promptHint ⇒ empty term).
115
+ { id: "auto", label: "Auto", category: "entrances-exits", description: "Let the model choose the movement", promptHint: "" },
116
+ { id: "none", label: "None", category: "entrances-exits", description: "No scripted character movement", promptHint: "" },
117
+ ...ENTRANCES_EXITS_MOTIONS,
118
+ ...TURNS_LOOKS_MOTIONS,
119
+ ...HEAD_GESTURES_MOTIONS,
120
+ ...WALKS_RUNS_MOTIONS,
121
+ ...RUNWAY_MOTIONS,
122
+ ...DANCE_MOTIONS,
123
+ ...FACE_EXPRESSION_MOTIONS,
124
+ ...GESTURES_MOTIONS,
125
+ ...CAMERA_INTERACTION_MOTIONS,
126
+ ...COMBAT_WEAPONS_MOTIONS,
127
+ ...ATHLETIC_STUNTS_MOTIONS,
128
+ ...EVASIVE_FALLS_MOTIONS,
129
+ ...POSTURE_SHIFTS_MOTIONS,
130
+ ...EVERYDAY_ACTIONS_MOTIONS,
131
+ ...VEHICLES_MOUNTS_MOTIONS,
132
+ ...ANIMALS_PETS_MOTIONS,
133
+ ...TWO_PERSON_MOTIONS,
134
+ ...IDLE_AMBIENT_MOTIONS,
135
+ ...STAGE_PERFORMANCE_MOTIONS,
136
+ ...UNNATURAL_HORROR_MOTIONS,
137
+ ]
138
+
139
+ const characterMotionById = new Map<string, CharacterMotion>(
140
+ CHARACTER_MOTIONS.map((m) => [m.id, m]),
141
+ )
142
+
143
+ export function getCharacterMotion(id: string | undefined | null): CharacterMotion | undefined {
144
+ if (!id) return undefined
145
+ return overlayEntry("character-motion", id, characterMotionById.get(id))
146
+ }
147
+
148
+ export function getCharacterMotionLabel(id: string | undefined | null, fallback?: string): string {
149
+ const m = getCharacterMotion(id)
150
+ if (m) return m.label
151
+ if (fallback !== undefined) return fallback
152
+ return (id ?? "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
153
+ }
154
+
155
+ export function getCharacterMotionPromptHint(id: string | undefined | null): string {
156
+ return getCharacterMotion(id)?.promptHint ?? ""
157
+ }
158
+
159
+ /** Compact counterpart of `getCharacterMotionPromptHint` — same lookup, same
160
+ * empty-string-on-miss, and the no-op `auto` / `none` heads inject nothing. */
161
+ export function getCharacterMotionTerm(id: string | undefined | null): string {
162
+ return resolveTerm(getCharacterMotion(id))
163
+ }
164
+
165
+ export const CHARACTER_MOTION_IDS: ReadonlyArray<string> = CHARACTER_MOTIONS.map((m) => m.id)
166
+
167
+ /** Cap on ordered picks. Shared by the composer (`.slice(0, N)`), the picker
168
+ * (`maxSelected`) and the config panel — the three must agree. */
169
+ export const CHARACTER_MOTION_MAX_PICKS = 3
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Timing scales — Position + Pace. Own wording; NOT the character-fx or the
173
+ // transition rows (an effect manifests, a transition occurs, a MOVEMENT is
174
+ // performed). `POSITION_CLAUSES` / `PACE_CLAUSES` are DERIVED from these arrays
175
+ // so the clause the composer injects and the hint the catalog advertises are
176
+ // the same string by construction.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export interface CharacterMotionTimingOption {
180
+ readonly id: string
181
+ readonly label: string
182
+ readonly description: string
183
+ readonly promptHint: string
184
+ readonly term?: string
185
+ }
186
+
187
+ export const CHARACTER_MOTION_POSITIONS = [
188
+ { id: "auto", label: "Auto", description: "Let the model place the movement", promptHint: "", term: "" },
189
+ { 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" },
190
+ { id: "middle", label: "Middle", description: "Begins midway through the clip", promptHint: "the movement begins midway through the clip", term: "starting midway through the clip" },
191
+ { 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" },
192
+ { 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" },
193
+ ] as const satisfies ReadonlyArray<CharacterMotionTimingOption>
194
+
195
+ export const CHARACTER_MOTION_PACES = [
196
+ { id: "auto", label: "Auto", description: "Let the model set the tempo", promptHint: "", term: "" },
197
+ { 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" },
198
+ { 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" },
199
+ { 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" },
200
+ { 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" },
201
+ { 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" },
202
+ ] as const satisfies ReadonlyArray<CharacterMotionTimingOption>
203
+
204
+ export type CharacterMotionPosition = (typeof CHARACTER_MOTION_POSITIONS)[number]["id"]
205
+ export type CharacterMotionPace = (typeof CHARACTER_MOTION_PACES)[number]["id"]
206
+
207
+ export interface CharacterMotionTiming {
208
+ position?: CharacterMotionPosition
209
+ pace?: CharacterMotionPace
210
+ }
211
+
212
+ /** Private twin of the helper in character-fx.ts / transitions.ts — the
213
+ * catalogs must stay independent. Total over the array by construction. */
214
+ function clausesOf<T extends CharacterMotionTimingOption>(
215
+ options: ReadonlyArray<T>,
216
+ ): Record<Exclude<T["id"], "auto">, string> {
217
+ return Object.fromEntries(
218
+ options.filter((o) => o.id !== "auto").map((o) => [o.id, o.promptHint]),
219
+ ) as Record<Exclude<T["id"], "auto">, string>
220
+ }
221
+
222
+ const POSITION_CLAUSES = clausesOf(CHARACTER_MOTION_POSITIONS)
223
+ const PACE_CLAUSES = clausesOf(CHARACTER_MOTION_PACES)
224
+
225
+ /** Widening guard — `as const` on the two arrays is load-bearing (see character-fx.ts). */
226
+ type NarrowIds<T> = string extends T ? never : true
227
+ const _timingIdsStayNarrow: [NarrowIds<CharacterMotionPosition>, NarrowIds<CharacterMotionPace>] = [true, true]
228
+ void _timingIdsStayNarrow
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Graph-aware composer — target + partner handles, ordered multi-pick, timing
232
+ // ---------------------------------------------------------------------------
233
+
234
+ const PARTNER_FALLBACK = "another person"
235
+ /** Last-resort recipient for a `counterpart` token whose entry authored no noun. */
236
+ const COUNTERPART_FALLBACK = "the other participant"
237
+
238
+ /**
239
+ * How an unwired partner / counterpart reads on its SECOND and later mention.
240
+ *
241
+ * Only an INDEFINITE noun phrase is rewritten. "another person" and "a horse"
242
+ * introduce a fresh referent every time they repeat, so a hint that mentions
243
+ * the partner five times would otherwise describe up to five different people.
244
+ * A DEFINITE phrase — "the held object", "the ducks", "the other participant" —
245
+ * already picks out one referent on every repetition, so it repeats verbatim:
246
+ * rewriting it would buy nothing and would mangle a plural ("that same ducks").
247
+ * That exemption is invisible on today's data (every counterpart noun mentioned
248
+ * more than once is indefinite) and exists so a future definite or plural noun
249
+ * cannot produce broken English here.
250
+ *
251
+ * A pronoun is deliberately NOT the fix: with two actors in the clause,
252
+ * "they" / "their" can attach to either one.
253
+ */
254
+ function laterReferenceTo(phrase: string): string {
255
+ const indefinite = /^(?:another|an|a) (.+)$/.exec(phrase)
256
+ return indefinite ? `that same ${indefinite[1]}` : phrase
257
+ }
258
+
259
+ /** What the caller knows about the wired target. `subjectMinor: true` drops
260
+ * every `adultOnly` pick after the cap; otherwise the output is unchanged. */
261
+ export interface CharacterMotionFloor {
262
+ readonly subjectMinor?: boolean
263
+ }
264
+
265
+ /**
266
+ * Compose a character-motion prompt fragment from an ORDERED list of 1–3 move
267
+ * ids, the display names wired to the `target` and `partner` handles, and the
268
+ * optional Position / Pace timing.
269
+ *
270
+ * Full mode: each hint has "the subject" rewritten to the target name(s) (only
271
+ * when a target is wired) and "the partner" rewritten to the partner name, or —
272
+ * when nothing is wired to that handle — to "another person" on that target's
273
+ * first mention and "that same person" after; ALWAYS, so the literal words
274
+ * "the partner" never ship. Both BEFORE the ", then " join. Compact mode: terms joined with ", then ",
275
+ * prefixed `"{target}: "` when a target is wired. A term never contains "the
276
+ * subject" (the prefix names the target) but a two-person term always contains
277
+ * "the partner", which is substituted exactly as in full mode.
278
+ * Timing clauses follow in the fixed order position, pace, in both modes.
279
+ * `floor.subjectMinor` drops adult-only picks after the cap; none left ⇒ "".
280
+ */
281
+ export function composeCharacterMotionHintFromConnections(
282
+ motionId: string | ReadonlyArray<string> | undefined | null,
283
+ targetHints: ReadonlyArray<string>,
284
+ partnerHints: ReadonlyArray<string>,
285
+ timing?: CharacterMotionTiming,
286
+ mode: PickerHintMode = "full",
287
+ floor?: CharacterMotionFloor,
288
+ ): string {
289
+ const picked = Array.isArray(motionId)
290
+ ? Array.from(new Set(motionId as ReadonlyArray<string>)).slice(0, CHARACTER_MOTION_MAX_PICKS)
291
+ : motionId ? [motionId as string] : []
292
+ // Cap the user's picks FIRST, then floor: a dropped pick never lets a later one backfill.
293
+ const ids = floor?.subjectMinor === true
294
+ ? picked.filter((id) => getCharacterMotion(id)?.adultOnly !== true)
295
+ : picked
296
+ const resolveBase = mode === "compact" ? getCharacterMotionTerm : getCharacterMotionPromptHint
297
+ const entries = ids.map((id) => ({ id, base: resolveBase(id) })).filter((e) => e.base.length > 0)
298
+ if (entries.length === 0) return ""
299
+
300
+ const targets = [...new Set(targetHints.filter((h) => h && h.length > 0))]
301
+ const targetClause = targets.join(" and ")
302
+ const partnerClause = partnerHints.filter((h) => h && h.length > 0).join(" and ")
303
+ const partnerName = partnerClause || PARTNER_FALLBACK
304
+
305
+ /**
306
+ * One introduction PER TARGET. Within one target's clauses the partner handle
307
+ * names a single participant, so the first occurrence introduces it and every
308
+ * later occurrence refers back. The scope decisions, stated:
309
+ * - ACROSS THE SEQUENCE (up to 3 picks): SHARED. Pick 2 reading "that same
310
+ * person" refers to the person pick 1 introduced — one target doing two
311
+ * moves does them to one partner.
312
+ * - ACROSS MULTIPLE TARGETS: NOT SHARED. Each target performs a SEPARATE
313
+ * COPY of the sequence — what the `multiple-targets` diagnostic promises —
314
+ * so each copy introduces its own partner. Sharing one referent across the
315
+ * "; separately, " clauses instead described one person being hugged,
316
+ * dipped or bridal-carried by two people at once.
317
+ * - COMPACT MODE: same rule, same scope, same helper. Its terms are shorter,
318
+ * but a 3-pick compact sequence repeats the referent just as a full one
319
+ * does, so it needs the same treatment.
320
+ * - A WIRED NAME IS NEVER REWRITTEN: repeating "Theo" is already unambiguous,
321
+ * which also keeps wired output byte-identical to the pre-fix composer.
322
+ * Each target's state is keyed by the resolved phrase rather than by one flag,
323
+ * because two picks can carry DIFFERENT counterpart nouns ("a dog", then
324
+ * "a horse") — two referents, each owed its own introduction.
325
+ *
326
+ * Call order is output order: `entries.map` runs in sequence order and, inside
327
+ * it, `targets.map` runs in the order those clauses are joined. A target's
328
+ * state therefore spans that target's picks and ignores the clauses of the
329
+ * other targets interleaved between them.
330
+ */
331
+ const introducedByTarget = new Map<string, Set<string>>()
332
+ const referenceTo = (target: string, phrase: string): string => {
333
+ if (partnerClause) return phrase
334
+ let introduced = introducedByTarget.get(target)
335
+ if (!introduced) introducedByTarget.set(target, (introduced = new Set<string>()))
336
+ if (introduced.has(phrase)) return laterReferenceTo(phrase)
337
+ introduced.add(phrase)
338
+ return phrase
339
+ }
340
+
341
+ const substituted = entries.map(({ id, base }) => {
342
+ // One pass with a callback: names are literal data, never replacement
343
+ // syntax ($&, $`, $') or a second set of template tokens to reprocess.
344
+ const substitute = (target: string) => base.replace(/\bthe (subject|partner|counterpart)\b/g, (token, role: string) => {
345
+ if (role === "subject") return mode !== "compact" && target ? target : token
346
+ if (role === "partner") return referenceTo(target, partnerName)
347
+ return referenceTo(target, partnerClause || getCharacterMotion(id)?.counterpart || COUNTERPART_FALLBACK)
348
+ })
349
+ // Each actor receives a grammatical singular clause. Do not invent a
350
+ // plural choreography or attach a singular verb to a joined name list.
351
+ return targets.length > 1
352
+ ? targets.map(target => mode === "compact" ? `${target}: ${substitute(target)}` : substitute(target)).join("; separately, ")
353
+ : substitute(targetClause)
354
+ })
355
+
356
+ const joined = substituted.join(", then ")
357
+ const combinedBase = mode === "compact" && targets.length === 1 ? `${targetClause}: ${joined}` : joined
358
+ const parts: string[] = [combinedBase]
359
+ if (timing?.position && timing.position !== "auto") parts.push(POSITION_CLAUSES[timing.position])
360
+ if (timing?.pace && timing.pace !== "auto") parts.push(PACE_CLAUSES[timing.pace])
361
+ return parts.join(", ")
362
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * HOW A START/END FRAME TRAVELS — one plan, read by both ends.
3
+ *
4
+ * A frame can reach a model two ways: as a real frame, or as a reference image
5
+ * bound in prose as the opening (or closing) frame. Which one is right is
6
+ * measured per model — the Seedance 2.0 family crop-zooms a frame 2% and drifts
7
+ * 11-26% darker within six frames, but holds its look from frame 0 when the same
8
+ * image rides as a reference; every other model measured reproduces the opening
9
+ * frame better as a frame (see `resolveFrameDelivery` in @nodaro/shared).
10
+ *
11
+ * The composition — where the frames sit in the reference list, which sentence
12
+ * binds them, when the switch is refused — lives HERE rather than at the
13
+ * dispatch site, because two places need the same answer:
14
+ *
15
+ * - `backend/src/lib/video-frame-dispatch.ts` builds the actual request,
16
+ * - the editor's config panel tells the user which mode their node will run in.
17
+ *
18
+ * When those two disagree the panel lies, which is worse than having no panel
19
+ * text at all. One function, two callers, no drift.
20
+ */
21
+ import { VIDEO_REF_LIMITS_BY_PROVIDER, resolveFrameDelivery, type FrameDelivery } from "@nodaro/shared"
22
+ import { REF_BINDING } from "./ref-binding.js"
23
+ import { promptBindsFirstFrame } from "./seedance-2-inputs.js"
24
+
25
+ export interface FrameDeliveryPlanArgs {
26
+ readonly provider: string | undefined
27
+ /** The node's / caller's choice. `auto` (or absent) resolves per model. */
28
+ readonly requested?: FrameDelivery
29
+ /** Whether the model accepts reference images at all. */
30
+ readonly supportsReferenceImages: boolean
31
+ readonly startFrameUrl?: string
32
+ readonly endFrameUrl?: string
33
+ /** The user's OWN reference images, in their existing order. */
34
+ readonly userReferenceUrls?: readonly string[]
35
+ /** Consulted only to avoid a duplicate opening-frame sentence. */
36
+ readonly prompt?: string
37
+ }
38
+
39
+ export interface FrameDeliveryPlan {
40
+ /** What will actually happen — never `auto`. */
41
+ readonly delivery: Exclude<FrameDelivery, "auto">
42
+ /** The full reference list to send. Equals the user's own list under `frame`. */
43
+ readonly referenceImageUrls: readonly string[]
44
+ /** Sentence(s) to append to the prompt. Empty under `frame`. */
45
+ readonly promptSuffix: string
46
+ /** Set when reference delivery was WANTED but refused, so a caller can say why. */
47
+ readonly refusedReason?: "no-reference-support" | "image-cap"
48
+ }
49
+
50
+ /**
51
+ * Resolve delivery and compose the request shape that follows from it.
52
+ *
53
+ * Two refusals, both deliberate:
54
+ * - a model with no reference-image support keeps frame mode (there is nowhere
55
+ * for the frame to go);
56
+ * - a switch that would push past the model's image cap keeps frame mode,
57
+ * because the alternative is dropping one of the USER's reference images to
58
+ * make room for ours.
59
+ *
60
+ * The frames are appended AFTER the user's own images so their `@image_N`
61
+ * ordinals never shift, and the opening sentence is skipped when the prompt
62
+ * already binds its own first frame — a second binding at another position
63
+ * dilutes the first back into coin-flip behaviour (field finding 2026-07-20).
64
+ */
65
+ export function planFrameDelivery(args: FrameDeliveryPlanArgs): FrameDeliveryPlan {
66
+ const userRefs = (args.userReferenceUrls ?? []).filter(Boolean)
67
+ const requested = args.requested ?? "auto"
68
+ const delivery = resolveFrameDelivery({
69
+ provider: args.provider,
70
+ requested,
71
+ supportsReferenceImages: args.supportsReferenceImages,
72
+ })
73
+
74
+ const frames = [args.startFrameUrl, args.endFrameUrl].filter((u): u is string => Boolean(u))
75
+
76
+ if (delivery === "frame" || frames.length === 0) {
77
+ return {
78
+ delivery: "frame",
79
+ referenceImageUrls: userRefs,
80
+ promptSuffix: "",
81
+ ...(requested === "reference" && !args.supportsReferenceImages
82
+ ? { refusedReason: "no-reference-support" as const }
83
+ : {}),
84
+ }
85
+ }
86
+
87
+ const cap = VIDEO_REF_LIMITS_BY_PROVIDER[args.provider ?? ""]?.images
88
+ if (cap !== undefined && userRefs.length + frames.length > cap) {
89
+ return { delivery: "frame", referenceImageUrls: userRefs, promptSuffix: "", refusedReason: "image-cap" }
90
+ }
91
+
92
+ const referenceImageUrls = [...userRefs]
93
+ const sentences: string[] = []
94
+ if (args.startFrameUrl) {
95
+ referenceImageUrls.push(args.startFrameUrl)
96
+ if (!promptBindsFirstFrame(args.prompt)) {
97
+ sentences.push(REF_BINDING.frame(referenceImageUrls.length, "opening"))
98
+ }
99
+ }
100
+ if (args.endFrameUrl) {
101
+ referenceImageUrls.push(args.endFrameUrl)
102
+ sentences.push(REF_BINDING.frame(referenceImageUrls.length, "closing"))
103
+ }
104
+
105
+ return { delivery: "reference", referenceImageUrls, promptSuffix: sentences.join(" ") }
106
+ }