@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.
- package/dist/index.cjs +9770 -1344
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +187 -7
- package/dist/index.d.ts +187 -7
- package/dist/index.js +9098 -684
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +18 -1
- package/src/__tests__/character-motion-docs-example.test.ts +26 -0
- package/src/__tests__/character-motion-timing-catalogs.test.ts +76 -0
- package/src/__tests__/character-motion.test.ts +207 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +136 -0
- package/src/__tests__/parameter-hint-mode.test.ts +28 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +159 -0
- package/src/__tests__/parameter-registry-sync.test.ts +2 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +1 -1
- package/src/__tests__/transition-timing-catalogs.test.ts +1 -1
- package/src/__tests__/video-reference-features.test.ts +10 -1
- package/src/age-floor.ts +1 -1
- package/src/character-motion/animals-pets.ts +89 -0
- package/src/character-motion/athletic-stunts.ts +218 -0
- package/src/character-motion/camera-interaction.ts +137 -0
- package/src/character-motion/combat-weapons.ts +206 -0
- package/src/character-motion/dance.ts +173 -0
- package/src/character-motion/entrances-exits.ts +113 -0
- package/src/character-motion/evasive-falls.ts +134 -0
- package/src/character-motion/everyday-actions.ts +278 -0
- package/src/character-motion/face-expression.ts +182 -0
- package/src/character-motion/gestures.ts +308 -0
- package/src/character-motion/head-gestures.ts +98 -0
- package/src/character-motion/idle-ambient.ts +128 -0
- package/src/character-motion/posture-shifts.ts +143 -0
- package/src/character-motion/runway.ts +101 -0
- package/src/character-motion/stage-performance.ts +131 -0
- package/src/character-motion/turns-looks.ts +116 -0
- package/src/character-motion/two-person.ts +233 -0
- package/src/character-motion/types.ts +42 -0
- package/src/character-motion/unnatural-horror.ts +95 -0
- package/src/character-motion/vehicles-mounts.ts +101 -0
- package/src/character-motion/walks-runs.ts +125 -0
- package/src/character-motion.ts +286 -0
- package/src/index.ts +1 -0
- package/src/parameter-prompt-hint.ts +65 -0
- package/src/picker-analyzer-registry.ts +9 -5
- package/src/picker-catalogs.ts +24 -0
- package/src/picker-wiring.ts +2 -0
- package/src/prompt-wizard-categories.ts +0 -2
- package/src/provider-prompt-doctrine.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^3.
|
|
23
|
+
"@nodaro/shared": "^3.10.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -4,6 +4,7 @@ import { STYLINGS } from "../styling.js"
|
|
|
4
4
|
import { MOODS } from "../mood.js"
|
|
5
5
|
import { POSES } from "../pose.js"
|
|
6
6
|
import { PHOTO_GENRES } from "../photo-genre.js"
|
|
7
|
+
import { CHARACTER_MOTIONS } from "../character-motion.js"
|
|
7
8
|
import { getAdultOnlyEntries } from "../age-floor.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -45,6 +46,20 @@ const EXEMPT: Readonly<Record<string, string>> = {
|
|
|
45
46
|
"caught-off-guard": "lips parted from a startled reaction, not a seductive expression",
|
|
46
47
|
"hands-on-hips": "neutral confident stance — hips referenced as a body part, no exposure or sexual content",
|
|
47
48
|
"hand-position-hands-on-hips": "neutral confident stance, hand-position variant of hands-on-hips",
|
|
49
|
+
"sway-to-music": "character-motion: sway to the music — dance mechanics, hips as a body part, no exposure",
|
|
50
|
+
"sway-arms-raised": "character-motion: arms-up sway — dance mechanics, hips as a body part, no exposure",
|
|
51
|
+
"do-the-worm": "character-motion: the worm — dance mechanics, hips as a body part, no exposure",
|
|
52
|
+
"strike-vogue-poses": "character-motion: vogue — dance mechanics, hips as a body part, no exposure",
|
|
53
|
+
"spin-salsa-turn": "character-motion: salsa turn — dance mechanics, hips as a body part, no exposure",
|
|
54
|
+
"sway-bachata-hips": "character-motion: bachata sway — dance mechanics, hips as a body part, no exposure",
|
|
55
|
+
"flourish-bollywood-hands": "character-motion: bollywood flourish — dance mechanics, hips as a body part, no exposure",
|
|
56
|
+
"do-the-twist": "character-motion: the twist — dance mechanics, hips as a body part, no exposure",
|
|
57
|
+
"throw-disco-point": "character-motion: disco point — dance mechanics, hips as a body part, no exposure",
|
|
58
|
+
"do-the-floss": "character-motion: the floss — dance mechanics, hips as a body part, no exposure",
|
|
59
|
+
"tears-well-up": "character-motion: tear-filled glistening eyes — emotion, not skin",
|
|
60
|
+
"pout-sulkily": "character-motion: a sulky pout — emotional expression, not a seductive one",
|
|
61
|
+
"plant-hands-on-hips": "character-motion: neutral confident stance — hips as a body part, no exposure",
|
|
62
|
+
"freeze-on-the-drop": "character-motion: freeze on the drop — dance mechanics, hips as a body part, no exposure",
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
/** The hand-curated set. The test fails if any of these loses the flag. */
|
|
@@ -71,9 +86,11 @@ const CURATED = [
|
|
|
71
86
|
"seductive", "sultry", "smoldering", "flirty",
|
|
72
87
|
"biting-lip", "arched-back", "sitting-edge-of-bed", "lounging", "lying-down",
|
|
73
88
|
"brand-helmut-newton", "glamour-portrait",
|
|
89
|
+
// character-motion
|
|
90
|
+
"sultry-look-over-shoulder", "sashay-forward", "sway-hips-walk", "drop-coat-off-shoulders", "sultry-look-back", "shimmy-the-hips", "roll-the-body", "bite-lip-teasingly", "lick-lips-suggestively", "smolder-heavy-lidded", "smolder-at-camera", "hug-partner-from-behind", "lean-in-almost-kiss", "kiss-partner", "rise-on-tiptoes-to-kiss-partner", "pull-partner-close",
|
|
74
91
|
] as const
|
|
75
92
|
|
|
76
|
-
const ALL = [...PEOPLE, ...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES]
|
|
93
|
+
const ALL = [...PEOPLE, ...STYLINGS, ...MOODS, ...POSES, ...PHOTO_GENRES, ...CHARACTER_MOTIONS]
|
|
77
94
|
|
|
78
95
|
describe("adultOnly ratchet", () => {
|
|
79
96
|
it("every id in CURATED exists and carries the flag", () => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Docs Maintenance Rule: worked examples in docs MUST match the code.
|
|
3
|
+
* The Character Motion page quotes the composer's exact output for one sequence
|
|
4
|
+
* in both hint modes; this reads the page and fails if either quote drifts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest"
|
|
7
|
+
import { readFileSync } from "node:fs"
|
|
8
|
+
import { dirname, join } from "node:path"
|
|
9
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
import { composeCharacterMotionHintFromConnections as compose } from "../character-motion.js"
|
|
11
|
+
|
|
12
|
+
const DOC = readFileSync(
|
|
13
|
+
join(dirname(fileURLToPath(import.meta.url)), "../../../../docs/nodes/parameters/character-motion.md"),
|
|
14
|
+
"utf8",
|
|
15
|
+
)
|
|
16
|
+
const IDS = ["walk-in-from-left", "wave-hello", "hug-partner"]
|
|
17
|
+
const TIMING = { position: "start", pace: "slow" } as const
|
|
18
|
+
|
|
19
|
+
describe("docs/nodes/parameters/character-motion.md worked example", () => {
|
|
20
|
+
it("quotes the full-mode output verbatim", () => {
|
|
21
|
+
expect(DOC).toContain(compose(IDS, ["Mira"], ["Theo"], TIMING))
|
|
22
|
+
})
|
|
23
|
+
it("quotes the compact-mode output verbatim", () => {
|
|
24
|
+
expect(DOC).toContain(compose(IDS, ["Mira"], ["Theo"], TIMING, "compact"))
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character Motion's two timing parameters (position / pace) are enumerable
|
|
3
|
+
* catalogs so id-only consumers (Studio, the SDK, MCP) can offer them. Twin of
|
|
4
|
+
* `character-fx-timing-catalogs.test.ts`; the wording is this node's own and
|
|
5
|
+
* nothing here may couple it to the transition or character-fx rows.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from "vitest"
|
|
8
|
+
import {
|
|
9
|
+
CHARACTER_MOTION_POSITIONS,
|
|
10
|
+
CHARACTER_MOTION_PACES,
|
|
11
|
+
composeCharacterMotionHintFromConnections,
|
|
12
|
+
} from "../character-motion.js"
|
|
13
|
+
import { CHARACTER_FX_POSITIONS } from "../character-fx.js"
|
|
14
|
+
import { TRANSITION_POSITIONS } from "../transitions.js"
|
|
15
|
+
|
|
16
|
+
const MOVE = "walk-in-from-left"
|
|
17
|
+
const DIMENSIONS = [
|
|
18
|
+
["position", CHARACTER_MOTION_POSITIONS],
|
|
19
|
+
["pace", CHARACTER_MOTION_PACES],
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
describe("character-motion timing catalogs", () => {
|
|
23
|
+
it("spells exactly the ids stored workflows will use", () => {
|
|
24
|
+
expect(CHARACTER_MOTION_POSITIONS.map((o) => o.id)).toEqual(["auto", "start", "middle", "end", "full"])
|
|
25
|
+
expect(CHARACTER_MOTION_PACES.map((o) => o.id)).toEqual(["auto", "slow-motion", "slow", "natural", "fast", "explosive"])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("pins the injected clauses byte for byte", () => {
|
|
29
|
+
expect(CHARACTER_MOTION_POSITIONS.map((o) => o.promptHint)).toEqual([
|
|
30
|
+
"",
|
|
31
|
+
"the movement begins at the opening of the clip",
|
|
32
|
+
"the movement begins midway through the clip",
|
|
33
|
+
"the movement happens in the closing moments of the clip",
|
|
34
|
+
"the movement plays out across the entire clip",
|
|
35
|
+
])
|
|
36
|
+
expect(CHARACTER_MOTION_PACES.map((o) => o.promptHint)).toEqual([
|
|
37
|
+
"",
|
|
38
|
+
"the action is rendered in slow motion, every phase of the movement stretched and drawn out",
|
|
39
|
+
"performed slowly and deliberately, each phase of the movement given its full time",
|
|
40
|
+
"performed at a natural everyday tempo, neither rushed nor drawn out",
|
|
41
|
+
"performed quickly, with brisk urgent tempo and sharp transitions between phases",
|
|
42
|
+
"performed with an explosive burst of energy, snapping from stillness into full-speed motion",
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("keeps its own position wording — not the character-fx or transition rows", () => {
|
|
47
|
+
const mine = CHARACTER_MOTION_POSITIONS.map((o) => o.promptHint)
|
|
48
|
+
expect(mine).not.toEqual(CHARACTER_FX_POSITIONS.map((o) => o.promptHint))
|
|
49
|
+
expect(mine).not.toEqual(TRANSITION_POSITIONS.map((o) => o.promptHint))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("every scale leads with a no-op auto; every other step has a hint and a term", () => {
|
|
53
|
+
for (const [field, options] of DIMENSIONS) {
|
|
54
|
+
expect(options[0]!.id, field).toBe("auto")
|
|
55
|
+
expect(options[0]!.promptHint, field).toBe("")
|
|
56
|
+
expect(options[0]!.term, field).toBe("")
|
|
57
|
+
for (const o of options.slice(1)) {
|
|
58
|
+
expect(o.promptHint, `${field}/${o.id}`).not.toBe("")
|
|
59
|
+
expect(o.term, `${field}/${o.id}`).toBeTruthy()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("composes every step verbatim with no dangling separator, in both modes", () => {
|
|
65
|
+
for (const [field, options] of DIMENSIONS) {
|
|
66
|
+
for (const o of options.slice(1)) {
|
|
67
|
+
for (const mode of ["full", "compact"] as const) {
|
|
68
|
+
const out = composeCharacterMotionHintFromConnections(MOVE, [], [], { [field]: o.id } as never, mode)
|
|
69
|
+
expect(out, `${field}/${o.id} ${mode}`).toContain(o.promptHint)
|
|
70
|
+
expect(out, `${field}/${o.id} ${mode}`).not.toMatch(/,\s*$/)
|
|
71
|
+
expect(out, `${field}/${o.id} ${mode}`).not.toContain("undefined")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
CHARACTER_MOTIONS,
|
|
4
|
+
CHARACTER_MOTION_IDS,
|
|
5
|
+
CHARACTER_MOTION_CATEGORY_ORDER,
|
|
6
|
+
CHARACTER_MOTION_CATEGORY_LABELS,
|
|
7
|
+
CHARACTER_MOTION_MAX_PICKS,
|
|
8
|
+
composeCharacterMotionHintFromConnections as compose,
|
|
9
|
+
getCharacterMotion,
|
|
10
|
+
getCharacterMotionLabel,
|
|
11
|
+
getCharacterMotionPromptHint as hintOf,
|
|
12
|
+
getCharacterMotionTerm as termOf,
|
|
13
|
+
} from "../character-motion.js"
|
|
14
|
+
|
|
15
|
+
// Real ids from the catalog. Solo moves carry "the subject" only; DUO carries
|
|
16
|
+
// "the partner" too.
|
|
17
|
+
const SOLO_A = "walk-in-from-left"
|
|
18
|
+
const SOLO_B = "turn-to-camera"
|
|
19
|
+
const SOLO_C = "break-into-smile"
|
|
20
|
+
const SOLO_D = "wave-hello"
|
|
21
|
+
const DUO = "hug-partner"
|
|
22
|
+
const injecting = CHARACTER_MOTIONS.filter((m) => m.promptHint !== "")
|
|
23
|
+
|
|
24
|
+
describe("character-motion catalog — shape", () => {
|
|
25
|
+
it("leads with the no-op auto and none heads, which inject nothing", () => {
|
|
26
|
+
expect(CHARACTER_MOTIONS.slice(0, 2).map((m) => m.id)).toEqual(["auto", "none"])
|
|
27
|
+
for (const id of ["auto", "none"]) {
|
|
28
|
+
expect(hintOf(id)).toBe("")
|
|
29
|
+
expect(termOf(id)).toBe("")
|
|
30
|
+
expect(getCharacterMotion(id)?.adultOnly).toBeUndefined()
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("ships 1003 injecting entries across 20 categories", () => {
|
|
35
|
+
expect(injecting).toHaveLength(1003)
|
|
36
|
+
expect(CHARACTER_MOTION_CATEGORY_ORDER).toHaveLength(20)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("ids are unique kebab-case", () => {
|
|
40
|
+
expect(new Set(CHARACTER_MOTION_IDS).size).toBe(CHARACTER_MOTION_IDS.length)
|
|
41
|
+
for (const id of CHARACTER_MOTION_IDS) expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("labels are unique case-insensitively, so no two tiles read the same", () => {
|
|
45
|
+
const labels = CHARACTER_MOTIONS.map((m) => m.label.toLowerCase())
|
|
46
|
+
expect(new Set(labels).size).toBe(labels.length)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("every category is ordered, labelled and non-empty; every entry sits in one", () => {
|
|
50
|
+
expect(new Set(CHARACTER_MOTION_CATEGORY_ORDER)).toEqual(new Set(Object.keys(CHARACTER_MOTION_CATEGORY_LABELS)))
|
|
51
|
+
for (const cat of CHARACTER_MOTION_CATEGORY_ORDER) {
|
|
52
|
+
expect(injecting.some((m) => m.category === cat), `${cat} is empty`).toBe(true)
|
|
53
|
+
}
|
|
54
|
+
for (const m of CHARACTER_MOTIONS) expect(CHARACTER_MOTION_CATEGORY_ORDER).toContain(m.category)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("the substitution tokens match the flags exactly", () => {
|
|
58
|
+
for (const m of injecting) {
|
|
59
|
+
expect(m.promptHint, `${m.id} hint must name the subject`).toMatch(/\bthe subject\b/)
|
|
60
|
+
expect(/\bthe partner\b/.test(m.promptHint), `${m.id}: hint partner token vs twoPerson`).toBe(m.twoPerson === true)
|
|
61
|
+
expect(m.term, `${m.id} needs an authored term`).toBeTruthy()
|
|
62
|
+
expect(m.term!, `${m.id}: a term never names the subject (compact mode prefixes it)`).not.toMatch(/\bthe subject\b/)
|
|
63
|
+
expect(/\bthe partner\b/.test(m.term!), `${m.id}: term partner token vs twoPerson`).toBe(m.twoPerson === true)
|
|
64
|
+
expect(m.promptHint.endsWith("."), `${m.id} trailing period`).toBe(false)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("character-motion getters", () => {
|
|
70
|
+
it("resolve known ids and return nothing for nullish or unknown ids", () => {
|
|
71
|
+
expect(getCharacterMotion(SOLO_D)?.id).toBe(SOLO_D)
|
|
72
|
+
expect(getCharacterMotion(undefined)).toBeUndefined()
|
|
73
|
+
expect(getCharacterMotion(null)).toBeUndefined()
|
|
74
|
+
expect(getCharacterMotion("no-such-move")).toBeUndefined()
|
|
75
|
+
expect(hintOf("no-such-move")).toBe("")
|
|
76
|
+
expect(termOf("no-such-move")).toBe("")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("label falls back to the title-cased id, or empty for nullish", () => {
|
|
80
|
+
expect(getCharacterMotionLabel("no-such-move")).toBe("No Such Move")
|
|
81
|
+
expect(getCharacterMotionLabel(null)).toBe("")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("composeCharacterMotionHintFromConnections — full mode", () => {
|
|
86
|
+
it("a single unwired pick is the bare hint", () => {
|
|
87
|
+
expect(compose(SOLO_A, [], [])).toBe(hintOf(SOLO_A))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("auto, none, unknown, null and [] inject nothing", () => {
|
|
91
|
+
for (const v of ["auto", "none", "no-such-move", null, undefined, [] as string[]]) {
|
|
92
|
+
expect(compose(v, ["Aria"], ["Ben"])).toBe("")
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("substitutes the wired target for every 'the subject'", () => {
|
|
97
|
+
expect(compose(SOLO_A, ["Aria"], [])).toBe(hintOf(SOLO_A).replace(/\bthe subject\b/g, "Aria"))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("joins the picks as an ordered sequence with ', then '", () => {
|
|
101
|
+
expect(compose([SOLO_B, SOLO_A], [], [])).toBe(`${hintOf(SOLO_B)}, then ${hintOf(SOLO_A)}`)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it(`de-duplicates, keeps first-pick order, then caps at ${CHARACTER_MOTION_MAX_PICKS}`, () => {
|
|
105
|
+
expect(compose([SOLO_A, SOLO_A, SOLO_B, SOLO_C, SOLO_D], [], [])).toBe(
|
|
106
|
+
[SOLO_A, SOLO_B, SOLO_C].map((id) => hintOf(id)).join(", then "),
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("an unwired partner reads 'another person' — the literal token never ships", () => {
|
|
111
|
+
const out = compose(DUO, [], [])
|
|
112
|
+
expect(out).not.toMatch(/\bthe partner\b/)
|
|
113
|
+
expect(out).toBe(hintOf(DUO).replace(/\bthe partner\b/g, "another person"))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("substitutes both wired names", () => {
|
|
117
|
+
expect(compose(DUO, ["Aria"], ["Ben"])).toBe(
|
|
118
|
+
hintOf(DUO).replace(/\bthe subject\b/g, "Aria").replace(/\bthe partner\b/g, "Ben"),
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("appends the position clause, then the pace clause", () => {
|
|
123
|
+
expect(compose(SOLO_A, [], [], { position: "start", pace: "slow-motion" })).toBe(
|
|
124
|
+
`${hintOf(SOLO_A)}, the movement begins at the opening of the clip, the action is rendered in slow motion, every phase of the movement stretched and drawn out`,
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("auto timing adds nothing", () => {
|
|
129
|
+
expect(compose(SOLO_A, [], [], { position: "auto", pace: "auto" })).toBe(hintOf(SOLO_A))
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("composeCharacterMotionHintFromConnections — compact mode", () => {
|
|
134
|
+
it("joins terms with ', then ' and has no prefix when unwired", () => {
|
|
135
|
+
expect(compose([SOLO_B, SOLO_C], [], [], undefined, "compact")).toBe(`${termOf(SOLO_B)}, then ${termOf(SOLO_C)}`)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("prefixes the wired target", () => {
|
|
139
|
+
expect(compose(SOLO_A, ["Aria"], [], undefined, "compact")).toBe(`Aria: ${termOf(SOLO_A)}`)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("substitutes the partner inside a two-person term", () => {
|
|
143
|
+
expect(compose([SOLO_A, DUO], ["Aria"], ["Ben"], undefined, "compact")).toBe(
|
|
144
|
+
`Aria: ${termOf(SOLO_A)}, then ${termOf(DUO).replace(/\bthe partner\b/g, "Ben")}`,
|
|
145
|
+
)
|
|
146
|
+
expect(compose(DUO, [], [], undefined, "compact")).toBe(termOf(DUO).replace(/\bthe partner\b/g, "another person"))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("timing clauses are the same promptHint strings in compact mode", () => {
|
|
150
|
+
expect(compose(SOLO_A, [], [], { pace: "fast" }, "compact")).toBe(
|
|
151
|
+
`${termOf(SOLO_A)}, performed quickly, with brisk urgent tempo and sharp transitions between phases`,
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// The composer is the floor for adult-only moves on the video prompt: its
|
|
157
|
+
// caller reports a minor subject and every `adultOnly` pick is dropped.
|
|
158
|
+
describe("composeCharacterMotionHintFromConnections — minor-age floor", () => {
|
|
159
|
+
const ADULT = "kiss-partner"
|
|
160
|
+
const ADULT_B = "lean-in-almost-kiss"
|
|
161
|
+
const MODES = ["full", "compact"] as const
|
|
162
|
+
const MINOR = { subjectMinor: true } as const
|
|
163
|
+
|
|
164
|
+
it("the fixtures carry the flags these tests rely on", () => {
|
|
165
|
+
expect(getCharacterMotion(ADULT)?.adultOnly).toBe(true)
|
|
166
|
+
expect(getCharacterMotion(ADULT_B)?.adultOnly).toBe(true)
|
|
167
|
+
for (const id of [SOLO_A, SOLO_B, SOLO_D]) expect(getCharacterMotion(id)?.adultOnly).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("drops an adult-only pick for a minor subject and keeps the neutral one, in both modes", () => {
|
|
171
|
+
const full = compose([SOLO_D, ADULT], [], [], undefined, "full", MINOR)
|
|
172
|
+
expect(full).toBe(hintOf(SOLO_D))
|
|
173
|
+
expect(full).not.toMatch(/kiss/i)
|
|
174
|
+
const compact = compose([SOLO_D, ADULT], [], [], undefined, "compact", MINOR)
|
|
175
|
+
expect(compact).toBe(termOf(SOLO_D))
|
|
176
|
+
expect(compact).not.toMatch(/kiss/i)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("only adult-only picks for a minor inject nothing — no timing clauses either", () => {
|
|
180
|
+
for (const mode of MODES) {
|
|
181
|
+
expect(compose([ADULT, ADULT_B], ["Aria"], ["Ben"], { position: "start", pace: "fast" }, mode, MINOR)).toBe("")
|
|
182
|
+
expect(compose(ADULT, [], [], undefined, mode, MINOR)).toBe("")
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("subjectMinor false and an omitted floor are byte-identical and keep the adult-only pick", () => {
|
|
187
|
+
for (const mode of MODES) {
|
|
188
|
+
const picks = [SOLO_D, ADULT]
|
|
189
|
+
const omitted = compose(picks, ["Aria"], ["Ben"], undefined, mode)
|
|
190
|
+
expect(compose(picks, ["Aria"], ["Ben"], undefined, mode, { subjectMinor: false })).toBe(omitted)
|
|
191
|
+
expect(compose(picks, ["Aria"], ["Ben"], undefined, mode, {})).toBe(omitted)
|
|
192
|
+
const kiss = mode === "full"
|
|
193
|
+
? hintOf(ADULT).replace(/\bthe subject\b/g, "Aria").replace(/\bthe partner\b/g, "Ben")
|
|
194
|
+
: termOf(ADULT).replace(/\bthe partner\b/g, "Ben")
|
|
195
|
+
expect(omitted).toContain(kiss)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it(`caps the user's picks at ${CHARACTER_MOTION_MAX_PICKS} before dropping, so a later pick never backfills`, () => {
|
|
200
|
+
expect(compose([SOLO_A, ADULT, SOLO_B, SOLO_D], [], [], undefined, "full", MINOR)).toBe(
|
|
201
|
+
`${hintOf(SOLO_A)}, then ${hintOf(SOLO_B)}`,
|
|
202
|
+
)
|
|
203
|
+
expect(compose([SOLO_A, ADULT, SOLO_B, SOLO_D], [], [], undefined, "compact", MINOR)).toBe(
|
|
204
|
+
`${termOf(SOLO_A)}, then ${termOf(SOLO_B)}`,
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -631,6 +631,142 @@
|
|
|
631
631
|
},
|
|
632
632
|
"expected": "Mira transforms into a werewolf — fur sprouts across the skin, fangs and claws extend, the snout elongates, eyes glow yellow, body re-shapes with visible muscle and bone movement, clothing tears"
|
|
633
633
|
},
|
|
634
|
+
{
|
|
635
|
+
"key": "character-motion/id:auto",
|
|
636
|
+
"note": "single-dim catalog id",
|
|
637
|
+
"node": {
|
|
638
|
+
"id": "n1",
|
|
639
|
+
"type": "character-motion",
|
|
640
|
+
"data": {
|
|
641
|
+
"characterMotion": "auto"
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
"ctx": null,
|
|
645
|
+
"expected": ""
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
"key": "character-motion/id:let-the-jaw-drop",
|
|
649
|
+
"note": "single-dim catalog id",
|
|
650
|
+
"node": {
|
|
651
|
+
"id": "n1",
|
|
652
|
+
"type": "character-motion",
|
|
653
|
+
"data": {
|
|
654
|
+
"characterMotion": "let-the-jaw-drop"
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
"ctx": null,
|
|
658
|
+
"expected": "the subject lets the jaw fall open under its own weight until the mouth hangs wide and slack, the lips loose and the tongue still, holding it open while the rest of the face stays blank"
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
"key": "character-motion/id:rise-after-knockdown",
|
|
662
|
+
"note": "single-dim catalog id",
|
|
663
|
+
"node": {
|
|
664
|
+
"id": "n1",
|
|
665
|
+
"type": "character-motion",
|
|
666
|
+
"data": {
|
|
667
|
+
"characterMotion": "rise-after-knockdown"
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
"ctx": null,
|
|
671
|
+
"expected": "the subject pushes up from the ground onto one knee, plants a hand and rises to standing, shakes the head clear and raises both fists back into guard"
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
"key": "character-motion/multi-pick",
|
|
675
|
+
"note": "array of 2 ids on \"characterMotion\"",
|
|
676
|
+
"node": {
|
|
677
|
+
"id": "n1",
|
|
678
|
+
"type": "character-motion",
|
|
679
|
+
"data": {
|
|
680
|
+
"characterMotion": [
|
|
681
|
+
"walk-in-from-left",
|
|
682
|
+
"walk-in-from-right"
|
|
683
|
+
]
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
"ctx": null,
|
|
687
|
+
"expected": "the subject walks into frame from the left edge at an even stride, weight rolling heel to toe, crosses toward center and stops facing forward, then the subject walks into frame from the right edge at an even stride, weight rolling heel to toe, crosses toward center and stops facing forward"
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
"key": "character-motion/pre-post",
|
|
691
|
+
"note": "preText + postText wrap the base fragment",
|
|
692
|
+
"node": {
|
|
693
|
+
"id": "n1",
|
|
694
|
+
"type": "character-motion",
|
|
695
|
+
"data": {
|
|
696
|
+
"characterMotion": "walk-in-from-left",
|
|
697
|
+
"preText": "shot on location",
|
|
698
|
+
"postText": "no text overlays"
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
"ctx": null,
|
|
702
|
+
"expected": "shot on location, the subject walks into frame from the left edge at an even stride, weight rolling heel to toe, crosses toward center and stops facing forward, no text overlays"
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
"key": "character-motion/sequence-timing",
|
|
706
|
+
"note": "character-motion: three ordered picks + position + pace, no ctx",
|
|
707
|
+
"node": {
|
|
708
|
+
"id": "n1",
|
|
709
|
+
"type": "character-motion",
|
|
710
|
+
"data": {
|
|
711
|
+
"characterMotion": [
|
|
712
|
+
"walk-in-from-left",
|
|
713
|
+
"turn-to-camera",
|
|
714
|
+
"wave-hello"
|
|
715
|
+
],
|
|
716
|
+
"position": "middle",
|
|
717
|
+
"pace": "slow"
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
"ctx": null,
|
|
721
|
+
"expected": "the subject walks into frame from the left edge at an even stride, weight rolling heel to toe, crosses toward center and stops facing forward, then the subject turns from back-to-camera around to face front, stepping the feet through the rotation, shoulders squaring to the camera and weight settling evenly on both feet, then the subject lifts one hand to shoulder height, palm forward, and waves it side to side in a friendly greeting before letting the arm drop back to the side, the movement begins midway through the clip, performed slowly and deliberately, each phase of the movement given its full time"
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
"key": "character-motion/with-ctx",
|
|
725
|
+
"note": "character-motion substituting upstream names for 'the subject' and 'the partner'",
|
|
726
|
+
"node": {
|
|
727
|
+
"id": "n1",
|
|
728
|
+
"type": "character-motion",
|
|
729
|
+
"data": {
|
|
730
|
+
"characterMotion": [
|
|
731
|
+
"walk-in-from-left",
|
|
732
|
+
"hug-partner"
|
|
733
|
+
]
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
"ctx": {
|
|
737
|
+
"nodes": [
|
|
738
|
+
{
|
|
739
|
+
"id": "c1",
|
|
740
|
+
"type": "character-ref",
|
|
741
|
+
"data": {
|
|
742
|
+
"characterName": "Mira"
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
"id": "c2",
|
|
747
|
+
"type": "character-ref",
|
|
748
|
+
"data": {
|
|
749
|
+
"characterName": "Theo"
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
],
|
|
753
|
+
"edges": [
|
|
754
|
+
{
|
|
755
|
+
"source": "c1",
|
|
756
|
+
"target": "n1",
|
|
757
|
+
"sourceHandle": "output",
|
|
758
|
+
"targetHandle": "target"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"source": "c2",
|
|
762
|
+
"target": "n1",
|
|
763
|
+
"sourceHandle": "output",
|
|
764
|
+
"targetHandle": "partner"
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
"expected": "Mira walks into frame from the left edge at an even stride, weight rolling heel to toe, crosses toward center and stops facing forward, then Mira steps toward Theo, opens both arms and wraps them around Theo's back in a firm hug, holds the hug, and loosens the arms to step back"
|
|
769
|
+
},
|
|
634
770
|
{
|
|
635
771
|
"key": "color-look/id:lifestyle-warm-magazine",
|
|
636
772
|
"note": "single-dim catalog id",
|
|
@@ -194,6 +194,33 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
194
194
|
expect(compact).toContain("with extreme exaggerated energy, wild flourishes, and dramatic distortion")
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
+
it("character-motion timing clauses are emitted identically in compact mode", () => {
|
|
198
|
+
const move = firstInjectingId("character-motion")
|
|
199
|
+
const data = { characterMotion: move, position: "full", pace: "explosive" }
|
|
200
|
+
const compact = getParameterPromptHint({ id: "n1", type: "character-motion", data: { ...data, hintMode: "compact" } })
|
|
201
|
+
expect(compact).toContain("the movement plays out across the entire clip")
|
|
202
|
+
expect(compact).toContain("performed with an explosive burst of energy, snapping from stillness into full-speed motion")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("character-motion names target and partner in both modes", () => {
|
|
206
|
+
const ctx: HintGraphContext = {
|
|
207
|
+
nodes: [
|
|
208
|
+
{ id: "c1", type: "character-ref", data: { characterName: "Mira" } },
|
|
209
|
+
{ id: "c2", type: "character-ref", data: { characterName: "Theo" } },
|
|
210
|
+
],
|
|
211
|
+
edges: [
|
|
212
|
+
{ source: "c1", target: "n1", targetHandle: "target" },
|
|
213
|
+
{ source: "c2", target: "n1", targetHandle: "partner" },
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
const node: HintNodeLike = { id: "n1", type: "character-motion", data: { characterMotion: "hug-partner" } }
|
|
217
|
+
for (const out of [getParameterPromptHint(node, ctx), getParameterPromptHint(withMode(node, "compact"), ctx)]) {
|
|
218
|
+
expect(out).toContain("Mira")
|
|
219
|
+
expect(out).toContain("Theo")
|
|
220
|
+
expect(out).not.toMatch(/\bthe (subject|partner)\b/)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
197
224
|
it("the wired character is NAMED in both modes", () => {
|
|
198
225
|
// Regression: compact mode used to run the full mode's substitution — a
|
|
199
226
|
// regex replace of "the subject" — over a `term` that never contains those
|
|
@@ -282,6 +309,7 @@ describe("hint mode: compact preserves everything but the base fragment", () =>
|
|
|
282
309
|
for (const [type, field] of [
|
|
283
310
|
["transition", "transition"],
|
|
284
311
|
["character-fx", "characterFx"],
|
|
312
|
+
["character-motion", "characterMotion"],
|
|
285
313
|
["camera-motion", "cameraMotion"],
|
|
286
314
|
] as const) {
|
|
287
315
|
expect(getParameterPromptHint({ id: "n1", type, data: { [field]: "auto", hintMode: "compact" } })).toBe("")
|