@nodaro/prompts 1.26.1 → 1.27.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 +2776 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1611 -3
- package/dist/index.d.ts +1611 -3
- package/dist/index.js +2761 -31
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/picker-art-images.test.ts +191 -0
- package/src/__tests__/transitions-scope.test.ts +178 -5
- package/src/catalog-sections.ts +24 -0
- package/src/index.ts +3 -0
- package/src/picker-art/character-art-files.generated.ts +1118 -0
- package/src/picker-art/images.ts +83 -0
- package/src/picker-art/index.ts +13 -0
- package/src/picker-art/look-preview-sets.ts +489 -0
- package/src/picker-art/paths.ts +53 -0
- package/src/picker-art/sound-art-files.generated.ts +319 -0
- package/src/picker-art/sound-art-map.ts +722 -0
- package/src/picker-art/sound-art-types.ts +19 -0
- package/src/picker-catalogs.ts +73 -25
- package/src/styling.ts +18 -0
- package/src/transitions.ts +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.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.14.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picker pictures over the API: every option that has a picture gets exactly
|
|
3
|
+
* one absolute `imageUrl`, built from the installation's base URL (or, for
|
|
4
|
+
* the rendered look previews, the Nodaro CDN — Cloud only); every option
|
|
5
|
+
* without one gets none; the Person / Styling topics carry their pictures;
|
|
6
|
+
* and the directory's `imageCount` agrees with what the detail call returns.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from "vitest"
|
|
9
|
+
import {
|
|
10
|
+
LOOK_PREVIEW_SETS,
|
|
11
|
+
PERSON_DIMENSION_SECTIONS,
|
|
12
|
+
PERSON_FIELD_BY_DIMENSION,
|
|
13
|
+
STYLING_DIMENSION_ORDER,
|
|
14
|
+
STYLING_DIMENSION_SECTIONS,
|
|
15
|
+
STYLING_FIELD_BY_DIMENSION,
|
|
16
|
+
characterArtPath,
|
|
17
|
+
getRegisteredPickerCatalogs,
|
|
18
|
+
lookPreviewStillUrl,
|
|
19
|
+
pickerOptionImageUrl,
|
|
20
|
+
projectPickerCatalog,
|
|
21
|
+
soundArtPath,
|
|
22
|
+
summarizePickerCatalogs,
|
|
23
|
+
type PickerCatalog,
|
|
24
|
+
type PickerImageOptions,
|
|
25
|
+
} from "../index.js"
|
|
26
|
+
import type { ProjectedCatalogOption } from "@nodaro/shared"
|
|
27
|
+
|
|
28
|
+
const CLOUD: PickerImageOptions = { baseUrl: "https://app.nodaro.ai", lookPreviews: true }
|
|
29
|
+
const COMMUNITY: PickerImageOptions = { baseUrl: "http://localhost:3000", lookPreviews: false }
|
|
30
|
+
|
|
31
|
+
/** Every projected option of a catalog with the dimension field it belongs to. */
|
|
32
|
+
function projectedOptions(c: PickerCatalog, images?: PickerImageOptions) {
|
|
33
|
+
const p = projectPickerCatalog(c, { images })
|
|
34
|
+
return [
|
|
35
|
+
...(p.options ?? []).map((option) => ({ field: c.valueField, option })),
|
|
36
|
+
...(p.dimensions ?? []).flatMap((d) => d.options.map((option) => ({ field: d.field as string | undefined, option }))),
|
|
37
|
+
] as ReadonlyArray<{ field: string | undefined; option: ProjectedCatalogOption }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The picture an option should get, from the art maps directly (independent of the projection). */
|
|
41
|
+
function expectedUrl(c: PickerCatalog, field: string | undefined, id: string, images: PickerImageOptions): string | undefined {
|
|
42
|
+
const own = characterArtPath(c.catalogId, id) ?? (field ? soundArtPath(c.catalogId, field, id) : undefined)
|
|
43
|
+
if (own) return images.baseUrl.replace(/\/+$/, "") + own
|
|
44
|
+
if (!images.lookPreviews) return undefined
|
|
45
|
+
const render = (LOOK_PREVIEW_SETS as Readonly<Record<string, Readonly<Record<string, string>>>>)[c.nodeType]?.[id]
|
|
46
|
+
return render ? lookPreviewStillUrl(render) : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CATALOGS = getRegisteredPickerCatalogs()
|
|
50
|
+
|
|
51
|
+
describe("picker option imageUrl", () => {
|
|
52
|
+
it.each([["Cloud", CLOUD], ["a self-hosted install", COMMUNITY]] as const)(
|
|
53
|
+
"on %s, every option gets exactly its picture (or none)",
|
|
54
|
+
(_, images) => {
|
|
55
|
+
const wrong: string[] = []
|
|
56
|
+
for (const c of CATALOGS) {
|
|
57
|
+
for (const { field, option } of projectedOptions(c, images)) {
|
|
58
|
+
const expected = expectedUrl(c, field, option.id, images)
|
|
59
|
+
if (option.imageUrl !== expected) wrong.push(`${c.nodeType}.${field}.${option.id}: ${option.imageUrl} ≠ ${expected}`)
|
|
60
|
+
if (expected === undefined && "imageUrl" in option) wrong.push(`${c.nodeType}.${option.id}: an empty imageUrl key`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
expect(wrong).toEqual([])
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
it("self-hosted pictures are absolute on the installation's own host", () => {
|
|
68
|
+
for (const images of [CLOUD, COMMUNITY]) {
|
|
69
|
+
const own = CATALOGS.flatMap((c) => projectedOptions(c, images))
|
|
70
|
+
.map(({ option }) => option.imageUrl)
|
|
71
|
+
.filter((u): u is string => u !== undefined && !u.startsWith("https://cdn.nodaro.ai/"))
|
|
72
|
+
expect(own.length).toBeGreaterThan(1000)
|
|
73
|
+
expect(own.filter((u) => !u.startsWith(`${images.baseUrl}/picker-art/`))).toEqual([])
|
|
74
|
+
expect(own.filter((u) => !/\.[0-9a-f]{8}\.webp$/.test(u))).toEqual([])
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("a self-hosted install never gets a Nodaro CDN picture; Cloud does, for the look pickers", () => {
|
|
79
|
+
const hostsOf = (images: PickerImageOptions) =>
|
|
80
|
+
new Set(
|
|
81
|
+
CATALOGS.flatMap((c) => projectedOptions(c, images))
|
|
82
|
+
.map(({ option }) => option.imageUrl)
|
|
83
|
+
.filter((u): u is string => u !== undefined)
|
|
84
|
+
.map((u) => new URL(u).host),
|
|
85
|
+
)
|
|
86
|
+
expect([...hostsOf(COMMUNITY)]).toEqual(["localhost:3000"])
|
|
87
|
+
expect([...hostsOf(CLOUD)].sort()).toEqual(["app.nodaro.ai", "cdn.nodaro.ai"])
|
|
88
|
+
const mood = projectPickerCatalog(CATALOGS.find((c) => c.nodeType === "mood")!, { images: CLOUD })
|
|
89
|
+
expect(mood.options?.every((o) => o.imageUrl?.startsWith("https://cdn.nodaro.ai/cdn-cgi/image/width=480,"))).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("a camera-motion clip is pictured by a still frame, never the video", () => {
|
|
93
|
+
const cm = projectPickerCatalog(CATALOGS.find((c) => c.nodeType === "camera-motion")!, { images: CLOUD })
|
|
94
|
+
const urls = (cm.options ?? []).map((o) => o.imageUrl).filter((u): u is string => u !== undefined)
|
|
95
|
+
expect(urls.length).toBeGreaterThan(0)
|
|
96
|
+
expect(urls.every((u) => u.includes("/cdn-cgi/media/mode=frame,") || u.includes("/cdn-cgi/image/"))).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("a trailing slash on the base URL does not double the slash", () => {
|
|
100
|
+
const withSlash = pickerOptionImageUrl({ nodeType: "person", catalogId: "person" }, "type", "man", { baseUrl: "http://localhost:3000/" })
|
|
101
|
+
expect(withSlash).toBe(pickerOptionImageUrl({ nodeType: "person", catalogId: "person" }, "type", "man", COMMUNITY))
|
|
102
|
+
expect(withSlash).toMatch(/^http:\/\/localhost:3000\/picker-art\/character\/person\/man\.[0-9a-f]{8}\.webp$/)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("an unusable base URL yields no picture rather than a broken one", () => {
|
|
106
|
+
for (const baseUrl of ["", "not a url", "javascript:alert(1)", "ftp://host"]) {
|
|
107
|
+
expect(pickerOptionImageUrl({ nodeType: "person", catalogId: "person" }, "type", "man", { baseUrl })).toBeUndefined()
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("without picture options, no option carries an imageUrl", () => {
|
|
112
|
+
const any = CATALOGS.flatMap((c) => projectedOptions(c)).filter(({ option }) => "imageUrl" in option)
|
|
113
|
+
expect(any).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("imageUrl rides at both detail levels", () => {
|
|
117
|
+
const person = CATALOGS.find((c) => c.nodeType === "person")!
|
|
118
|
+
for (const detail of ["compact", "full"] as const) {
|
|
119
|
+
const man = projectPickerCatalog(person, { detail, images: CLOUD }).dimensions?.[0]?.options.find((o) => o.id === "man")
|
|
120
|
+
expect(man?.imageUrl).toMatch(/\/picker-art\/character\/person\/man\./)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("picker catalog sections (Person, Styling)", () => {
|
|
126
|
+
it("person returns its topics, as fields, each with its round picture", () => {
|
|
127
|
+
const p = projectPickerCatalog(CATALOGS.find((c) => c.nodeType === "person")!, { images: CLOUD })
|
|
128
|
+
expect(p.sections?.map((s) => s.label)).toEqual(PERSON_DIMENSION_SECTIONS.map((s) => s.label))
|
|
129
|
+
expect(p.sections?.[0]).toEqual({
|
|
130
|
+
label: "Identity",
|
|
131
|
+
fields: PERSON_DIMENSION_SECTIONS[0].dimensions.map((d) => PERSON_FIELD_BY_DIMENSION[d]),
|
|
132
|
+
imageUrl: expect.stringMatching(/^https:\/\/app\.nodaro\.ai\/picker-art\/character\/sections\/identity\.[0-9a-f]{8}\.webp$/),
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("styling returns its topics, as fields, each with its round picture", () => {
|
|
137
|
+
const p = projectPickerCatalog(CATALOGS.find((c) => c.nodeType === "styling")!, { images: COMMUNITY })
|
|
138
|
+
expect(p.sections?.map((s) => s.label)).toEqual(STYLING_DIMENSION_SECTIONS.map((s) => s.label))
|
|
139
|
+
for (const [i, section] of (p.sections ?? []).entries()) {
|
|
140
|
+
expect(section.fields).toEqual(STYLING_DIMENSION_SECTIONS[i].dimensions.map((d) => STYLING_FIELD_BY_DIMENSION[d]))
|
|
141
|
+
expect(section.imageUrl).toMatch(/^http:\/\/localhost:3000\/picker-art\/character\/sections\//)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("every topic field is one of the catalog's dimensions", () => {
|
|
146
|
+
for (const nodeType of ["person", "styling"]) {
|
|
147
|
+
const p = projectPickerCatalog(CATALOGS.find((c) => c.nodeType === nodeType)!, {})
|
|
148
|
+
const fields = new Set((p.dimensions ?? []).map((d) => d.field))
|
|
149
|
+
expect(p.sections?.flatMap((s) => s.fields).filter((f) => !fields.has(f))).toEqual([])
|
|
150
|
+
expect(p.sections?.every((s) => !("imageUrl" in s))).toBe(true) // no pictures without picture options
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("other catalogs have no sections", () => {
|
|
155
|
+
const others = CATALOGS.filter((c) => c.nodeType !== "person" && c.nodeType !== "styling")
|
|
156
|
+
expect(others.filter((c) => projectPickerCatalog(c, { images: CLOUD }).sections !== undefined).map((c) => c.nodeType)).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("STYLING_DIMENSION_SECTIONS lists every styling dimension exactly once", () => {
|
|
160
|
+
const listed = STYLING_DIMENSION_SECTIONS.flatMap((s) => s.dimensions)
|
|
161
|
+
expect(new Set(listed).size).toBe(listed.length)
|
|
162
|
+
expect([...listed].sort()).toEqual([...STYLING_DIMENSION_ORDER].sort())
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe("picker catalog directory imageCount", () => {
|
|
167
|
+
it.each([["Cloud", CLOUD], ["a self-hosted install", COMMUNITY]] as const)(
|
|
168
|
+
"on %s, imageCount is the number of options the detail call pictures",
|
|
169
|
+
(_, images) => {
|
|
170
|
+
const summaries = summarizePickerCatalogs({ images })
|
|
171
|
+
for (const c of CATALOGS) {
|
|
172
|
+
const pictured = projectedOptions(c, images).filter(({ option }) => option.imageUrl).length
|
|
173
|
+
expect(summaries.find((s) => s.nodeType === c.nodeType)?.imageCount, c.nodeType).toBe(pictured)
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
it("counts no picture without picture options", () => {
|
|
179
|
+
expect(summarizePickerCatalogs().every((s) => s.imageCount === 0)).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("the character, music and voice pickers are pictured everywhere; the look pickers on Cloud only", () => {
|
|
183
|
+
const pictured = (images: PickerImageOptions) =>
|
|
184
|
+
new Set(summarizePickerCatalogs({ images }).filter((s) => s.imageCount > 0).map((s) => s.nodeType))
|
|
185
|
+
const everywhere = ["person", "styling", "held-prop", "material", "animal", "music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery"]
|
|
186
|
+
expect([...pictured(COMMUNITY)].sort()).toEqual([...everywhere].sort())
|
|
187
|
+
const cloud = pictured(CLOUD)
|
|
188
|
+
expect(everywhere.every((t) => cloud.has(t))).toBe(true)
|
|
189
|
+
for (const look of ["style", "mood", "lens", "framing", "lighting", "camera-motion"]) expect(cloud.has(look), look).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -57,11 +57,8 @@ describe("approved row bodies", () => {
|
|
|
57
57
|
)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it("zoom-into-mouth
|
|
61
|
-
expect(getTransitionPromptHint("zoom-into-mouth")).
|
|
62
|
-
"the camera pushes into the subject's open mouth, the dark interior fills the frame, and the camera passes " +
|
|
63
|
-
"through into the new scene which materialises as if emerging from inside the body",
|
|
64
|
-
)
|
|
60
|
+
it("zoom-into-mouth never mentions 'the throat' (its body is pinned in the F5 block below)", () => {
|
|
61
|
+
expect(getTransitionPromptHint("zoom-into-mouth")).not.toContain("throat")
|
|
65
62
|
})
|
|
66
63
|
})
|
|
67
64
|
|
|
@@ -184,6 +181,182 @@ describe("F2 + vortex bodies (2026-09-25 A/B: F2 arm B, vortex rework D2)", () =
|
|
|
184
181
|
})
|
|
185
182
|
})
|
|
186
183
|
|
|
184
|
+
describe("F3 bodies (2026-09-25 A/B: F3 arm B)", () => {
|
|
185
|
+
// sun-glare, lens-crack and lightning-flash: the F3 drafts, tidied (first letter lower-cased, final
|
|
186
|
+
// full stop dropped). Each rendered string below is byte-identical to the clause the winning take was
|
|
187
|
+
// generated from.
|
|
188
|
+
const F3_BODIES: Record<string, { term: string; body: string }> = {
|
|
189
|
+
"sun-glare": {
|
|
190
|
+
term: "sun glare",
|
|
191
|
+
body:
|
|
192
|
+
"intense warm glare floods the lens from one corner of the frame, blooming and scattering flares unti" +
|
|
193
|
+
"l the whole picture is washed out to a bright haze. The camera stays where it is and the framing doe" +
|
|
194
|
+
"s not change. As the glare fades, the second shot emerges through the haze. The shot ends on the sec" +
|
|
195
|
+
"ond shot, clear and fully resolved, with no glare left. The glare comes from the lens itself, and th" +
|
|
196
|
+
"e second shot appears only as the haze clears",
|
|
197
|
+
},
|
|
198
|
+
"lens-crack": {
|
|
199
|
+
term: "lens crack",
|
|
200
|
+
body:
|
|
201
|
+
"a hairline crack snaps diagonally across the lens and branches into a web of fracture lines that spr" +
|
|
202
|
+
"eads over the whole picture. The camera stays where it is and the framing does not change. Seen thro" +
|
|
203
|
+
"ugh the cracks, the first shot gives way to the second shot, and then the fracture lines fade away. " +
|
|
204
|
+
"The shot ends on the second shot, clear and fully resolved, with no cracks left. The cracks form on " +
|
|
205
|
+
"the lens itself, while everything behind them stays whole",
|
|
206
|
+
},
|
|
207
|
+
"lightning-flash": {
|
|
208
|
+
term: "lightning strike",
|
|
209
|
+
body:
|
|
210
|
+
"a brilliant jagged bolt of lightning cracks across the frame and its flash turns the whole picture w" +
|
|
211
|
+
"hite. The camera stays where it is and the framing does not change. As the flash dies away, the seco" +
|
|
212
|
+
"nd shot is revealed in its place. The shot ends on the second shot, fully resolved, with no bolt or " +
|
|
213
|
+
"flash left. The change happens inside the flash, and the second shot appears only as the white fades",
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it.each(Object.keys(F3_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
|
|
218
|
+
const { term, body } = F3_BODIES[id]!
|
|
219
|
+
expect(getTransitionPromptHint(id)).toBe(body)
|
|
220
|
+
expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it.each(Object.keys(F3_BODIES))("%s at middle / short / natural", (id) => {
|
|
224
|
+
const { term, body } = F3_BODIES[id]!
|
|
225
|
+
expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
226
|
+
`${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe("F4 bodies (2026-09-25 A/B: F4 arm B)", () => {
|
|
232
|
+
// building-explosion and vehicle-explosion: the F4 drafts, tidied (first letter lower-cased, final
|
|
233
|
+
// full stop dropped). Each rendered string below is byte-identical to the clause the winning take was
|
|
234
|
+
// generated from.
|
|
235
|
+
const F4_BODIES: Record<string, { term: string; body: string }> = {
|
|
236
|
+
"building-explosion": {
|
|
237
|
+
term: "building explosion",
|
|
238
|
+
body:
|
|
239
|
+
"the largest structure in the frame detonates in a massive fireball, and debris and dust plume outwar" +
|
|
240
|
+
"d until they fill the whole picture. The camera stays where it is and the framing does not change. A" +
|
|
241
|
+
"s the dust cloud clears, the second shot is revealed in its place. The shot ends on the second shot," +
|
|
242
|
+
" clear and fully resolved, with no dust or debris left. The blast comes from that structure itself, " +
|
|
243
|
+
"and the second shot appears only as the dust clears",
|
|
244
|
+
},
|
|
245
|
+
"vehicle-explosion": {
|
|
246
|
+
term: "vehicle explosion",
|
|
247
|
+
body:
|
|
248
|
+
"a vehicle in the frame bursts into a violent explosion of fire and twisted metal, and the fireball b" +
|
|
249
|
+
"illows toward the lens until orange flame fills the whole picture. The camera stays where it is and " +
|
|
250
|
+
"the framing does not change. The flame gives way to thick smoke, and as the smoke parts the second s" +
|
|
251
|
+
"hot is revealed. The shot ends on the second shot, clear and fully resolved, with no fire or smoke l" +
|
|
252
|
+
"eft. The explosion comes from that vehicle itself, and the second shot appears only as the smoke par" +
|
|
253
|
+
"ts",
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
it.each(Object.keys(F4_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
|
|
258
|
+
const { term, body } = F4_BODIES[id]!
|
|
259
|
+
expect(getTransitionPromptHint(id)).toBe(body)
|
|
260
|
+
expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it.each(Object.keys(F4_BODIES))("%s at middle / short / natural", (id) => {
|
|
264
|
+
const { term, body } = F4_BODIES[id]!
|
|
265
|
+
expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
266
|
+
`${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe("F5 bodies (2026-09-25 A/B: F5 arm B)", () => {
|
|
272
|
+
// zoom-into-book, pull-out-reveal, zoom-into-mouth, walk-through-door and mask-transition: the F5
|
|
273
|
+
// drafts, tidied (first letter lower-cased, final full stop dropped). Each rendered string below is
|
|
274
|
+
// byte-identical to the clause the winning take was generated from.
|
|
275
|
+
const F5_BODIES: Record<string, { term: string; body: string }> = {
|
|
276
|
+
"zoom-into-book": {
|
|
277
|
+
term: "zoom into book",
|
|
278
|
+
body:
|
|
279
|
+
"the camera pushes down toward the illustrated page of an open book in the frame until the illustrati" +
|
|
280
|
+
"on fills the whole picture. The camera travels in one smooth line, without turning or rolling. The d" +
|
|
281
|
+
"rawn picture comes alive, gaining depth, light and movement, and becomes the second shot. The shot e" +
|
|
282
|
+
"nds inside the second shot, real and fully resolved, with no paper, ink lines or page edges left. Th" +
|
|
283
|
+
"e camera heads for the page from the start and goes into its illustration",
|
|
284
|
+
},
|
|
285
|
+
"pull-out-reveal": {
|
|
286
|
+
term: "pull-back reveal",
|
|
287
|
+
body:
|
|
288
|
+
"the camera pulls straight back fast, and the whole first shot shrinks until its edges show as the bo" +
|
|
289
|
+
"rder of a framed picture inside a larger space. The camera travels back in one smooth line, without " +
|
|
290
|
+
"turning, tilting or rolling. The larger space around the picture is the second shot, and the first s" +
|
|
291
|
+
"hot stays inside the picture. The shot ends on the second shot, with the first shot visible as a pic" +
|
|
292
|
+
"ture within it. The first shot's image stays exactly the same as it shrinks, so it reads as the same" +
|
|
293
|
+
" picture all along",
|
|
294
|
+
},
|
|
295
|
+
"zoom-into-mouth": {
|
|
296
|
+
term: "zoom into mouth",
|
|
297
|
+
body:
|
|
298
|
+
"the camera pushes straight into the first subject's open mouth until the dark interior fills the who" +
|
|
299
|
+
"le picture. The camera travels forward in one smooth line, without turning, tilting or rolling. It p" +
|
|
300
|
+
"asses through the darkness, and the second shot emerges out of it. The shot ends inside the second s" +
|
|
301
|
+
"hot, still and fully resolved, with no mouth left in view. The dark interior stays a plain darkness " +
|
|
302
|
+
"the camera passes through",
|
|
303
|
+
},
|
|
304
|
+
"walk-through-door": {
|
|
305
|
+
term: "walk through doorway",
|
|
306
|
+
body:
|
|
307
|
+
"the camera follows the first subject through a doorway in the frame, moving forward at the subject's" +
|
|
308
|
+
" pace. The camera travels straight forward, without turning, tilting or rolling. On the far side of " +
|
|
309
|
+
"the doorway the space is the second shot, a different place in different light. The shot ends in the" +
|
|
310
|
+
" second shot, fully resolved, with the doorway behind the camera. The change of place happens at the" +
|
|
311
|
+
" doorway itself, the moment the camera passes through it",
|
|
312
|
+
},
|
|
313
|
+
"mask-transition": {
|
|
314
|
+
term: "mask transition",
|
|
315
|
+
body:
|
|
316
|
+
"a dark foreground shape sweeps across the lens and fills the frame with black. The camera keeps trav" +
|
|
317
|
+
"elling forward through the black in one smooth line. It emerges from the darkness into the second sh" +
|
|
318
|
+
"ot. The shot ends in the second shot, fully resolved, with no dark shape left. The dark shape passes" +
|
|
319
|
+
" close to the lens and is gone once the second shot appears",
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
it.each(Object.keys(F5_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
|
|
324
|
+
const { term, body } = F5_BODIES[id]!
|
|
325
|
+
expect(getTransitionPromptHint(id)).toBe(body)
|
|
326
|
+
expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it.each(Object.keys(F5_BODIES))("%s at middle / short / natural", (id) => {
|
|
330
|
+
const { term, body } = F5_BODIES[id]!
|
|
331
|
+
expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
332
|
+
`${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe("shockwave body (2026-09-25 rework: shockwave3 E1)", () => {
|
|
338
|
+
// The E1 draft as tested: a flash bursts into a bright opaque ring that races past every edge, the ring
|
|
339
|
+
// the one hard border between the shots. The rendered string at the tile default is byte-identical to
|
|
340
|
+
// the clause the winning take was generated from.
|
|
341
|
+
const TERM = "shockwave"
|
|
342
|
+
const BODY =
|
|
343
|
+
"a flash at the exact centre of the frame bursts into a sharp, bright ring that races past every edge" +
|
|
344
|
+
" in a moment, trailing a smear of motion blur behind its rim and warping the picture as it goes. The" +
|
|
345
|
+
" camera stays where it is and the picture stays level. The second shot shows only inside the ring an" +
|
|
346
|
+
"d the first only outside it, with the bright ring as the one hard border and no blending anywhere"
|
|
347
|
+
|
|
348
|
+
it("renders `term (body)` at the tile-default levers", () => {
|
|
349
|
+
expect(getTransitionPromptHint("shockwave")).toBe(BODY)
|
|
350
|
+
expect(composeTransitionHintFromConnections("shockwave", [], [], {}, "full", { scope: "shot" })).toBe(`${TERM} (${BODY})`)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it("at middle / short / natural", () => {
|
|
354
|
+
expect(composeTransitionHintFromConnections("shockwave", [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
355
|
+
`${TERM} (${BODY}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
356
|
+
)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
187
360
|
describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
|
|
188
361
|
it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
|
|
189
362
|
const out = composeTransitionHintFromConnections(id, [], [], { position: "full", duration: "short", intensity: "natural" })
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PERSON_DIMENSION_SECTIONS, PERSON_FIELD_BY_DIMENSION } from "./person.js"
|
|
2
|
+
import { STYLING_DIMENSION_SECTIONS, STYLING_FIELD_BY_DIMENSION } from "./styling.js"
|
|
3
|
+
|
|
4
|
+
/** One of the topics the editor groups a catalog's settings under, as node-data fields. */
|
|
5
|
+
export interface CatalogTopic {
|
|
6
|
+
readonly label: string
|
|
7
|
+
readonly fields: ReadonlyArray<string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The editor's topics for a catalog (Person, Styling), in order, with their
|
|
12
|
+
* settings as node-data fields — or undefined for every other catalog. Kept
|
|
13
|
+
* out of the picker-catalogs funnel on purpose: the topic lists are layout
|
|
14
|
+
* metadata, not catalog entries.
|
|
15
|
+
*/
|
|
16
|
+
export function catalogTopics(catalogId: string): ReadonlyArray<CatalogTopic> | undefined {
|
|
17
|
+
if (catalogId === "person") {
|
|
18
|
+
return PERSON_DIMENSION_SECTIONS.map((s) => ({ label: s.label, fields: s.dimensions.map((d) => PERSON_FIELD_BY_DIMENSION[d] as string) }))
|
|
19
|
+
}
|
|
20
|
+
if (catalogId === "styling") {
|
|
21
|
+
return STYLING_DIMENSION_SECTIONS.map((s) => ({ label: s.label, fields: s.dimensions.map((d) => STYLING_FIELD_BY_DIMENSION[d] as string) }))
|
|
22
|
+
}
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -99,3 +99,6 @@ export * from "./character-motion-diagnostics.js"
|
|
|
99
99
|
|
|
100
100
|
// --- Per-ad creative analysis for the social scraper nodes ---
|
|
101
101
|
export * from "./ad-creative-analysis.js"
|
|
102
|
+
|
|
103
|
+
// --- Picker art: option pictures + topic icons (editor and API) ---
|
|
104
|
+
export * from "./picker-art/index.js"
|