@nodaro/prompts 1.26.0 → 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 +2780 -34
- 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 +2765 -35
- 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 +239 -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 +15 -15
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
|
|
|
@@ -123,6 +120,243 @@ describe("F1 bodies — the subject becomes the material and reforms (2026-09-25
|
|
|
123
120
|
})
|
|
124
121
|
})
|
|
125
122
|
|
|
123
|
+
describe("F2 + vortex bodies (2026-09-25 A/B: F2 arm B, vortex rework D2)", () => {
|
|
124
|
+
// sand-storm, paint-splash and aurora-sweep: the F2 drafts, tidied (first letter lower-cased, final
|
|
125
|
+
// full stop dropped). vortex-swirl: the rework's D2 body, as tested. Each rendered string below is
|
|
126
|
+
// byte-identical to the clause the winning take was generated from.
|
|
127
|
+
const F2_BODIES: Record<string, { term: string; body: string }> = {
|
|
128
|
+
"sand-storm": {
|
|
129
|
+
term: "sand storm",
|
|
130
|
+
body:
|
|
131
|
+
"a wall of opaque, swirling ochre sand sweeps in from one side of the frame and surges across it " +
|
|
132
|
+
"until the whole picture is hidden in dust. The camera stays where it is and the framing does not " +
|
|
133
|
+
"change. The dust then thins and sinks away toward the lower edge of the frame, revealing the second " +
|
|
134
|
+
"shot behind it. The shot ends on the second shot, clear and fully resolved, with no dust left. The " +
|
|
135
|
+
"first shot stays as it is until the sand covers it completely, and the second shot appears only as " +
|
|
136
|
+
"the dust clears",
|
|
137
|
+
},
|
|
138
|
+
"paint-splash": {
|
|
139
|
+
term: "paint splash",
|
|
140
|
+
body:
|
|
141
|
+
"vivid splashes of coloured paint fly across the frame in arcing streaks and pile over one another " +
|
|
142
|
+
"until the whole picture is covered in wet paint. The camera stays where it is and the framing does " +
|
|
143
|
+
"not change. The paint then gathers toward the centre of the frame and shrinks to nothing, uncovering" +
|
|
144
|
+
" the second shot from the edges inward. The shot ends on the second shot, clean and fully resolved, " +
|
|
145
|
+
"with no paint left. The paint lies on the surface of the picture itself, and the second shot appears" +
|
|
146
|
+
" only where the paint has gone",
|
|
147
|
+
},
|
|
148
|
+
"aurora-sweep": {
|
|
149
|
+
term: "aurora sweep",
|
|
150
|
+
body:
|
|
151
|
+
"a luminous curtain of green and violet aurora light ripples across the whole frame, and its bright " +
|
|
152
|
+
"bands veil the first shot. The camera stays where it is and the framing does not change. As the " +
|
|
153
|
+
"bands fade, the second shot is revealed behind them. The shot ends on the second shot, clear and " +
|
|
154
|
+
"fully resolved, with no aurora light left. The aurora glows over the front of the picture, and the " +
|
|
155
|
+
"second shot appears only as it fades",
|
|
156
|
+
},
|
|
157
|
+
"vortex-swirl": {
|
|
158
|
+
term: "vortex swirl",
|
|
159
|
+
body:
|
|
160
|
+
"only the first subject twists, winding around its own centre like wrung cloth into a tight narrow " +
|
|
161
|
+
"column at the same place in the frame, turning faster as it narrows, while the surroundings stay " +
|
|
162
|
+
"upright and still. the camera is locked on a tripod head planted in one spot, holding the frame " +
|
|
163
|
+
"level from start to finish. the column then unwinds in the same direction and opens into the shape " +
|
|
164
|
+
"of the second subject, while the second shot appears around it. the shot ends on the second subject," +
|
|
165
|
+
" solid and fully resolved, with nothing left turning. the twist stays inside the outline of the " +
|
|
166
|
+
"subject, and the edges of the frame stay square and still",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
it.each(Object.keys(F2_BODIES))("%s renders `term (body)` at the tile-default levers", (id) => {
|
|
171
|
+
const { term, body } = F2_BODIES[id]!
|
|
172
|
+
expect(getTransitionPromptHint(id)).toBe(body)
|
|
173
|
+
expect(composeTransitionHintFromConnections(id, [], [], {}, "full", { scope: "shot" })).toBe(`${term} (${body})`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it.each(Object.keys(F2_BODIES))("%s at middle / short / natural", (id) => {
|
|
177
|
+
const { term, body } = F2_BODIES[id]!
|
|
178
|
+
expect(composeTransitionHintFromConnections(id, [], [], { position: "middle", duration: "short", intensity: "natural" })).toBe(
|
|
179
|
+
`${term} (${body}), the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
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
|
+
|
|
126
360
|
describe("L1 — a cut spans nothing, so `full` adds no clause", () => {
|
|
127
361
|
it.each(INSTANT_IDS)("%s + full renders no position clause", (id) => {
|
|
128
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"
|