@nodaro/prompts 1.46.0 → 1.48.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.
Files changed (91) hide show
  1. package/dist/{catalog-packs-C9CXEWfl.d.cts → catalog-packs-BDsrv8St.d.cts} +1 -1
  2. package/dist/{catalog-packs-D7NJpo4k.d.ts → catalog-packs-CAK42voB.d.ts} +1 -1
  3. package/dist/{chunk-SMKIVT7P.js → chunk-36XMRVY4.js} +4 -4
  4. package/dist/{chunk-SMKIVT7P.js.map → chunk-36XMRVY4.js.map} +1 -1
  5. package/dist/{chunk-JHEZJKTX.cjs → chunk-54BYDWAM.cjs} +62 -52
  6. package/dist/chunk-54BYDWAM.cjs.map +1 -0
  7. package/dist/{chunk-IKHRZ5UQ.js → chunk-BTV2I7EN.js} +3 -3
  8. package/dist/{chunk-IKHRZ5UQ.js.map → chunk-BTV2I7EN.js.map} +1 -1
  9. package/dist/{chunk-57PTYJJX.js → chunk-CMEUYFBZ.js} +13 -13
  10. package/dist/chunk-CMEUYFBZ.js.map +1 -0
  11. package/dist/{chunk-2OIKQ4WB.js → chunk-GZVCBSFG.js} +5 -5
  12. package/dist/{chunk-2OIKQ4WB.js.map → chunk-GZVCBSFG.js.map} +1 -1
  13. package/dist/{chunk-FCTK7RGO.cjs → chunk-IMQB7GIK.cjs} +42 -39
  14. package/dist/chunk-IMQB7GIK.cjs.map +1 -0
  15. package/dist/{chunk-2HJJBXGA.js → chunk-JHRVNWBS.js} +6 -6
  16. package/dist/{chunk-2HJJBXGA.js.map → chunk-JHRVNWBS.js.map} +1 -1
  17. package/dist/{chunk-OSKABA64.cjs → chunk-JV2WSMAA.cjs} +8 -8
  18. package/dist/{chunk-OSKABA64.cjs.map → chunk-JV2WSMAA.cjs.map} +1 -1
  19. package/dist/{chunk-VAK4RUNU.cjs → chunk-KNAKOSKV.cjs} +13 -13
  20. package/dist/{chunk-VAK4RUNU.cjs.map → chunk-KNAKOSKV.cjs.map} +1 -1
  21. package/dist/{chunk-TXHFL725.js → chunk-LD47QOKS.js} +62 -53
  22. package/dist/chunk-LD47QOKS.js.map +1 -0
  23. package/dist/{chunk-QKD2KUZW.cjs → chunk-MGK6SFTV.cjs} +20 -6
  24. package/dist/chunk-MGK6SFTV.cjs.map +1 -0
  25. package/dist/{chunk-YCCQXX3U.cjs → chunk-MYVJVOPB.cjs} +39 -39
  26. package/dist/{chunk-YCCQXX3U.cjs.map → chunk-MYVJVOPB.cjs.map} +1 -1
  27. package/dist/{chunk-PHR4UETI.cjs → chunk-PJFX3MFI.cjs} +18 -18
  28. package/dist/{chunk-PHR4UETI.cjs.map → chunk-PJFX3MFI.cjs.map} +1 -1
  29. package/dist/{chunk-PUWJ2ZBG.js → chunk-RRHU6MD5.js} +20 -6
  30. package/dist/chunk-RRHU6MD5.js.map +1 -0
  31. package/dist/{chunk-NN4FL7SB.js → chunk-XBRW75LM.js} +4 -4
  32. package/dist/{chunk-NN4FL7SB.js.map → chunk-XBRW75LM.js.map} +1 -1
  33. package/dist/{chunk-C4FDHCEZ.cjs → chunk-XGNUOU4F.cjs} +125 -125
  34. package/dist/{chunk-C4FDHCEZ.cjs.map → chunk-XGNUOU4F.cjs.map} +1 -1
  35. package/dist/{chunk-CPKYIVWP.js → chunk-YXJSPQC6.js} +12 -9
  36. package/dist/chunk-YXJSPQC6.js.map +1 -0
  37. package/dist/{chunk-OY7XOPDG.cjs → chunk-Z755PH3K.cjs} +57 -57
  38. package/dist/chunk-Z755PH3K.cjs.map +1 -0
  39. package/dist/entries/assembly.cjs +28 -28
  40. package/dist/entries/assembly.d.cts +1 -2
  41. package/dist/entries/assembly.d.ts +1 -2
  42. package/dist/entries/assembly.js +5 -5
  43. package/dist/entries/catalog-packs.d.cts +3 -3
  44. package/dist/entries/catalog-packs.d.ts +3 -3
  45. package/dist/entries/person.d.cts +2 -2
  46. package/dist/entries/person.d.ts +2 -2
  47. package/dist/entries/picker-catalogs.cjs +56 -56
  48. package/dist/entries/picker-catalogs.d.cts +26 -4
  49. package/dist/entries/picker-catalogs.d.ts +26 -4
  50. package/dist/entries/picker-catalogs.js +4 -4
  51. package/dist/entries/pickers.cjs +377 -373
  52. package/dist/entries/pickers.d.cts +473 -2
  53. package/dist/entries/pickers.d.ts +473 -2
  54. package/dist/entries/pickers.js +2 -2
  55. package/dist/entries/presets.cjs +35 -35
  56. package/dist/entries/presets.js +1 -1
  57. package/dist/entries/prompt-builder.cjs +75 -75
  58. package/dist/entries/prompt-builder.d.cts +2 -3
  59. package/dist/entries/prompt-builder.d.ts +2 -3
  60. package/dist/entries/prompt-builder.js +4 -4
  61. package/dist/index.cjs +564 -560
  62. package/dist/index.d.cts +5 -6
  63. package/dist/index.d.ts +5 -6
  64. package/dist/index.js +9 -9
  65. package/dist/{picker-catalogs-C3utGMRB.d.cts → picker-catalogs-BiRCs_5G.d.cts} +8 -0
  66. package/dist/{picker-catalogs-BM8IphF1.d.ts → picker-catalogs-CX8pHi8q.d.ts} +8 -0
  67. package/dist/{subject-registry-DFiGx49v.d.ts → subject-registry-Cf802LQ5.d.ts} +8 -4
  68. package/dist/{subject-registry-CQi_CZfJ.d.cts → subject-registry-D2uMBVpP.d.cts} +8 -4
  69. package/package.json +2 -2
  70. package/src/__tests__/assemble-video-input.test.ts +13 -11
  71. package/src/__tests__/direction-registry.test.ts +5 -5
  72. package/src/__tests__/factory-presets.test.ts +26 -2
  73. package/src/__tests__/image-unwired-reference-tokens.test.ts +3 -2
  74. package/src/__tests__/prompt-style-section.test.ts +2 -2
  75. package/src/__tests__/style-video-wording.test.ts +258 -0
  76. package/src/catalog-overlay.ts +15 -1
  77. package/src/direction-registry.ts +25 -7
  78. package/src/factory-presets/generate-image.ts +18 -4
  79. package/src/parameter-prompt-hint.ts +23 -7
  80. package/src/picker-catalogs.ts +11 -0
  81. package/src/style.ts +78 -49
  82. package/dist/chunk-57PTYJJX.js.map +0 -1
  83. package/dist/chunk-CPKYIVWP.js.map +0 -1
  84. package/dist/chunk-FCTK7RGO.cjs.map +0 -1
  85. package/dist/chunk-JHEZJKTX.cjs.map +0 -1
  86. package/dist/chunk-OY7XOPDG.cjs.map +0 -1
  87. package/dist/chunk-PUWJ2ZBG.js.map +0 -1
  88. package/dist/chunk-QKD2KUZW.cjs.map +0 -1
  89. package/dist/chunk-TXHFL725.js.map +0 -1
  90. package/dist/transitions-BLFfAJUH.d.cts +0 -450
  91. package/dist/transitions-BgwWM37i.d.ts +0 -450
@@ -20,7 +20,7 @@ import {
20
20
  renderDirectionHints,
21
21
  } from "../direction-registry.js"
22
22
  import { joinPromptHints } from "../prompt-hint-join.js"
23
- import { getStylePromptHint } from "../style.js"
23
+ import { getStylePromptHint, getStyleVideoPromptHint } from "../style.js"
24
24
  import { getColorLookPromptHint } from "../color-look.js"
25
25
  import { getEraPromptHint } from "../era.js"
26
26
  import { getCameraFormatPromptHint } from "../camera-format.js"
@@ -98,7 +98,7 @@ describe("partitionStyleClauses — which slot a clause lands in", () => {
98
98
  )
99
99
  expect(slots.get(getCameraMotionTerm(CAMERA_MOTION))).toBe("body")
100
100
  expect(slots.get(renderTransitionBases([TRANSITION])[0])).toBe("body")
101
- expect(slots.get(getStylePromptHint(STYLE))).toBe("film")
101
+ expect(slots.get(getStyleVideoPromptHint(STYLE))).toBe("film")
102
102
  expect(slots.get(getFramingPromptHint(SHOT_SIZE))).toBe("scene")
103
103
  })
104
104
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Every style carries a VIDEO-only wording (`videoPromptHint`): what the medium
3
+ * is, then its concrete techniques (the pattern that styled every shot in video
4
+ * tests). Every video surface reads it through `getStyleVideoPromptHint`
5
+ * (falling back to `promptHint` for a pack style without one); every image
6
+ * surface keeps `promptHint`. These tests pin:
7
+ * - stills render exactly the image wording for every style;
8
+ * - video renders each style's own video wording, the three tested on video
9
+ * and the two chosen for 3D Render and Charcoal word for word, the
10
+ * photographic ones still photographic;
11
+ * - a deployment's catalog packs reach the video wording the same way they
12
+ * reach the image wording, and a rewrite of the image wording never ships
13
+ * beside the stock video wording.
14
+ */
15
+ import { afterEach, describe, expect, it } from "vitest"
16
+ import {
17
+ STYLES,
18
+ getStyle,
19
+ getStylePromptHint,
20
+ getStyleVideoPromptHint,
21
+ getPickerCatalog,
22
+ projectPickerCatalog,
23
+ registerCatalogPack,
24
+ resetCatalogPacks,
25
+ renderDirectionHints,
26
+ composeVideoPromptText,
27
+ assembleImageInput,
28
+ getParameterPromptHint,
29
+ type PickerCatalogInput,
30
+ } from "../index.js"
31
+
32
+ afterEach(() => resetCatalogPacks())
33
+
34
+ const LOW_POLY_VIDEO =
35
+ "Stylised low poly 3D animation, computer-generated: every person, face, vehicle and room built from large flat-shaded triangles, visible facets on every surface, flat untextured colours, crisp shadow edges between facets."
36
+ /** The three wordings tested on video (Seedance 2.5), word for word. */
37
+ const TESTED_ON_VIDEO: Readonly<Record<string, string>> = {
38
+ "pop-art": "Flat 2D cel-shaded comic illustration, hand-drawn: thick uniform black ink outlines, flat colour fills, Ben-Day dot shading, bold saturated primary colours, high contrast.",
39
+ claymation: "Stop-motion claymation, handmade: every person, vehicle and set sculpted from matte plasticine clay with visible fingerprints and tool marks, rounded simplified forms, miniature handcrafted diorama sets, soft warm studio lighting.",
40
+ anime: "2D Japanese anime, hand-drawn cel animation: clean thin ink line art, flat cel shading with one hard shadow tone, large expressive eyes, hair drawn as solid shapes, saturated colours, painted backgrounds.",
41
+ }
42
+ /** The 3D Render and Charcoal video wordings as chosen, word for word, beside their unchanged image wordings. */
43
+ const CHOSEN_FOR_VIDEO: Readonly<Record<string, { readonly video: string; readonly image: string }>> = {
44
+ "3d-render": {
45
+ video: "Stylised 3D render animation, computer-generated: every person, vehicle and room modelled in smooth matte CG surfaces, simplified clean forms, soft studio lighting, gentle ambient occlusion, bright clean colours.",
46
+ image: "3D rendered style, modern CGI aesthetic with clean shading, subtle ambient occlusion and crisp materials",
47
+ },
48
+ charcoal: {
49
+ video: "Rough 2D charcoal sketch, hand-drawn on white paper: every person and every face drawn in heavy black charcoal outlines and loose hatched strokes, white paper showing through, smudged edges, bold gestural lines.",
50
+ image: "rendered as a charcoal drawing on toothy paper, soft black and grey tonal masses with smudged blended midtones, sharp accented darks, expressive gestural strokes and the raw immediacy of a fine-art figure study",
51
+ },
52
+ }
53
+ /** The photographic styles: their video wording keeps them photographed, never drawn. */
54
+ const PHOTOGRAPHIC = ["photorealistic", "cinematic", "noir", "retro-vintage", "early-color-photo"]
55
+ const videoOf = (id: string) => STYLES.find((s) => s.id === id)!.videoPromptHint!
56
+ const LOW_POLY_IMAGE =
57
+ "low poly style, faceted geometric 3D aesthetic with visible flat triangular faces, simplified forms and crisp shadow edges between facets"
58
+
59
+ const styleNode = (style: string) => ({ id: "s1", type: "style", data: { style } })
60
+
61
+ describe("the catalog", () => {
62
+ it("has 49 styles, and every one carries its own video wording, different from its image wording", () => {
63
+ expect(STYLES).toHaveLength(49)
64
+ for (const s of STYLES) {
65
+ expect(s.videoPromptHint, s.id).toEqual(expect.any(String))
66
+ expect(s.videoPromptHint, s.id).not.toBe(s.promptHint)
67
+ }
68
+ })
69
+
70
+ it("each video wording names the medium, then its techniques after a colon: one sentence, one line", () => {
71
+ for (const s of STYLES) {
72
+ const v = s.videoPromptHint!
73
+ expect(v, s.id).toMatch(/^[^:]+: \S[^\n]*\.$/)
74
+ expect(v.length, s.id).toBeLessThanOrEqual(240)
75
+ }
76
+ })
77
+
78
+ it("the three wordings tested on video are word for word", () => {
79
+ for (const [id, wording] of Object.entries(TESTED_ON_VIDEO)) expect(getStyleVideoPromptHint(id), id).toBe(wording)
80
+ })
81
+
82
+ it("the 3D Render and Charcoal video wordings are word for word, and their image wordings unchanged", () => {
83
+ for (const [id, { video, image }] of Object.entries(CHOSEN_FOR_VIDEO)) {
84
+ expect(getStyleVideoPromptHint(id), id).toBe(video)
85
+ expect(getStylePromptHint(id), id).toBe(image)
86
+ }
87
+ })
88
+
89
+ it("the photographic styles stay photographed on video", () => {
90
+ for (const id of PHOTOGRAPHIC) expect(getStyleVideoPromptHint(id), id).toMatch(/live-action|photograph/i)
91
+ })
92
+
93
+ it("Low Poly keeps its image wording and carries the video wording", () => {
94
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
95
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
96
+ })
97
+
98
+ it("every style's video wording is its own, read through getStyleVideoPromptHint", () => {
99
+ for (const s of STYLES) expect(getStyleVideoPromptHint(s.id), s.id).toBe(s.videoPromptHint)
100
+ })
101
+
102
+ it("an unknown or empty id has no wording on either surface", () => {
103
+ expect(getStyleVideoPromptHint("no-such-style")).toBe("")
104
+ expect(getStyleVideoPromptHint(undefined)).toBe("")
105
+ expect(getStyleVideoPromptHint("")).toBe("")
106
+ })
107
+ })
108
+
109
+ describe("image surfaces read only promptHint, for all 49 styles", () => {
110
+ it("the direction fold on image", () => {
111
+ for (const s of STYLES) {
112
+ expect(renderDirectionHints({ style: s.id }, { surface: "image" }), s.id).toEqual([s.promptHint])
113
+ }
114
+ })
115
+
116
+ it("the assembled image prompt", () => {
117
+ for (const s of STYLES) {
118
+ const { prompt } = assembleImageInput({ userPrompt: "a man walks", provider: "flux-2-max", direction: { style: s.id } })
119
+ expect(prompt, s.id).toBe(`a man walks\n\n[style]:\n${s.promptHint}`)
120
+ }
121
+ })
122
+
123
+ it("a Style node's own hint (no surface, and surface image)", () => {
124
+ for (const s of STYLES) {
125
+ expect(getParameterPromptHint(styleNode(s.id)), s.id).toBe(s.promptHint)
126
+ expect(getParameterPromptHint(styleNode(s.id), undefined, { surface: "image" }), s.id).toBe(s.promptHint)
127
+ }
128
+ })
129
+ })
130
+
131
+ describe("video surfaces read the video wording", () => {
132
+ it("the direction fold on video: each style's video wording", () => {
133
+ for (const s of STYLES) expect(renderDirectionHints({ style: s.id }, { surface: "video" }), s.id).toEqual([videoOf(s.id)])
134
+ })
135
+
136
+ it("the composed video prompt", () => {
137
+ for (const s of STYLES) {
138
+ expect(composeVideoPromptText("a man walks", { style: s.id }), s.id).toBe(`a man walks\n\n[style]:\n${videoOf(s.id)}`)
139
+ }
140
+ })
141
+
142
+ it("a Style node folded into a video consumer", () => {
143
+ for (const s of STYLES) {
144
+ expect(getParameterPromptHint(styleNode(s.id), undefined, { surface: "video" }), s.id).toBe(videoOf(s.id))
145
+ }
146
+ })
147
+
148
+ it("keeps the node's own pre/post text around the video wording", () => {
149
+ const node = { id: "s1", type: "style", data: { style: "low-poly", preText: "before", postText: "after" } }
150
+ // The video wording ends a sentence, so what follows it is joined with a space (joinHintFragments).
151
+ expect(getParameterPromptHint(node, undefined, { surface: "video" })).toBe(`before, ${LOW_POLY_VIDEO} after`)
152
+ })
153
+
154
+ it("compact mode injects the same term on both surfaces", () => {
155
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video", mode: "compact" })).toEqual(["low poly"])
156
+ const node = { id: "s1", type: "style", data: { style: "low-poly", hintMode: "compact" } }
157
+ expect(getParameterPromptHint(node, undefined, { surface: "video" })).toBe("low poly")
158
+ })
159
+
160
+ it("a Style node on a camera move's start state takes the video surface down with it", () => {
161
+ const nodes = [
162
+ { id: "cm", type: "camera-motion", data: { cameraMotion: "dolly-in" } },
163
+ styleNode("low-poly"),
164
+ ]
165
+ const edges = [{ id: "e1", source: "s1", target: "cm", targetHandle: "startState" }]
166
+ const video = getParameterPromptHint(nodes[0], { nodes, edges }, { surface: "video" })
167
+ const image = getParameterPromptHint(nodes[0], { nodes, edges })
168
+ expect(video).toContain(LOW_POLY_VIDEO)
169
+ expect(image).toContain(LOW_POLY_IMAGE)
170
+ expect(image).not.toContain(LOW_POLY_VIDEO)
171
+ })
172
+ })
173
+
174
+ describe("catalog packs", () => {
175
+ const styleCatalog = () => getPickerCatalog("style")!
176
+ const replaceWith = (mapOption: (o: NonNullable<PickerCatalogInput["options"]>[number]) => object) => {
177
+ const base = styleCatalog()
178
+ registerCatalogPack({
179
+ id: "t-style-replace",
180
+ catalogId: "style",
181
+ mode: "replace",
182
+ catalog: { ...base, options: base.options!.map((o) => ({ ...o, ...mapOption(o) })) },
183
+ })
184
+ }
185
+
186
+ it("the composed catalog carries every style's video wording", () => {
187
+ const options = styleCatalog().options!
188
+ expect(options).toHaveLength(49)
189
+ for (const o of options) expect(o.videoPromptHint, o.id).toBe(videoOf(o.id))
190
+ })
191
+
192
+ it("the wire projection does not carry it", () => {
193
+ const projected = projectPickerCatalog(styleCatalog(), { detail: "full" })
194
+ expect(JSON.stringify(projected)).not.toContain("videoPromptHint")
195
+ })
196
+
197
+ it("a deny pack: a denied style has no video wording either", () => {
198
+ registerCatalogPack({ id: "t-deny", catalogId: "style", mode: "deny", denyIds: ["low-poly"] })
199
+ expect(getStyleVideoPromptHint("low-poly")).toBe("")
200
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video" })).toEqual([])
201
+ // A sibling the pack did not touch still resolves on both surfaces.
202
+ expect(getStyleVideoPromptHint("anime")).toBe(TESTED_ON_VIDEO.anime)
203
+ expect(getStylePromptHint("anime")).toBe(STYLES.find((s) => s.id === "anime")!.promptHint)
204
+ })
205
+
206
+ it("a deny pack on ANOTHER style keeps Low Poly's video wording", () => {
207
+ registerCatalogPack({ id: "t-deny", catalogId: "style", mode: "deny", denyIds: ["anime"] })
208
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
209
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
210
+ })
211
+
212
+ it("the browser's copy of the server catalog (full detail, no video wording) keeps the stock video wording", () => {
213
+ // What `catalog-bootstrap` registers on a curated deployment: the server's
214
+ // composed catalog read back from `/v1/catalogs?detail=full`.
215
+ const wire = projectPickerCatalog(styleCatalog(), { detail: "full" }) as unknown as PickerCatalogInput
216
+ registerCatalogPack({ id: "wire:style", catalogId: "style", mode: "replace", catalog: wire })
217
+ expect(getStyleVideoPromptHint("low-poly")).toBe(LOW_POLY_VIDEO)
218
+ expect(getStylePromptHint("low-poly")).toBe(LOW_POLY_IMAGE)
219
+ })
220
+
221
+ it("a pack that rewrites the image wording drops the stock video wording (video follows the rewrite)", () => {
222
+ replaceWith((o) => (o.id === "low-poly" ? { promptHint: "curated low poly wording", videoPromptHint: undefined } : {}))
223
+ expect(getStylePromptHint("low-poly")).toBe("curated low poly wording")
224
+ expect(getStyleVideoPromptHint("low-poly")).toBe("curated low poly wording")
225
+ expect(getStyle("low-poly")?.videoPromptHint).toBeUndefined()
226
+ })
227
+
228
+ it("a pack may carry its own video wording", () => {
229
+ replaceWith((o) => (o.id === "low-poly" ? { promptHint: "curated still", videoPromptHint: "curated motion" } : {}))
230
+ expect(getStylePromptHint("low-poly")).toBe("curated still")
231
+ expect(getStyleVideoPromptHint("low-poly")).toBe("curated motion")
232
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "video" })).toEqual(["curated motion"])
233
+ expect(renderDirectionHints({ style: "low-poly" }, { surface: "image" })).toEqual(["curated still"])
234
+ })
235
+
236
+ it("a pack may replace a stock style's video wording and keep its image wording", () => {
237
+ replaceWith((o) => (o.id === "anime" ? { videoPromptHint: "anime video wording" } : {}))
238
+ expect(getStylePromptHint("anime")).toBe(STYLES.find((s) => s.id === "anime")!.promptHint)
239
+ expect(getStyleVideoPromptHint("anime")).toBe("anime video wording")
240
+ })
241
+
242
+ it("a pack-ADDED style resolves its own video wording, else its image wording", () => {
243
+ registerCatalogPack({
244
+ id: "t-extend",
245
+ catalogId: "style",
246
+ mode: "extend",
247
+ options: [
248
+ { id: "claymation-x", label: "Clay X", promptHint: "clay still", videoPromptHint: "clay motion" },
249
+ { id: "inkwash-x", label: "Ink X", promptHint: "ink still" },
250
+ ],
251
+ })
252
+ expect(getStyleVideoPromptHint("claymation-x")).toBe("clay motion")
253
+ expect(getStylePromptHint("claymation-x")).toBe("clay still")
254
+ expect(getStyleVideoPromptHint("inkwash-x")).toBe("ink still")
255
+ expect(getParameterPromptHint(styleNode("claymation-x"), undefined, { surface: "video" })).toBe("clay motion")
256
+ expect(getParameterPromptHint(styleNode("claymation-x"))).toBe("clay still")
257
+ })
258
+ })
@@ -26,6 +26,16 @@ import { catalogPacksVersion, hasCatalogPacksFor } from "./catalog-packs.js"
26
26
  * carry (typed extras a specific builder reads) survive from the base.
27
27
  * - pack-ADDED id (no base entry) → the composed option itself.
28
28
  *
29
+ * THE VIDEO WORDING (`videoPromptHint`, styles only) pairs with the image
30
+ * wording it was written beside, so it cannot simply survive from the base
31
+ * like a typed extra: a pack that rewrites a style's `promptHint` would then
32
+ * still inject the stock video text. The composed option's own
33
+ * `videoPromptHint` wins when it carries one; otherwise the base's is kept
34
+ * only while the composed `promptHint` is the stock one (a deny / extend pack,
35
+ * or the browser's `replace` copy of the server's composed catalog, which
36
+ * carries `promptHint` but not the video wording); a rewritten `promptHint`
37
+ * drops it, so video falls back to the pack's own wording.
38
+ *
29
39
  * LATE-BOUND RESOLVER, ON PURPOSE. The catalog modules cannot import
30
40
  * `getPickerCatalog`: picker-catalogs.ts imports every one of them to build
31
41
  * `PICKER_CATALOGS` at module scope, and a module-scope cycle is a TDZ error
@@ -91,7 +101,7 @@ export function overlayEntry<T extends { readonly id: string }>(
91
101
  const opt = composedOptionIndex(catalogId).get(id)
92
102
  if (!opt) return undefined
93
103
  if (!base) return opt as unknown as T
94
- return {
104
+ const out: Record<string, unknown> = {
95
105
  ...base,
96
106
  label: opt.label,
97
107
  promptHint: opt.promptHint,
@@ -99,6 +109,10 @@ export function overlayEntry<T extends { readonly id: string }>(
99
109
  ...(opt.description !== undefined ? { description: opt.description } : {}),
100
110
  ...(opt.category !== undefined ? { category: opt.category } : {}),
101
111
  }
112
+ const baseFields = base as { readonly promptHint?: unknown; readonly videoPromptHint?: unknown }
113
+ if (opt.videoPromptHint !== undefined) out.videoPromptHint = opt.videoPromptHint
114
+ else if (baseFields.videoPromptHint !== undefined && opt.promptHint !== baseFields.promptHint) delete out.videoPromptHint
115
+ return out as unknown as T
102
116
  }
103
117
 
104
118
  /**
@@ -56,7 +56,7 @@ import { getExposurePromptHint, getExposureTerm } from "./exposure-settings.js"
56
56
  import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
57
57
  import { buildAtmosphereHints } from "./atmosphere.js"
58
58
  import { buildPostProcessHints } from "./post-process-effects.js"
59
- import { getStylePromptHint, getStyleTerm } from "./style.js"
59
+ import { getStylePromptHint, getStyleTerm, getStyleVideoPromptHint } from "./style.js"
60
60
  import { buildMoodHints } from "./mood.js"
61
61
  import { buildAestheticHints } from "./aesthetic.js"
62
62
  import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
@@ -125,8 +125,13 @@ export interface DirectionFieldSpec {
125
125
  * Render selected ids in this catalog's own doctrine: per-id for independent
126
126
  * catalogs, ONE blended clause for Mood / Aesthetic / Photographer, the
127
127
  * catalog's own multi builder where it has one. `[]` when nothing resolves.
128
+ *
129
+ * `surface` is the stage being folded. Nearly every row ignores it (one
130
+ * wording serves stills and video); a row whose catalog authors a video-only
131
+ * wording (`style`, via `getStyleVideoPromptHint`) reads it. Absent reads as
132
+ * `"image"`.
128
133
  */
129
- readonly render: (ids: readonly string[], mode: PickerHintMode) => string[]
134
+ readonly render: (ids: readonly string[], mode: PickerHintMode, surface?: "image" | "video") => string[]
130
135
  }
131
136
 
132
137
  // ── Render adapters (module-private) ────────────────────────────────────────
@@ -163,6 +168,16 @@ const viaStringBuilder =
163
168
  return s.length > 0 ? [s] : []
164
169
  }
165
170
 
171
+ /**
172
+ * Per-id emission whose FULL wording depends on the surface: the image getter
173
+ * on a still, the video getter on video (which falls back to the image wording
174
+ * for an id that authors none). The compact term is the same on both.
175
+ */
176
+ const perIdBySurface =
177
+ (image: (id: string) => string, video: (id: string) => string, compact: (id: string) => string) =>
178
+ (ids: readonly string[], mode: PickerHintMode, surface?: "image" | "video"): string[] =>
179
+ perId(surface === "video" ? video : image, compact)(ids, mode)
180
+
166
181
  /** Mood's builder takes a RECORD and returns a list — and blends multi picks. */
167
182
  const viaMood = (ids: readonly string[], mode: PickerHintMode): string[] =>
168
183
  buildMoodHints({ mood: ids.length === 1 ? ids[0] : [...ids] }, mode).filter(
@@ -242,7 +257,7 @@ export const DIRECTION_FIELDS = [
242
257
  { key: "postProcess", surface: "image", family: "look", maxPicks: 2, render: viaListBuilder(buildPostProcessHints) },
243
258
 
244
259
  // Style.
245
- { key: "style", surface: "both", family: "look", styleGroup: "film", maxPicks: 1, userRef: "moodboard", render: perId(getStylePromptHint, getStyleTerm) },
260
+ { key: "style", surface: "both", family: "look", styleGroup: "film", maxPicks: 1, userRef: "moodboard", render: perIdBySurface(getStylePromptHint, getStyleVideoPromptHint, getStyleTerm) },
246
261
  { key: "mood", surface: "both", family: "look", maxPicks: 2, render: viaMood },
247
262
  { key: "aesthetic", surface: "both", family: "look", maxPicks: 2, render: viaStringBuilder(buildAestheticHints) },
248
263
  { key: "photoGenre", surface: "image", family: "look", maxPicks: 1, render: perId(getPhotoGenrePromptHint, getPhotoGenreTerm) },
@@ -260,7 +275,9 @@ export const DIRECTION_FIELDS = [
260
275
  { key: "temporalFreeze", surface: "video", family: "motion", maxPicks: 1, render: temporal },
261
276
  { key: "temporalDirection", surface: "video", family: "motion", maxPicks: 1, render: temporal },
262
277
  { key: "temporalShutter", surface: "video", family: "motion", maxPicks: 1, render: temporal },
263
- { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: renderTransitionBases },
278
+ // Wrapped: `renderTransitionBases`' third parameter is a row's option values,
279
+ // not the surface `render` is handed.
280
+ { key: "transition", surface: "video", family: "motion", maxPicks: 2, render: (ids, mode) => renderTransitionBases(ids, mode) },
264
281
  { key: "loopSubject", surface: "video", family: "motion", maxPicks: 1, render: perId(getLoopSubjectPromptHint, getLoopSubjectTerm) },
265
282
 
266
283
  // ── LEGACY BLOCK — see the table doc above. Placed LAST, in today's exact
@@ -408,14 +425,15 @@ function renderRow(
408
425
  spec: DirectionFieldSpec,
409
426
  ids: readonly string[],
410
427
  mode: PickerHintMode,
428
+ surface: "image" | "video",
411
429
  refs: ReadonlyMap<string, string> | undefined,
412
430
  ): string[] {
413
431
  if (spec.userRef === undefined || !ids.some((id) => parseUserDirectionRef(id) !== null)) {
414
- return spec.render(ids, mode)
432
+ return spec.render(ids, mode, surface)
415
433
  }
416
434
  return ids.flatMap((id) => {
417
435
  const ref = parseUserDirectionRef(id)
418
- if (ref === null) return spec.render([id], mode)
436
+ if (ref === null) return spec.render([id], mode, surface)
419
437
  const clause = ref.kind === spec.userRef ? refs?.get(id) : undefined
420
438
  return clause ? [clause] : []
421
439
  })
@@ -457,7 +475,7 @@ export function renderDirectionHintClauses(
457
475
  spec.maxPicks,
458
476
  )
459
477
  if (ids.length === 0) continue
460
- for (const hint of renderRow(spec, ids, modeForFamily(mode, spec.family), opts.refs)) {
478
+ for (const hint of renderRow(spec, ids, modeForFamily(mode, spec.family), opts.surface, opts.refs)) {
461
479
  if (hint.length > 0 && !seen.has(hint)) {
462
480
  seen.add(hint)
463
481
  out.push({
@@ -293,10 +293,10 @@ export const GENERATE_IMAGE_PRESETS: readonly FactoryPreset[] = [
293
293
  },
294
294
  },
295
295
  // ── Face Privacy (connect the photo → {image:1}) ─────────────────────────
296
- // gpt-image-2 + aspectRatio "auto" so the output keeps the input photo's
297
- // shape. NOTE: KIE's gpt-image-2 spec forces resolution 1K when aspect is
298
- // "auto" (the config panel's fail-safe snaps any other value back), so
299
- // these pin 1K explicitly rather than shipping a self-contradicting 2K.
296
+ // aspectRatio "auto" so the output keeps the input photo's shape. NOTE:
297
+ // KIE's gpt-image-2 spec forces resolution 1K when aspect is "auto" (the
298
+ // config panel's fail-safe snaps any other value back), so the gpt-image-2
299
+ // presets pin 1K explicitly rather than shipping a self-contradicting 2K.
300
300
  {
301
301
  id: "generate-image/faceless-3d-head",
302
302
  name: "Faceless · 3D Blank Head",
@@ -340,6 +340,20 @@ export const GENERATE_IMAGE_PRESETS: readonly FactoryPreset[] = [
340
340
  "opaque faces, visible facial features, eyes, nose, mouth, changing the background, altering clothing, hair or body, restyling the scene, relighting, changing pose or framing, distorted anatomy, watermark, blurry",
341
341
  },
342
342
  },
343
+ // GPT Image 2.5 Flare has no auto x resolution restriction, so this one keeps
344
+ // the photo's shape AND renders 2K.
345
+ {
346
+ id: "generate-image/remove-people",
347
+ name: "Remove People",
348
+ description: "Remove every person from the photo; the background stays exactly the same.",
349
+ group: "Face Privacy",
350
+ data: {
351
+ provider: "gpt-image-2-5-flare",
352
+ aspectRatio: "auto",
353
+ resolution: "2K",
354
+ promptPrefix: "remove all people from {image:1}\nkeep background exactly the same",
355
+ },
356
+ },
343
357
  // ── Photography & Cinematic ──────────────────────────────────────────────
344
358
  {
345
359
  id: "generate-image/cinematic-portrait",
@@ -26,7 +26,7 @@ import { getCameraFormatPromptHint, getCameraFormatTerm } from "./camera-format.
26
26
  import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
27
27
  import { buildAtmosphereHints } from "./atmosphere.js"
28
28
  import { buildActionFxHints } from "./action-fx.js"
29
- import { getStylePromptHint, getStyleTerm } from "./style.js"
29
+ import { getStylePromptHint, getStyleTerm, getStyleVideoPromptHint } from "./style.js"
30
30
  import { getSettingPromptHint, getSettingTerm } from "./setting.js"
31
31
  import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
32
32
  import { buildPersonHints } from "./person.js"
@@ -179,12 +179,26 @@ const MOTION_INTENSITY_HINTS: Readonly<Record<string, { readonly full: string; r
179
179
  * transition / camera-motion / character-fx timing and start-state/end-state
180
180
  * clauses, multi-pick joining and multi-dimension composition are emitted the
181
181
  * same way in both modes.
182
+ *
183
+ * SURFACE — `opts.surface: "video"` is for a caller folding the hint into ONE
184
+ * video consumer's prompt (the canvas's video nodes): a Style node then injects
185
+ * its video wording (`getStyleVideoPromptHint`). Absent (or `"image"`) is the
186
+ * output this function always returned — what a still, a `{Label}` reference
187
+ * and every preview of the node's own text read, since a node's own output
188
+ * text has no single consumer. Every other node type ignores it.
182
189
  */
183
190
  export function getParameterPromptHint(
184
191
  node: HintNodeLike | undefined,
185
192
  ctx?: HintGraphContext,
193
+ opts?: ParameterHintOptions,
186
194
  ): string {
187
- return resolveParameterHint(node, ctx)
195
+ return resolveParameterHint(node, ctx, undefined, opts?.surface === "video" ? "video" : "image")
196
+ }
197
+
198
+ /** How `getParameterPromptHint` resolves a node. */
199
+ export interface ParameterHintOptions {
200
+ /** The stage the hint is folded into. Absent = `"image"`. */
201
+ readonly surface?: "image" | "video"
188
202
  }
189
203
 
190
204
  /**
@@ -198,6 +212,7 @@ function resolveParameterHint(
198
212
  node: HintNodeLike | undefined,
199
213
  ctx?: HintGraphContext,
200
214
  inherited?: PickerHintMode,
215
+ surface: "image" | "video" = "image",
201
216
  ): string {
202
217
  if (!node?.type) return ""
203
218
  const data = (node.data ?? {}) as Record<string, unknown>
@@ -216,7 +231,7 @@ function resolveParameterHint(
216
231
  // themselves parameter nodes (framing/tone/etc.) that don't need graph
217
232
  // context, and avoiding recursion keeps the walk cycle-safe. The mode
218
233
  // rides down so the composed clause stays at one level of detail.
219
- const hint = resolveParameterHint(src, undefined, mode)
234
+ const hint = resolveParameterHint(src, undefined, mode, surface)
220
235
  if (!hint) continue
221
236
  if (edge.targetHandle === "startState") startHints.push(hint)
222
237
  else if (edge.targetHandle === "endState") endHints.push(hint)
@@ -247,7 +262,7 @@ function resolveParameterHint(
247
262
  if (edge.target !== node.id) continue
248
263
  const src = ctx.nodes.find((n) => n.id === edge.source)
249
264
  if (!src) continue
250
- const hint = resolveParameterHint(src, undefined, mode) // no ctx — cycle-safe
265
+ const hint = resolveParameterHint(src, undefined, mode, surface) // no ctx — cycle-safe
251
266
  if (!hint) continue
252
267
  if (edge.targetHandle === "startState") startHints.push(hint)
253
268
  else if (edge.targetHandle === "endState") endHints.push(hint)
@@ -301,7 +316,7 @@ function resolveParameterHint(
301
316
  )
302
317
  }
303
318
 
304
- const base = resolveBaseHint(node.type, data, mode)
319
+ const base = resolveBaseHint(node.type, data, mode, surface)
305
320
  if (base) return base
306
321
  // Pack-extend fallback: a single-dim pack entry the per-catalog getter (which
307
322
  // reads the frozen base array) can't resolve. Resolve it against the
@@ -313,7 +328,7 @@ function resolveParameterHint(
313
328
  if (cat?.kind === "single" && cat.valueField) {
314
329
  const id = typeof data[cat.valueField] === "string" ? (data[cat.valueField] as string) : ""
315
330
  const opt = cat.options?.find((o) => o.id === id)
316
- if (opt) return byMode(mode, opt.promptHint, resolveTerm(opt))
331
+ if (opt) return byMode(mode, surface === "video" ? (opt.videoPromptHint ?? opt.promptHint) : opt.promptHint, resolveTerm(opt))
317
332
  }
318
333
  return base
319
334
  }
@@ -333,6 +348,7 @@ function resolveBaseHint(
333
348
  type: string,
334
349
  data: Record<string, unknown>,
335
350
  mode: PickerHintMode = "full",
351
+ surface: "image" | "video" = "image",
336
352
  ): string {
337
353
  switch (type) {
338
354
  case "framing":
@@ -373,7 +389,7 @@ function resolveBaseHint(
373
389
  case "action-fx":
374
390
  return withCustomText(data, buildActionFxHints(data.actionFx, mode).join(", "))
375
391
  case "style":
376
- return withCustomText(data, byMode(mode, getStylePromptHint, getStyleTerm)(asStr(data.style)))
392
+ return withCustomText(data, byMode(mode, surface === "video" ? getStyleVideoPromptHint : getStylePromptHint, getStyleTerm)(asStr(data.style)))
377
393
  case "setting":
378
394
  return withCustomText(data, byMode(mode, getSettingPromptHint, getSettingTerm)(asStr(data.setting)))
379
395
  case "loop-subject":
@@ -132,6 +132,14 @@ export interface PickerOption {
132
132
  * `label` and inject `term`; they never derive it themselves.
133
133
  */
134
134
  readonly term: string
135
+ /**
136
+ * Style only: the wording a VIDEO prompt injects in place of `promptHint`
137
+ * (see `Style.videoPromptHint`). Present only on an entry that authors one;
138
+ * absent means video uses `promptHint`. A pack option may carry its own. On
139
+ * this in-memory catalog only: the wire projection (`projectPickerCatalog`)
140
+ * does not carry it.
141
+ */
142
+ readonly videoPromptHint?: string
135
143
  /**
136
144
  * Transitions only: present (`true`) on a row whose mechanism is a CUT, so
137
145
  * it takes no duration and no intensity — see `Transition.instant` /
@@ -221,6 +229,8 @@ interface BaseCatalogEntry {
221
229
  readonly promptHint: string
222
230
  /** Optional authored compact term (see `term.ts` for the convention). */
223
231
  readonly term?: string
232
+ /** Style: see `Style.videoPromptHint`. Propagated verbatim into the flattened option. */
233
+ readonly videoPromptHint?: string
224
234
  /** W1-a: see Person.adultOnly. Propagated verbatim into the flattened option. */
225
235
  readonly adultOnly?: true
226
236
  /** Transitions: see `Transition.instant`. Propagated into the flattened option as `instant: true`. */
@@ -267,6 +277,7 @@ function toOptions<T extends BaseCatalogEntry>(
267
277
  term: resolveTerm(e),
268
278
  }
269
279
  if (e.description) opt.description = e.description
280
+ if (e.videoPromptHint) opt.videoPromptHint = e.videoPromptHint
270
281
  if (categoryField) opt.category = e[categoryField] as unknown as string
271
282
  if (e.adultOnly) opt.adultOnly = true
272
283
  if (e.instant) opt.instant = true