@nodaro/prompts 1.11.0 → 1.13.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 +627 -177
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +726 -33
- package/dist/index.d.ts +726 -33
- package/dist/index.js +598 -179
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
- package/src/__tests__/assemble-image-input.test.ts +100 -19
- package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
- package/src/__tests__/assemble-video-input.test.ts +167 -33
- package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/prompt-style-section.test.ts +345 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/style-section-boundary.test.ts +179 -0
- package/src/__tests__/subject-fold.test.ts +251 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +169 -41
- package/src/assemble-video-input.ts +200 -25
- package/src/direction-registry.ts +116 -28
- package/src/hint-shedding.ts +87 -0
- package/src/index.ts +3 -0
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +628 -88
- package/src/prompt-hint-join.ts +9 -0
- package/src/prompt-style-section.ts +256 -0
- package/src/read-node-direction.ts +60 -1
- package/src/subject-registry.ts +464 -0
- package/src/video-reference-resolver.ts +5 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
3
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Seam whitespace at a resolved mention (image path).
|
|
7
|
+
*
|
|
8
|
+
* A live prod payload assembled to
|
|
9
|
+
*
|
|
10
|
+
* "the person from reference image A and reference image C walking …"
|
|
11
|
+
*
|
|
12
|
+
* — TWO spaces after the character phrase. An editor serializes a mention chip
|
|
13
|
+
* as its token plus its own trailing space, and the prose that follows carries
|
|
14
|
+
* the space the author typed, so an ordinary sentence reaches the route as
|
|
15
|
+
* `@panda:1 and @panda2:2 …`. The VIDEO core never shows this: every return of
|
|
16
|
+
* `resolveVideoReferenceCore` runs `resolveReferenceTokens`, whose
|
|
17
|
+
* `[^\S\r\n]{2,}` collapse tidies the gap. The image path had no such tidy, so
|
|
18
|
+
* the doubled space landed in the model's prompt exactly where the reference is
|
|
19
|
+
* being described.
|
|
20
|
+
*
|
|
21
|
+
* `spliceMentionPhrase` now collapses the horizontal whitespace on either side
|
|
22
|
+
* of each splice seam — scoped to the seam, so prose the author double-spaced
|
|
23
|
+
* elsewhere is left alone and an already-single-spaced prompt is byte-identical.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const panda: ConnectedReference = {
|
|
27
|
+
id: "c1", defaultName: "Panda", source: "wired-character",
|
|
28
|
+
url: "https://cdn/panda.png", characterSlug: "panda",
|
|
29
|
+
}
|
|
30
|
+
const panda2: ConnectedReference = {
|
|
31
|
+
id: "n2", defaultName: "Panda2", source: "wired-image", url: "https://cdn/panda2.png",
|
|
32
|
+
}
|
|
33
|
+
const beach: ConnectedReference = {
|
|
34
|
+
id: "l1", defaultName: "Beach", source: "wired-location",
|
|
35
|
+
url: "https://cdn/beach.png", locationSlug: "beach",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REFS = [panda, panda2, beach]
|
|
39
|
+
|
|
40
|
+
/** Mention URLs merge character → location → image, so the letters are
|
|
41
|
+
* A = the character, B = the location, C = the named image. */
|
|
42
|
+
const EXPECTED =
|
|
43
|
+
"the person from reference image A and reference image C walking in "
|
|
44
|
+
+ "the location from reference image B at dusk"
|
|
45
|
+
|
|
46
|
+
const build = (prompt: string) => buildImagePrompt({
|
|
47
|
+
prompt,
|
|
48
|
+
connectedReferences: REFS,
|
|
49
|
+
provider: "nano-banana-pro",
|
|
50
|
+
referenceFormat: "hybrid",
|
|
51
|
+
}).prompt
|
|
52
|
+
|
|
53
|
+
describe("mention splice — seam whitespace", () => {
|
|
54
|
+
it("Tal's shape: character + image + location in one sentence, single-spaced throughout", () => {
|
|
55
|
+
expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
|
|
59
|
+
expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("collapses on the LEADING side of a seam too", () => {
|
|
63
|
+
expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("never emits a doubled space, whichever side of the chip carries it", () => {
|
|
67
|
+
for (const prompt of [
|
|
68
|
+
"@panda:1 and @panda2:2 walking in @beach:3 at dusk",
|
|
69
|
+
"@panda:1 and @panda2:2 walking in @beach:3 at dusk",
|
|
70
|
+
"@panda:1 and @panda2:2 walking in @beach:3 at dusk",
|
|
71
|
+
]) {
|
|
72
|
+
expect(build(prompt)).toBe(EXPECTED)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("leaves a doubled space the author put in their own prose alone (seam-scoped, not a body tidy)", () => {
|
|
77
|
+
expect(build("@panda:1 walks slowly past @beach:3")).toBe(
|
|
78
|
+
"the person from reference image A walks slowly past the location from reference image B",
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("preserves newlines — the collapse class is horizontal-only", () => {
|
|
83
|
+
expect(build("@panda:1 runs\n\n@beach:3 at dusk")).toBe(
|
|
84
|
+
"the person from reference image A runs\n\nthe location from reference image B at dusk",
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// The `{image:N:label}` positional pill is a mention chip like any other:
|
|
90
|
+
// `buildRefPillNodes` appends its own trailing space to EVERY pill it builds,
|
|
91
|
+
// the positional `imageRef` node included. Its hybrid expansion used a raw
|
|
92
|
+
// `.replace`, so the same doubled space survived on this path after the three
|
|
93
|
+
// `@`-mention resolvers were fixed.
|
|
94
|
+
describe("mention splice — the {image:N:label} positional pill", () => {
|
|
95
|
+
const hat: ConnectedReference = {
|
|
96
|
+
id: "n1", defaultName: "Hat", source: "wired-image", url: "https://cdn/hat.png",
|
|
97
|
+
}
|
|
98
|
+
const coat: ConnectedReference = {
|
|
99
|
+
id: "n2", defaultName: "Coat", source: "wired-image", url: "https://cdn/coat.png",
|
|
100
|
+
}
|
|
101
|
+
const buildTokens = (prompt: string, refs: readonly ConnectedReference[]) => buildImagePrompt({
|
|
102
|
+
prompt,
|
|
103
|
+
connectedReferences: [...refs],
|
|
104
|
+
referenceImageUrls: refs.map((r) => r.url!),
|
|
105
|
+
provider: "nano-banana-pro",
|
|
106
|
+
referenceFormat: "hybrid",
|
|
107
|
+
}).prompt
|
|
108
|
+
|
|
109
|
+
it("collapses the trailing seam the pill's own space creates", () => {
|
|
110
|
+
expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
|
|
111
|
+
"A man wearing the hat from reference image A in the park",
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("the already-single-spaced prompt is byte-identical", () => {
|
|
116
|
+
expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
|
|
117
|
+
"A man wearing the hat from reference image A in the park",
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("two pills in one sentence both splice cleanly (right-to-left offsets stay valid)", () => {
|
|
122
|
+
expect(buildTokens("a man wearing {image:1:hat} and {image:2:coat} walks", [hat, coat])).toBe(
|
|
123
|
+
"A man wearing the hat from reference image A and the coat from reference image B walks",
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("leaves a doubled space the author put in their own prose alone", () => {
|
|
128
|
+
expect(buildTokens("{image:1:hat} worn loosely", [hat])).toBe(
|
|
129
|
+
"The hat from reference image A worn loosely",
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("an out-of-range token stays visible, and its whitespace with it", () => {
|
|
134
|
+
expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).toContain(
|
|
135
|
+
"{image:7:hat} in the park",
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Indentation is structure, not a seam. A run that OPENS a line is the author's
|
|
141
|
+
// layout (a shot list, a numbered beat sheet), so the leading collapse is
|
|
142
|
+
// anchored to prose on the same line via `(?<=\S)`. Without that anchor a
|
|
143
|
+
// mention starting an indented line flattened the indent to a single space —
|
|
144
|
+
// the same class of damage as merging paragraphs, and it would have made the
|
|
145
|
+
// "an already-single-spaced prompt is byte-identical" claim false for every
|
|
146
|
+
// multi-line prompt.
|
|
147
|
+
describe("mention splice — line-initial indentation survives", () => {
|
|
148
|
+
/** One character ref only, so no unmentioned ref appends a canonical phrase
|
|
149
|
+
* and the assertions can be byte-exact on the multi-line shape. */
|
|
150
|
+
const buildOne = (prompt: string) => buildImagePrompt({
|
|
151
|
+
prompt,
|
|
152
|
+
connectedReferences: [panda],
|
|
153
|
+
provider: "nano-banana-pro",
|
|
154
|
+
referenceFormat: "hybrid",
|
|
155
|
+
}).prompt
|
|
156
|
+
|
|
157
|
+
it("a mention that opens an indented line keeps the indent verbatim", () => {
|
|
158
|
+
expect(buildOne("Scene:\n @panda:1 stands")).toBe(
|
|
159
|
+
"Scene:\n the person from reference image A stands",
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("indent preserved AND the trailing seam still collapses on the same mention", () => {
|
|
164
|
+
expect(buildOne("Scene:\n @panda:1 stands")).toBe(
|
|
165
|
+
"Scene:\n the person from reference image A stands",
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("a leading run at the very start of the prompt is indentation too", () => {
|
|
170
|
+
expect(buildOne(" @panda:1 stands")).toBe(
|
|
171
|
+
" the person from reference image A stands",
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("mid-line prose before the run still collapses (the anchor is `\\S`, not `^`)", () => {
|
|
176
|
+
expect(buildOne("Scene:\n a man and @panda:1 stands")).toBe(
|
|
177
|
+
"Scene:\n a man and the person from reference image A stands",
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Wired CREATURE / OBJECT mentions (`@<name-slug>:<index>[:<role>]`) are chips
|
|
183
|
+
// too — `buildRefPillNodes` gives them the same trailing space every other pill
|
|
184
|
+
// gets — so their resolver splices through `spliceMentionPhrase` alongside the
|
|
185
|
+
// character, location and named-image ones. Without that, the seam bug would
|
|
186
|
+
// have been fixed for three mention kinds and reintroduced by the fourth.
|
|
187
|
+
describe("mention splice — wired creature / object mentions", () => {
|
|
188
|
+
const nessie: ConnectedReference = {
|
|
189
|
+
id: "cr1", defaultName: "Nessie", source: "wired-creature", url: "https://cdn/nessie.png",
|
|
190
|
+
}
|
|
191
|
+
const chair: ConnectedReference = {
|
|
192
|
+
id: "ob1", defaultName: "Chair", source: "wired-object", url: "https://cdn/chair.png",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** BOTH entities are mentioned in every case, so neither appends a trailing
|
|
196
|
+
* canonical phrase and the assertions stay byte-exact on one line. */
|
|
197
|
+
const buildEntities = (prompt: string) => buildImagePrompt({
|
|
198
|
+
prompt,
|
|
199
|
+
connectedReferences: [nessie, chair],
|
|
200
|
+
provider: "nano-banana-pro",
|
|
201
|
+
referenceFormat: "hybrid",
|
|
202
|
+
}).prompt
|
|
203
|
+
|
|
204
|
+
const ENTITIES_EXPECTED =
|
|
205
|
+
"the creature from reference image A looms over "
|
|
206
|
+
+ "the object from reference image B in the fog"
|
|
207
|
+
|
|
208
|
+
it("collapses the seam whitespace the chips' own trailing spaces create", () => {
|
|
209
|
+
expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
|
|
213
|
+
expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("collapses on the LEADING side of an entity seam too", () => {
|
|
217
|
+
expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("never emits a doubled space, whichever side of the chip carries it", () => {
|
|
221
|
+
for (const prompt of [
|
|
222
|
+
"@nessie:1 looms over @chair:2 in the fog",
|
|
223
|
+
"@nessie:1 looms over @chair:2 in the fog",
|
|
224
|
+
"@nessie:1 looms over @chair:2 in the fog",
|
|
225
|
+
]) {
|
|
226
|
+
expect(buildEntities(prompt)).toBe(ENTITIES_EXPECTED)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("a ROLE segment splices the same way", () => {
|
|
231
|
+
expect(buildEntities("@nessie:1:markings over @chair:2:material in the fog")).toBe(
|
|
232
|
+
"the markings from reference image A over "
|
|
233
|
+
+ "the material from reference image B in the fog",
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("leaves a doubled space the author put in their own prose alone (seam-scoped)", () => {
|
|
238
|
+
expect(buildEntities("@nessie:1 looms slowly over @chair:2 in the fog")).toBe(
|
|
239
|
+
"the creature from reference image A looms slowly over "
|
|
240
|
+
+ "the object from reference image B in the fog",
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("preserves newlines — the collapse class is horizontal-only", () => {
|
|
245
|
+
expect(buildEntities("@nessie:1 looms\n\n@chair:2 in the fog")).toBe(
|
|
246
|
+
"the creature from reference image A looms\n\n"
|
|
247
|
+
+ "the object from reference image B in the fog",
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("an entity mention that opens an indented line keeps the indent verbatim", () => {
|
|
252
|
+
expect(buildEntities("Scene:\n @nessie:1 looms over @chair:2 in the fog")).toBe(
|
|
253
|
+
"Scene:\n the creature from reference image A looms over "
|
|
254
|
+
+ "the object from reference image B in the fog",
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
STYLE_SECTION_HEADER,
|
|
4
|
+
asBodyClauses,
|
|
5
|
+
composeSectionedPrompt,
|
|
6
|
+
endsInsideStyleSection,
|
|
7
|
+
insertBeforeStyleSection,
|
|
8
|
+
partitionStyleClauses,
|
|
9
|
+
renderStyleSection,
|
|
10
|
+
sectionedClauseCosts,
|
|
11
|
+
splitStyleSection,
|
|
12
|
+
styleSectionFromClauses,
|
|
13
|
+
} from "../prompt-style-section.js"
|
|
14
|
+
import {
|
|
15
|
+
DIRECTION_FIELDS,
|
|
16
|
+
FILM_STYLE_KEYS,
|
|
17
|
+
IMAGE_HINT_MODE_DEFAULT,
|
|
18
|
+
VIDEO_HINT_MODE_DEFAULT,
|
|
19
|
+
directionFieldsForSurface,
|
|
20
|
+
renderDirectionHints,
|
|
21
|
+
} from "../direction-registry.js"
|
|
22
|
+
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
23
|
+
import { getStylePromptHint } from "../style.js"
|
|
24
|
+
import { getColorLookPromptHint } from "../color-look.js"
|
|
25
|
+
import { getEraPromptHint } from "../era.js"
|
|
26
|
+
import { getCameraFormatPromptHint } from "../camera-format.js"
|
|
27
|
+
import { getFramingPromptHint } from "../framing.js"
|
|
28
|
+
import { getLightingPromptHint } from "../lighting.js"
|
|
29
|
+
import { getCameraMotionTerm } from "../camera-motions.js"
|
|
30
|
+
import { getTransitionTerm } from "../transitions.js"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* THE `[style]` SECTION CONTRACT, at the level it is defined: clauses in, one
|
|
34
|
+
* string out. The composers (`assembleImageInput`, `composeVideoPromptText`)
|
|
35
|
+
* are pinned against the same shape in their own suites; what lives here is the
|
|
36
|
+
* grammar itself — which clause lands on which line, and the exact bytes.
|
|
37
|
+
*
|
|
38
|
+
* The header is asserted as a LITERAL everywhere below, never through
|
|
39
|
+
* `STYLE_SECTION_HEADER`, so a typo in the constant fails here instead of
|
|
40
|
+
* silently redefining the contract.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// Real catalog ids: every `get*PromptHint` returns "" on a miss, so a made-up
|
|
44
|
+
// id would make most of these assertions vacuously pass.
|
|
45
|
+
const STYLE = "anime" // look • film
|
|
46
|
+
const COLOR_LOOK = "teal-orange" // look • film
|
|
47
|
+
const ERA = "1920s-flapper" // look • film
|
|
48
|
+
const CAMERA_FORMAT = "16mm-film" // look • film
|
|
49
|
+
const SHOT_SIZE = "wide-shot" // look • scene
|
|
50
|
+
const TIME_OF_DAY = "golden-hour" // look • scene
|
|
51
|
+
const CAMERA_MOTION = "handheld" // motion • body
|
|
52
|
+
const TRANSITION = "cross-dissolve" // motion • body
|
|
53
|
+
|
|
54
|
+
const IMAGE = { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT } as const
|
|
55
|
+
const VIDEO = { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT } as const
|
|
56
|
+
|
|
57
|
+
describe("the section header", () => {
|
|
58
|
+
it("is exactly `[style]:`, lowercase", () => {
|
|
59
|
+
expect(STYLE_SECTION_HEADER).toBe("[style]:")
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe("FILM_STYLE_KEYS — the film/scene split lives in the registry", () => {
|
|
64
|
+
it("names the five film rows, in table order", () => {
|
|
65
|
+
expect(FILM_STYLE_KEYS).toEqual([
|
|
66
|
+
"cameraFormat",
|
|
67
|
+
"colorLook",
|
|
68
|
+
"style",
|
|
69
|
+
"era",
|
|
70
|
+
"cameraFormatId",
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("derives from the table's own `styleGroup` column (no second list)", () => {
|
|
75
|
+
expect(FILM_STYLE_KEYS).toEqual(
|
|
76
|
+
DIRECTION_FIELDS.filter((f) => "styleGroup" in f && f.styleGroup === "film").map(
|
|
77
|
+
(f) => f.key,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("marks only LOOK rows as film (a motion row could never reach the section)", () => {
|
|
83
|
+
for (const spec of DIRECTION_FIELDS) {
|
|
84
|
+
if ("styleGroup" in spec && spec.styleGroup === "film") {
|
|
85
|
+
expect(spec.family, spec.key).toBe("look")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("partitionStyleClauses — which slot a clause lands in", () => {
|
|
92
|
+
it("sends the MOTION family to the body and the LOOK family to the section", () => {
|
|
93
|
+
const slots = new Map(
|
|
94
|
+
partitionStyleClauses(
|
|
95
|
+
{ cameraMotion: CAMERA_MOTION, transition: TRANSITION, style: STYLE, shotSize: SHOT_SIZE },
|
|
96
|
+
VIDEO,
|
|
97
|
+
).map((c) => [c.text, c.slot]),
|
|
98
|
+
)
|
|
99
|
+
expect(slots.get(getCameraMotionTerm(CAMERA_MOTION))).toBe("body")
|
|
100
|
+
expect(slots.get(getTransitionTerm(TRANSITION))).toBe("body")
|
|
101
|
+
expect(slots.get(getStylePromptHint(STYLE))).toBe("film")
|
|
102
|
+
expect(slots.get(getFramingPromptHint(SHOT_SIZE))).toBe("scene")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("leaves the image surface with no body clause at all (no motion row folds there)", () => {
|
|
106
|
+
// The surface filter and the family split are deliberately aligned: every
|
|
107
|
+
// image-surface direction row is `look`, so an image `[style]` section
|
|
108
|
+
// carries the WHOLE direction fold and the body carries none of it.
|
|
109
|
+
expect(directionFieldsForSurface("image").every((f) => f.family === "look")).toBe(true)
|
|
110
|
+
const everyImageKey = Object.fromEntries(
|
|
111
|
+
directionFieldsForSurface("image").map((f) => [f.key, ""]),
|
|
112
|
+
)
|
|
113
|
+
expect(
|
|
114
|
+
partitionStyleClauses({ ...everyImageKey, style: STYLE, shotSize: SHOT_SIZE }, IMAGE).every(
|
|
115
|
+
(c) => c.slot !== "body",
|
|
116
|
+
),
|
|
117
|
+
).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("keeps registry table order inside each slot", () => {
|
|
121
|
+
const direction = { style: STYLE, colorLook: COLOR_LOOK, shotSize: SHOT_SIZE, timeOfDay: TIME_OF_DAY }
|
|
122
|
+
expect(partitionStyleClauses(direction, IMAGE).map((c) => c.text)).toEqual(
|
|
123
|
+
renderDirectionHints(direction, IMAGE),
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("returns nothing for an absent, empty or unresolvable direction", () => {
|
|
128
|
+
expect(partitionStyleClauses(undefined, IMAGE)).toEqual([])
|
|
129
|
+
expect(partitionStyleClauses({}, IMAGE)).toEqual([])
|
|
130
|
+
expect(partitionStyleClauses({ style: "__no_such_id__" }, IMAGE)).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe("renderStyleSection — the two lines", () => {
|
|
135
|
+
it("puts the film line first and the scene line second, each `. `-joined", () => {
|
|
136
|
+
expect(
|
|
137
|
+
renderStyleSection(
|
|
138
|
+
{
|
|
139
|
+
cameraFormat: CAMERA_FORMAT,
|
|
140
|
+
colorLook: COLOR_LOOK,
|
|
141
|
+
style: STYLE,
|
|
142
|
+
era: ERA,
|
|
143
|
+
shotSize: SHOT_SIZE,
|
|
144
|
+
timeOfDay: TIME_OF_DAY,
|
|
145
|
+
},
|
|
146
|
+
IMAGE,
|
|
147
|
+
),
|
|
148
|
+
).toBe(
|
|
149
|
+
"[style]:\n" +
|
|
150
|
+
[
|
|
151
|
+
getCameraFormatPromptHint(CAMERA_FORMAT),
|
|
152
|
+
getColorLookPromptHint(COLOR_LOOK),
|
|
153
|
+
getStylePromptHint(STYLE),
|
|
154
|
+
getEraPromptHint(ERA),
|
|
155
|
+
].join(". ") +
|
|
156
|
+
"\n" +
|
|
157
|
+
[getFramingPromptHint(SHOT_SIZE), getLightingPromptHint(TIME_OF_DAY)].join(". "),
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("omits the film line entirely when no film dimension is selected", () => {
|
|
162
|
+
expect(renderStyleSection({ shotSize: SHOT_SIZE }, IMAGE)).toBe(
|
|
163
|
+
`[style]:\n${getFramingPromptHint(SHOT_SIZE)}`,
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("omits the scene line entirely when no other look dimension is selected", () => {
|
|
168
|
+
expect(renderStyleSection({ style: STYLE }, IMAGE)).toBe(
|
|
169
|
+
`[style]:\n${getStylePromptHint(STYLE)}`,
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("renders NOTHING when the fold carries no look clause", () => {
|
|
174
|
+
expect(renderStyleSection(undefined, VIDEO)).toBe("")
|
|
175
|
+
expect(renderStyleSection({}, VIDEO)).toBe("")
|
|
176
|
+
expect(renderStyleSection({ cameraMotion: CAMERA_MOTION, transition: TRANSITION }, VIDEO)).toBe("")
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("never indents a line and never ends with a newline", () => {
|
|
180
|
+
// The video reference resolver collapses 2+ HORIZONTAL spaces unanchored,
|
|
181
|
+
// so an indented section line would come back flattened — the section is
|
|
182
|
+
// written flush-left instead of relying on the collapse leaving it alone.
|
|
183
|
+
const section = renderStyleSection(
|
|
184
|
+
{ style: STYLE, colorLook: COLOR_LOOK, shotSize: SHOT_SIZE },
|
|
185
|
+
IMAGE,
|
|
186
|
+
)
|
|
187
|
+
for (const line of section.split("\n")) expect(line).toBe(line.trimStart())
|
|
188
|
+
expect(section.endsWith("\n")).toBe(false)
|
|
189
|
+
expect(section).not.toMatch(/[^\S\r\n]{2,}/)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("agrees with the clause-level renderer the composers use", () => {
|
|
193
|
+
const direction = { style: STYLE, shotSize: SHOT_SIZE }
|
|
194
|
+
expect(renderStyleSection(direction, VIDEO)).toBe(
|
|
195
|
+
styleSectionFromClauses(partitionStyleClauses(direction, VIDEO)),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe("composeSectionedPrompt — body, gap, section", () => {
|
|
201
|
+
const FILM = getStylePromptHint(STYLE)
|
|
202
|
+
const SCENE = getFramingPromptHint(SHOT_SIZE)
|
|
203
|
+
const clauses = [
|
|
204
|
+
{ text: "a knight rides", slot: "body" },
|
|
205
|
+
{ text: FILM, slot: "film" },
|
|
206
|
+
{ text: SCENE, slot: "scene" },
|
|
207
|
+
] as const
|
|
208
|
+
|
|
209
|
+
it("joins body clauses with `. ` and hangs the section off a blank line", () => {
|
|
210
|
+
expect(composeSectionedPrompt("at dusk", clauses, "")).toBe(
|
|
211
|
+
`at dusk. a knight rides\n\n[style]:\n${FILM}\n${SCENE}`,
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("keeps the structured fragment last IN THE BODY, ahead of the section", () => {
|
|
216
|
+
expect(composeSectionedPrompt("at dusk", clauses, "Subject: a knight.")).toBe(
|
|
217
|
+
`at dusk. a knight rides. Subject: a knight.\n\n[style]:\n${FILM}\n${SCENE}`,
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("emits NO header and no extra newline when nothing reaches the section", () => {
|
|
222
|
+
// Byte-identical to the plain hint join — this is what keeps every
|
|
223
|
+
// look-free caller (and every fully-shed one) exactly where it was.
|
|
224
|
+
const bodyOnly = [{ text: "a knight rides", slot: "body" }] as const
|
|
225
|
+
expect(composeSectionedPrompt("at dusk", bodyOnly, "")).toBe(
|
|
226
|
+
joinPromptHints("at dusk", ["a knight rides"]),
|
|
227
|
+
)
|
|
228
|
+
expect(composeSectionedPrompt("at dusk", bodyOnly, "")).not.toContain("[style]")
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("returns the prompt VERBATIM AND UNTRIMMED with no clause and no fragment", () => {
|
|
232
|
+
expect(composeSectionedPrompt(" a knight \n", [], "")).toBe(" a knight \n")
|
|
233
|
+
expect(composeSectionedPrompt(undefined, [], "")).toBeUndefined()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("TRIMS the prompt when the section is the only thing folded", () => {
|
|
237
|
+
// The section counts as "something folded", so the body is trimmed exactly
|
|
238
|
+
// as the hint-join branch trims it — otherwise the blank line would inherit
|
|
239
|
+
// the prompt's trailing whitespace.
|
|
240
|
+
expect(composeSectionedPrompt(" a knight \n", [{ text: FILM, slot: "film" }], "")).toBe(
|
|
241
|
+
`a knight\n\n[style]:\n${FILM}`,
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("drops the gap for a blank or absent prompt (never a leading newline)", () => {
|
|
246
|
+
const only = [{ text: FILM, slot: "film" }] as const
|
|
247
|
+
expect(composeSectionedPrompt("", only, "")).toBe(`[style]:\n${FILM}`)
|
|
248
|
+
expect(composeSectionedPrompt(" ", only, "")).toBe(`[style]:\n${FILM}`)
|
|
249
|
+
expect(composeSectionedPrompt(undefined, only, "")).toBe(`[style]:\n${FILM}`)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("never ends the composed prompt with a newline", () => {
|
|
253
|
+
for (const prompt of ["a knight", "", undefined]) {
|
|
254
|
+
expect(composeSectionedPrompt(prompt, clauses, "Subject: a knight.")!.endsWith("\n")).toBe(
|
|
255
|
+
false,
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("marks every subject clause as body", () => {
|
|
261
|
+
expect(asBodyClauses(["a woman in her 30s", "wearing a red coat"])).toEqual([
|
|
262
|
+
{ text: "a woman in her 30s", slot: "body" },
|
|
263
|
+
{ text: "wearing a red coat", slot: "body" },
|
|
264
|
+
])
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe("sectionedClauseCosts — what each clause really costs", () => {
|
|
269
|
+
const clauses = [
|
|
270
|
+
{ text: "a knight rides", slot: "body" },
|
|
271
|
+
{ text: getStylePromptHint(STYLE), slot: "film" },
|
|
272
|
+
{ text: getFramingPromptHint(SHOT_SIZE), slot: "scene" },
|
|
273
|
+
] as const
|
|
274
|
+
|
|
275
|
+
it("is the exact composed-length delta of each clause, tail-first", () => {
|
|
276
|
+
const costs = sectionedClauseCosts("at dusk", clauses, "")
|
|
277
|
+
expect(costs).toHaveLength(clauses.length)
|
|
278
|
+
for (let kept = 0; kept < clauses.length; kept++) {
|
|
279
|
+
const below = composeSectionedPrompt("at dusk", clauses.slice(0, kept), "")?.length ?? 0
|
|
280
|
+
const at = composeSectionedPrompt("at dusk", clauses.slice(0, kept + 1), "")?.length ?? 0
|
|
281
|
+
expect(costs[kept]).toBe(at - below)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("charges the LAST surviving look clause for the header it keeps alive", () => {
|
|
286
|
+
// "\n\n[style]:\n" is 11 characters that only come back when the section
|
|
287
|
+
// disappears entirely — so the first look clause carries them, and a shed
|
|
288
|
+
// that drops it reclaims more than the clause's own text.
|
|
289
|
+
const costs = sectionedClauseCosts("at dusk", clauses, "")
|
|
290
|
+
expect(costs[1]).toBe(getStylePromptHint(STYLE).length + "\n\n[style]:\n".length)
|
|
291
|
+
// The second look clause only brings its own line separator.
|
|
292
|
+
expect(costs[2]).toBe(getFramingPromptHint(SHOT_SIZE).length + "\n".length)
|
|
293
|
+
// A body clause brings the ". " it was joined with.
|
|
294
|
+
expect(costs[0]).toBe("a knight rides".length + ". ".length)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe("the section boundary — what a later assembler may append", () => {
|
|
299
|
+
const FILM = getStylePromptHint(STYLE)
|
|
300
|
+
const clauses = [{ text: FILM, slot: "film" }] as const
|
|
301
|
+
const composed = composeSectionedPrompt("a knight", clauses, "")!
|
|
302
|
+
const bodyless = composeSectionedPrompt("", clauses, "")!
|
|
303
|
+
|
|
304
|
+
it("splits a composed prompt into its body and its section", () => {
|
|
305
|
+
expect(splitStyleSection(composed)).toEqual({ body: "a knight", section: `[style]:\n${FILM}` })
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("splits the body-less form, where the section IS the prompt", () => {
|
|
309
|
+
expect(splitStyleSection(bodyless)).toEqual({ body: "", section: `[style]:\n${FILM}` })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("reports no section for a prompt that carries none", () => {
|
|
313
|
+
expect(splitStyleSection("a knight")).toEqual({ body: "a knight", section: "" })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("inserts body lines AHEAD of the section, keeping the look clauses last", () => {
|
|
317
|
+
expect(insertBeforeStyleSection(composed, ["the person from reference image A"])).toBe(
|
|
318
|
+
`a knight\nthe person from reference image A\n\n[style]:\n${FILM}`,
|
|
319
|
+
)
|
|
320
|
+
expect(insertBeforeStyleSection(bodyless, ["the person from reference image A"])).toBe(
|
|
321
|
+
`the person from reference image A\n\n[style]:\n${FILM}`,
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("is the plain `\\n` join with no section — the byte-parity path", () => {
|
|
326
|
+
// What every appender emitted before the section existed, including the
|
|
327
|
+
// leading newline an empty prompt produces. Anything else would move bytes
|
|
328
|
+
// on the look-free runs, which are most of them.
|
|
329
|
+
expect(insertBeforeStyleSection("a knight", ["a", "b"])).toBe("a knight\na\nb")
|
|
330
|
+
expect(insertBeforeStyleSection("", ["a"])).toBe("\na")
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("is a no-op with no lines to add", () => {
|
|
334
|
+
expect(insertBeforeStyleSection(composed, [])).toBe(composed)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it("knows when a prompt ends INSIDE the section", () => {
|
|
338
|
+
expect(endsInsideStyleSection(composed)).toBe(true)
|
|
339
|
+
expect(endsInsideStyleSection(bodyless)).toBe(true)
|
|
340
|
+
// A blank line closes the header's scope, and the next appender sees it.
|
|
341
|
+
expect(endsInsideStyleSection(`${composed}\n\nStyle: cinematic`)).toBe(false)
|
|
342
|
+
expect(endsInsideStyleSection("a knight")).toBe(false)
|
|
343
|
+
expect(endsInsideStyleSection("")).toBe(false)
|
|
344
|
+
})
|
|
345
|
+
})
|