@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.
- package/dist/index.cjs +11175 -1797
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +280 -41
- package/dist/index.d.ts +280 -41
- package/dist/index.js +10049 -686
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +26 -1
- package/src/__tests__/character-motion-audit.test.ts +77 -0
- package/src/__tests__/character-motion-docs-example.test.ts +26 -0
- package/src/__tests__/character-motion-exit-visibility.test.ts +183 -0
- package/src/__tests__/character-motion-partner-referent.test.ts +225 -0
- package/src/__tests__/character-motion-timing-catalogs.test.ts +76 -0
- package/src/__tests__/character-motion.test.ts +214 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +136 -0
- package/src/__tests__/frame-delivery.test.ts +93 -0
- package/src/__tests__/graph-composed-unwired-identity.test.ts +166 -0
- package/src/__tests__/parameter-hint-mode.test.ts +28 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +165 -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 +116 -0
- package/src/character-motion/athletic-stunts.ts +159 -0
- package/src/character-motion/camera-interaction.ts +96 -0
- package/src/character-motion/combat-weapons.ts +147 -0
- package/src/character-motion/dance.ts +125 -0
- package/src/character-motion/entrances-exits.ts +92 -0
- package/src/character-motion/evasive-falls.ts +92 -0
- package/src/character-motion/everyday-actions.ts +237 -0
- package/src/character-motion/face-expression.ts +129 -0
- package/src/character-motion/gestures.ts +215 -0
- package/src/character-motion/head-gestures.ts +70 -0
- package/src/character-motion/idle-ambient.ts +89 -0
- package/src/character-motion/posture-shifts.ts +120 -0
- package/src/character-motion/runway.ts +77 -0
- package/src/character-motion/stage-performance.ts +96 -0
- package/src/character-motion/turns-looks.ts +90 -0
- package/src/character-motion/two-person.ts +245 -0
- package/src/character-motion/types.ts +45 -0
- package/src/character-motion/unnatural-horror.ts +57 -0
- package/src/character-motion/vehicles-mounts.ts +105 -0
- package/src/character-motion/walks-runs.ts +97 -0
- package/src/character-motion-diagnostics.ts +49 -0
- package/src/character-motion.ts +362 -0
- package/src/frame-delivery.ts +106 -0
- package/src/index.ts +5 -0
- package/src/parameter-prompt-hint.ts +71 -0
- package/src/picker-analyzer-registry.ts +9 -5
- package/src/picker-catalogs.ts +33 -1
- package/src/picker-wiring.ts +2 -0
- package/src/prompt-wizard-categories.ts +0 -2
- package/src/provider-prompt-doctrine.ts +1 -1
|
@@ -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,214 @@
|
|
|
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 1054 injecting entries across 20 categories", () => {
|
|
35
|
+
expect(injecting).toHaveLength(1054)
|
|
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 is introduced once, then referred back to", () => {
|
|
111
|
+
const out = compose(DUO, [], [])
|
|
112
|
+
expect(out).not.toMatch(/\bthe partner\b/)
|
|
113
|
+
// DUO mentions the partner twice: introduce, then refer back.
|
|
114
|
+
let first = true
|
|
115
|
+
expect(out).toBe(hintOf(DUO).replace(/\bthe partner\b/g, () => {
|
|
116
|
+
const word = first ? "another person" : "that same person"
|
|
117
|
+
first = false
|
|
118
|
+
return word
|
|
119
|
+
}))
|
|
120
|
+
expect(out.match(/another person/g)).toHaveLength(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("substitutes both wired names", () => {
|
|
124
|
+
expect(compose(DUO, ["Aria"], ["Ben"])).toBe(
|
|
125
|
+
hintOf(DUO).replace(/\bthe subject\b/g, "Aria").replace(/\bthe partner\b/g, "Ben"),
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("appends the position clause, then the pace clause", () => {
|
|
130
|
+
expect(compose(SOLO_A, [], [], { position: "start", pace: "slow-motion" })).toBe(
|
|
131
|
+
`${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`,
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("auto timing adds nothing", () => {
|
|
136
|
+
expect(compose(SOLO_A, [], [], { position: "auto", pace: "auto" })).toBe(hintOf(SOLO_A))
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("composeCharacterMotionHintFromConnections — compact mode", () => {
|
|
141
|
+
it("joins terms with ', then ' and has no prefix when unwired", () => {
|
|
142
|
+
expect(compose([SOLO_B, SOLO_C], [], [], undefined, "compact")).toBe(`${termOf(SOLO_B)}, then ${termOf(SOLO_C)}`)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("prefixes the wired target", () => {
|
|
146
|
+
expect(compose(SOLO_A, ["Aria"], [], undefined, "compact")).toBe(`Aria: ${termOf(SOLO_A)}`)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("substitutes the partner inside a two-person term", () => {
|
|
150
|
+
expect(compose([SOLO_A, DUO], ["Aria"], ["Ben"], undefined, "compact")).toBe(
|
|
151
|
+
`Aria: ${termOf(SOLO_A)}, then ${termOf(DUO).replace(/\bthe partner\b/g, "Ben")}`,
|
|
152
|
+
)
|
|
153
|
+
expect(compose(DUO, [], [], undefined, "compact")).toBe(termOf(DUO).replace(/\bthe partner\b/g, "another person"))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("timing clauses are the same promptHint strings in compact mode", () => {
|
|
157
|
+
expect(compose(SOLO_A, [], [], { pace: "fast" }, "compact")).toBe(
|
|
158
|
+
`${termOf(SOLO_A)}, performed quickly, with brisk urgent tempo and sharp transitions between phases`,
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// The composer is the floor for adult-only moves on the video prompt: its
|
|
164
|
+
// caller reports a minor subject and every `adultOnly` pick is dropped.
|
|
165
|
+
describe("composeCharacterMotionHintFromConnections — minor-age floor", () => {
|
|
166
|
+
const ADULT = "kiss-partner"
|
|
167
|
+
const ADULT_B = "lean-in-almost-kiss"
|
|
168
|
+
const MODES = ["full", "compact"] as const
|
|
169
|
+
const MINOR = { subjectMinor: true } as const
|
|
170
|
+
|
|
171
|
+
it("the fixtures carry the flags these tests rely on", () => {
|
|
172
|
+
expect(getCharacterMotion(ADULT)?.adultOnly).toBe(true)
|
|
173
|
+
expect(getCharacterMotion(ADULT_B)?.adultOnly).toBe(true)
|
|
174
|
+
for (const id of [SOLO_A, SOLO_B, SOLO_D]) expect(getCharacterMotion(id)?.adultOnly).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("drops an adult-only pick for a minor subject and keeps the neutral one, in both modes", () => {
|
|
178
|
+
const full = compose([SOLO_D, ADULT], [], [], undefined, "full", MINOR)
|
|
179
|
+
expect(full).toBe(hintOf(SOLO_D))
|
|
180
|
+
expect(full).not.toMatch(/kiss/i)
|
|
181
|
+
const compact = compose([SOLO_D, ADULT], [], [], undefined, "compact", MINOR)
|
|
182
|
+
expect(compact).toBe(termOf(SOLO_D))
|
|
183
|
+
expect(compact).not.toMatch(/kiss/i)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("only adult-only picks for a minor inject nothing — no timing clauses either", () => {
|
|
187
|
+
for (const mode of MODES) {
|
|
188
|
+
expect(compose([ADULT, ADULT_B], ["Aria"], ["Ben"], { position: "start", pace: "fast" }, mode, MINOR)).toBe("")
|
|
189
|
+
expect(compose(ADULT, [], [], undefined, mode, MINOR)).toBe("")
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("subjectMinor false and an omitted floor are byte-identical and keep the adult-only pick", () => {
|
|
194
|
+
for (const mode of MODES) {
|
|
195
|
+
const picks = [SOLO_D, ADULT]
|
|
196
|
+
const omitted = compose(picks, ["Aria"], ["Ben"], undefined, mode)
|
|
197
|
+
expect(compose(picks, ["Aria"], ["Ben"], undefined, mode, { subjectMinor: false })).toBe(omitted)
|
|
198
|
+
expect(compose(picks, ["Aria"], ["Ben"], undefined, mode, {})).toBe(omitted)
|
|
199
|
+
const kiss = mode === "full"
|
|
200
|
+
? hintOf(ADULT).replace(/\bthe subject\b/g, "Aria").replace(/\bthe partner\b/g, "Ben")
|
|
201
|
+
: termOf(ADULT).replace(/\bthe partner\b/g, "Ben")
|
|
202
|
+
expect(omitted).toContain(kiss)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it(`caps the user's picks at ${CHARACTER_MOTION_MAX_PICKS} before dropping, so a later pick never backfills`, () => {
|
|
207
|
+
expect(compose([SOLO_A, ADULT, SOLO_B, SOLO_D], [], [], undefined, "full", MINOR)).toBe(
|
|
208
|
+
`${hintOf(SOLO_A)}, then ${hintOf(SOLO_B)}`,
|
|
209
|
+
)
|
|
210
|
+
expect(compose([SOLO_A, ADULT, SOLO_B, SOLO_D], [], [], undefined, "compact", MINOR)).toBe(
|
|
211
|
+
`${termOf(SOLO_A)}, then ${termOf(SOLO_B)}`,
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -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:sprint-start",
|
|
662
|
+
"note": "single-dim catalog id",
|
|
663
|
+
"node": {
|
|
664
|
+
"id": "n1",
|
|
665
|
+
"type": "character-motion",
|
|
666
|
+
"data": {
|
|
667
|
+
"characterMotion": "sprint-start"
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
"ctx": null,
|
|
671
|
+
"expected": "the subject crouches in starting blocks with fingertips on the line, raises the pelvis into the set position, then drives forward off the front foot in a low lean and accelerates into full stride"
|
|
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",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { planFrameDelivery } from "../frame-delivery.js"
|
|
3
|
+
|
|
4
|
+
const START = "https://cdn.example/start.png"
|
|
5
|
+
const END = "https://cdn.example/end.png"
|
|
6
|
+
|
|
7
|
+
describe("planFrameDelivery", () => {
|
|
8
|
+
it("keeps a frame as a frame on a model whose frame mode measured fine", () => {
|
|
9
|
+
const plan = planFrameDelivery({
|
|
10
|
+
provider: "seedance-2-5", supportsReferenceImages: true, startFrameUrl: START, prompt: "a walk",
|
|
11
|
+
})
|
|
12
|
+
expect(plan).toEqual({ delivery: "frame", referenceImageUrls: [], promptSuffix: "" })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("moves the frame into the reference list on the Seedance 2.0 family", () => {
|
|
16
|
+
const plan = planFrameDelivery({
|
|
17
|
+
provider: "seedance-2-fast", supportsReferenceImages: true, startFrameUrl: START,
|
|
18
|
+
})
|
|
19
|
+
expect(plan.delivery).toBe("reference")
|
|
20
|
+
expect(plan.referenceImageUrls).toEqual([START])
|
|
21
|
+
expect(plan.promptSuffix).toBe("Use @image_1 as the opening (first) frame of the video.")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("appends frames after the user's own images so their ordinals hold", () => {
|
|
25
|
+
const plan = planFrameDelivery({
|
|
26
|
+
provider: "seedance-2-fast",
|
|
27
|
+
supportsReferenceImages: true,
|
|
28
|
+
startFrameUrl: START,
|
|
29
|
+
endFrameUrl: END,
|
|
30
|
+
userReferenceUrls: ["https://cdn.example/a.png", "https://cdn.example/b.png"],
|
|
31
|
+
})
|
|
32
|
+
expect(plan.referenceImageUrls).toEqual(["https://cdn.example/a.png", "https://cdn.example/b.png", START, END])
|
|
33
|
+
expect(plan.promptSuffix).toContain("@image_3 as the opening (first) frame")
|
|
34
|
+
expect(plan.promptSuffix).toContain("@image_4 as the closing (last) frame")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("adds no second opening sentence when the prompt already binds one", () => {
|
|
38
|
+
const plan = planFrameDelivery({
|
|
39
|
+
provider: "seedance-2-fast",
|
|
40
|
+
supportsReferenceImages: true,
|
|
41
|
+
startFrameUrl: START,
|
|
42
|
+
prompt: "Use @image_1 as the first frame, it is the last keyframe of @video_1",
|
|
43
|
+
})
|
|
44
|
+
expect(plan.delivery).toBe("reference")
|
|
45
|
+
expect(plan.promptSuffix).toBe("")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("honours an explicit choice in both directions", () => {
|
|
49
|
+
expect(planFrameDelivery({
|
|
50
|
+
provider: "seedance-2-fast", requested: "frame", supportsReferenceImages: true, startFrameUrl: START,
|
|
51
|
+
}).delivery).toBe("frame")
|
|
52
|
+
expect(planFrameDelivery({
|
|
53
|
+
provider: "veo3.1", requested: "reference", supportsReferenceImages: true, startFrameUrl: START,
|
|
54
|
+
}).delivery).toBe("reference")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("refuses, with a reason, on a model that takes no reference images", () => {
|
|
58
|
+
const plan = planFrameDelivery({
|
|
59
|
+
provider: "wan-i2v", requested: "reference", supportsReferenceImages: false, startFrameUrl: START,
|
|
60
|
+
})
|
|
61
|
+
expect(plan).toMatchObject({ delivery: "frame", refusedReason: "no-reference-support" })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("refuses rather than displace a user's reference image at the cap", () => {
|
|
65
|
+
const refs = Array.from({ length: 9 }, (_, i) => `https://cdn.example/ref-${i}.png`)
|
|
66
|
+
const plan = planFrameDelivery({
|
|
67
|
+
provider: "seedance-2-fast", supportsReferenceImages: true, startFrameUrl: START, userReferenceUrls: refs,
|
|
68
|
+
})
|
|
69
|
+
expect(plan).toMatchObject({ delivery: "frame", refusedReason: "image-cap" })
|
|
70
|
+
expect(plan.referenceImageUrls).toEqual(refs)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("is a no-op when no frame is wired", () => {
|
|
74
|
+
const plan = planFrameDelivery({ provider: "seedance-2-fast", supportsReferenceImages: true })
|
|
75
|
+
expect(plan).toEqual({ delivery: "frame", referenceImageUrls: [], promptSuffix: "" })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("plans the same shape whether it is asked with urls or with stand-ins", () => {
|
|
79
|
+
// The config panel knows a frame is wired before it knows its url, so it
|
|
80
|
+
// passes placeholders. Ordering and sentences must not depend on the string.
|
|
81
|
+
const real = planFrameDelivery({
|
|
82
|
+
provider: "seedance-2-mini", supportsReferenceImages: true, startFrameUrl: START, endFrameUrl: END,
|
|
83
|
+
userReferenceUrls: ["https://cdn.example/a.png"],
|
|
84
|
+
})
|
|
85
|
+
const preview = planFrameDelivery({
|
|
86
|
+
provider: "seedance-2-mini", supportsReferenceImages: true, startFrameUrl: "frame:start", endFrameUrl: "frame:end",
|
|
87
|
+
userReferenceUrls: ["ref:0"],
|
|
88
|
+
})
|
|
89
|
+
expect(preview.delivery).toBe(real.delivery)
|
|
90
|
+
expect(preview.promptSuffix).toBe(real.promptSuffix)
|
|
91
|
+
expect(preview.referenceImageUrls).toHaveLength(real.referenceImageUrls.length)
|
|
92
|
+
})
|
|
93
|
+
})
|