@nodaro/prompts 1.34.0 → 1.36.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.
@@ -1,3 +1,4 @@
1
+ import { T2I_TO_I2I_VARIANT } from "@nodaro/shared"
1
2
  import type { FactoryPreset } from "./types.js"
2
3
  import { stylizedSubjectFor, editsFor } from "./shared-image.js"
3
4
  import { GENERATE_IMAGE_PRESETS } from "./generate-image.js"
@@ -14,6 +15,19 @@ import { LOTTIE_OVERLAY_PRESETS } from "./lottie-overlay.js"
14
15
  export type { FactoryPreset, FactoryPresetGroup } from "./types.js"
15
16
  export { groupFactoryPresets } from "./types.js"
16
17
 
18
+ /**
19
+ * A shared preset as Modify Image takes it. Modify Image always works from a source photo, so its
20
+ * provider list names a model's image-to-image id where the model has a separate one
21
+ * (`seedream-5-pro` → `seedream-5-pro-i2i`). The shared catalogs may carry the text-to-image id
22
+ * Generate Image takes — its route switches to that same image-to-image model once a photo is
23
+ * connected. Providers with no separate image-to-image id are left as they are.
24
+ */
25
+ function forModifyImage(p: FactoryPreset): FactoryPreset {
26
+ const provider = p.data.provider
27
+ const imageToImage = typeof provider === "string" ? T2I_TO_I2I_VARIANT[provider] : undefined
28
+ return imageToImage ? { ...p, data: { ...p.data, provider: imageToImage } } : p
29
+ }
30
+
17
31
  /** System/factory presets shipped with the app. Assembled here in the original
18
32
  * single-file key order; each value lives in its per-domain module. */
19
33
  export const FACTORY_PRESETS: Readonly<Record<string, readonly FactoryPreset[]>> = {
@@ -22,7 +36,7 @@ export const FACTORY_PRESETS: Readonly<Record<string, readonly FactoryPreset[]>>
22
36
  // modify-image shares the Stylized Subject + Edits catalogs with generate-image
23
37
  // (single source of truth in STYLIZED_SUBJECT / IMAGE_EDITS). modify-image is
24
38
  // slated for deprecation in favor of generate-image — when removed, drop this key.
25
- "modify-image": [...stylizedSubjectFor("modify-image"), ...editsFor("modify-image")],
39
+ "modify-image": [...stylizedSubjectFor("modify-image"), ...editsFor("modify-image")].map(forModifyImage),
26
40
  "generate-video": GENERATE_VIDEO_PRESETS,
27
41
  "text-to-speech": TEXT_TO_SPEECH_PRESETS,
28
42
  "text-to-audio": TEXT_TO_AUDIO_PRESETS,
@@ -1,11 +1,12 @@
1
1
  import type { FactoryPreset } from "./types.js"
2
+ import { REALISM_FINISH, REALISM_PASS_INSTRUCTION } from "../realism-wording.js"
2
3
 
3
4
  /**
4
5
  * Selective-stylization presets shared by `modify-image` (transform an input photo) and
5
6
  * `generate-image` (use a connected reference image). `modify-image` is slated for deprecation in
6
7
  * favor of `generate-image`, so the catalog lives in ONE place to prevent the two from drifting —
7
- * when `modify-image` is removed, just drop its key from `FACTORY_PRESETS` below. `nano-banana-pro`
8
- * is a valid provider for both nodes' enums (IMAGE_GEN_PROVIDERS and MODIFY_IMAGE_PROVIDERS).
8
+ * when `modify-image` is removed, just drop its key from `FACTORY_PRESETS` below. The provider is
9
+ * set per node by {@link editProviderFor}.
9
10
  */
10
11
  const STYLIZED_SUBJECT: ReadonlyArray<{
11
12
  readonly slug: string
@@ -18,7 +19,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
18
19
  name: "Cartoon Person, Real World",
19
20
  description: "Subject → 3D cartoon, rest stays photoreal.",
20
21
  data: {
21
- provider: "nano-banana-pro",
22
22
  promptPrefix:
23
23
  "Restyle ONLY the person in the reference image into a polished 3D animated character — Pixar/Disney sensibility: soft subsurface skin shading, expressive oversized eyes, smoothly stylized hair, clean rounded forms. Preserve their identity, exact pose, expression, and framing. Everything else stays untouched and fully photorealistic: keep the original background, props, clothing fabric and texture, and the scene's real lighting, shadows, and color grade. Composite the stylized figure into the real plate so it sits naturally — matched light direction, contact shadows, and depth.",
24
24
  negativePrompt:
@@ -30,7 +30,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
30
30
  name: "Caricature, Real Photo",
31
31
  description: "Exaggerated cartoon head on a real scene.",
32
32
  data: {
33
- provider: "nano-banana-pro",
34
33
  promptPrefix:
35
34
  "Turn ONLY the person into a playful hand-illustrated caricature: enlarge the head, exaggerate their most recognizable features, and amplify expression with bold, confident linework and clean cel-style shading — while keeping them clearly identifiable. Leave everything else exactly as shot and photorealistic: the body proportions below the neck, the clothing, the background, props, and the scene's real lighting and shadows remain unchanged. Blend the caricature head onto the real photo seamlessly with matched lighting and a believable neckline.",
36
35
  negativePrompt:
@@ -42,7 +41,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
42
41
  name: "Anime Person, Real Background",
43
42
  description: "Subject → 2D anime, real environment.",
44
43
  data: {
45
- provider: "nano-banana-pro",
46
44
  promptPrefix:
47
45
  "Restyle ONLY the person as a crisp 2D anime character — clean ink outlines, flat cel shading with sharp shadow shapes, glossy stylized hair, and expressive anime eyes — while preserving their identity, pose, expression, and framing. Keep the real-world background, environment, props, and the original photographic lighting, shadows, and color completely untouched and photorealistic. Integrate the 2D figure into the live-action plate so the light direction and contact shadows read naturally.",
48
46
  negativePrompt:
@@ -54,7 +52,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
54
52
  name: "Real Person, Cartoon World",
55
53
  description: "Inverse — real subject, stylized world.",
56
54
  data: {
57
- provider: "nano-banana-pro",
58
55
  promptPrefix:
59
56
  "Inverse stylization: keep the PERSON completely photorealistic and unchanged — real skin, hair, clothing, identity, pose, and framing all preserved exactly. Restyle ONLY the surrounding world into a vibrant, hand-crafted cartoon environment: simplified painterly shapes, bold saturated colors, soft graphic shading, and whimsical stylized props and scenery. Light the real person to match the cartoon world's color and direction so they sit convincingly inside it, with believable contact shadows.",
60
57
  negativePrompt:
@@ -66,7 +63,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
66
63
  name: "Claymation Figure, Real Set",
67
64
  description: "Subject → clay figure, real surroundings.",
68
65
  data: {
69
- provider: "nano-banana-pro",
70
66
  promptPrefix:
71
67
  "Restyle ONLY the person into a tactile stop-motion claymation figure — molded plasticine surface with visible fingerprints, tool marks, subtle seams, and a soft matte sheen, in the handmade spirit of Aardman puppets — while keeping their identity, pose, expression, and framing. Everything else stays exactly as shot and photorealistic: the real background, set, props, and the scene's natural lighting and shadows are unchanged. Seat the clay figure into the real plate with matched light direction and grounded contact shadows.",
72
68
  negativePrompt:
@@ -76,13 +72,23 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
76
72
  ]
77
73
 
78
74
  /** Build the Stylized Subject presets for a node type (id = `<nodeType>/<slug>`). */
75
+ /**
76
+ * Both preset families are edits of a connected photo, so they run on the platform's edit model,
77
+ * GPT Image 2.5 Flare (decided 2026-10-08 from the image model bake-off: it kept pose, framing and
78
+ * product design on every edit). Modify Image takes the i2i id; Generate Image takes the t2i id,
79
+ * which the platform swaps to the same i2i model whenever a reference image is connected.
80
+ */
81
+ export function editProviderFor(nodeType: string): string {
82
+ return nodeType === "modify-image" ? "gpt-image-2-5-flare-i2i" : "gpt-image-2-5-flare"
83
+ }
84
+
79
85
  export function stylizedSubjectFor(nodeType: string): FactoryPreset[] {
80
86
  return STYLIZED_SUBJECT.map((p) => ({
81
87
  id: `${nodeType}/${p.slug}`,
82
88
  name: p.name,
83
89
  description: p.description,
84
90
  group: "Stylized Subject",
85
- data: p.data,
91
+ data: { provider: editProviderFor(nodeType), ...p.data },
86
92
  }))
87
93
  }
88
94
 
@@ -102,7 +108,6 @@ const IMAGE_EDITS: ReadonlyArray<{
102
108
  name: "Remove Background",
103
109
  description: "Cut out the subject on white.",
104
110
  data: {
105
- provider: "nano-banana-pro",
106
111
  promptPrefix:
107
112
  "Cleanly cut the main subject out of the reference image and place it on a pure, solid white background. Trace a crisp, accurate edge — preserve fine detail like hair strands, fur, and translucent or wispy edges — with no leftover background pixels and no color fringing. Keep the subject itself completely unchanged: same pose, lighting, color, and full silhouette, nothing cropped.",
108
113
  negativePrompt:
@@ -114,7 +119,6 @@ const IMAGE_EDITS: ReadonlyArray<{
114
119
  name: "Replace Background",
115
120
  description: "Swap in a new scene.",
116
121
  data: {
117
- provider: "nano-banana-pro",
118
122
  prompt:
119
123
  "Replace the background behind the subject with {scene || a sunlit Mediterranean terrace}. Keep the subject perfectly unchanged — identity, pose, edges, and proportions intact — and composite it into the new scene so it looks truly photographed there: relight the subject's rim and ambient tones to match the new environment, cast believable contact shadows, and match perspective, depth of field, and color grade.",
120
124
  negativePrompt:
@@ -126,7 +130,6 @@ const IMAGE_EDITS: ReadonlyArray<{
126
130
  name: "Colorize B&W Photo",
127
131
  description: "Natural color from grayscale.",
128
132
  data: {
129
- provider: "nano-banana-pro",
130
133
  promptPrefix:
131
134
  "Colorize this black-and-white photograph with natural, historically plausible color. Give skin realistic, even tones, and assign believable hues to clothing, foliage, sky, and materials. Preserve every original detail, grain, contrast, and the exact composition — add color only, never invent or remove content.",
132
135
  negativePrompt:
@@ -138,7 +141,6 @@ const IMAGE_EDITS: ReadonlyArray<{
138
141
  name: "Restore Old Photo",
139
142
  description: "Repair scratches, denoise, sharpen.",
140
143
  data: {
141
- provider: "nano-banana-pro",
142
144
  promptPrefix:
143
145
  "Restore this old, damaged photograph: remove scratches, dust, creases, stains, and noise, repair torn or missing areas by reconstructing what is plainly there, and gently recover sharpness and tonal detail. Keep the result authentic and true to the original — preserve identity, era, natural film grain, and composition. Repair only; do not beautify, restyle, or invent new features.",
144
146
  negativePrompt:
@@ -150,7 +152,6 @@ const IMAGE_EDITS: ReadonlyArray<{
150
152
  name: "Relight Scene",
151
153
  description: "New lighting, same subject.",
152
154
  data: {
153
- provider: "nano-banana-pro",
154
155
  prompt:
155
156
  "Relight the image with {lighting style || soft golden-hour key light from the left}. Re-derive highlights, shadows, ambient fill, and color temperature consistently across the whole frame for a believable, physically plausible result. Keep the subject, pose, expression, materials, and composition completely unchanged — only the lighting changes.",
156
157
  negativePrompt:
@@ -162,7 +163,6 @@ const IMAGE_EDITS: ReadonlyArray<{
162
163
  name: "Restyle (whole image)",
163
164
  description: "Apply an art style to everything.",
164
165
  data: {
165
- provider: "nano-banana-pro",
166
166
  prompt:
167
167
  "Restyle the entire image in a {art style || soft watercolor illustration} look, applying the style consistently to the subject, background, and every element. Keep the composition, subject placement, and the subject clearly recognizable — reinterpret texture, color, and rendering, not the underlying content or layout.",
168
168
  negativePrompt:
@@ -174,7 +174,6 @@ const IMAGE_EDITS: ReadonlyArray<{
174
174
  name: "Doodle Overlay",
175
175
  description: "Hand-drawn marker doodles on top — photo stays untouched.",
176
176
  data: {
177
- provider: "nano-banana-pro",
178
177
  promptPrefix:
179
178
  "Layer playful hand-drawn doodles ON TOP of the reference photo while keeping every pixel of the underlying photograph completely unchanged — same people, same colors, same lighting, same crop. Doodle style: wobbly hand-drawn felt-tip marker lines in white with a few yellow accents, like quick sketches on a printed photo. Add: loose outline traces hugging one or two key shapes, small sparkles and stars, a hand-drawn thought cloud or speech bubble with a short witty handwritten phrase that reacts to what is actually happening in the photo, and simple arrows or underlines pointing at fun details. Outlines only — never fill shapes with color. Never cover faces or the main focal point; place doodles in empty areas like sky, walls or margins. Keep it balanced: four to seven doodle elements total, charming and confident, not cluttered. The photo is the hero — the doodles are the seasoning.",
180
179
  negativePrompt:
@@ -186,13 +185,53 @@ const IMAGE_EDITS: ReadonlyArray<{
186
185
  name: "Doodle Overlay · Expressive",
187
186
  description: "Looser free-form doodles — the model improvises.",
188
187
  data: {
189
- provider: "nano-banana-pro",
190
188
  promptPrefix:
191
189
  "Decorate the reference photo with expressive hand-drawn doodles layered on top, keeping the underlying photograph itself completely unchanged. Free, confident hand-drawn marker linework — wobbly outlines, sparkles, squiggles, little flames or hearts where they fit the mood, and a short handwritten phrase that responds cleverly to the content of the photo (never a generic cliché). Mix of white and one or two bright accent colors. Outline style only, no filled shapes. Compose the doodles around the subject — never on top of faces or the focal point — using empty areas and edges. Loose, playful, zine-like energy: it should feel like a bored genius doodled on a printed photo. Keep the original image pixels intact beneath the doodle layer.",
192
190
  negativePrompt:
193
191
  "altered photo, restyled or recolored scene, covered faces, filled color blocks, sticker or clipart look, cliché phrases, garbled or misspelled handwriting, cluttered composition, watermark, blurry",
194
192
  },
195
193
  },
194
+ {
195
+ slug: "add-skin-realism",
196
+ name: "Add Skin Realism",
197
+ description: "Brings back visible skin texture — same person, same photo.",
198
+ data: { provider: "nano-banana-2-1", aspectRatio: "auto", resolution: "2K", promptPrefix: REALISM_PASS_INSTRUCTION },
199
+ },
200
+ {
201
+ slug: "natural-photo-finish",
202
+ name: "Natural Photo Finish",
203
+ description: "Takes out a rendered-looking finish, keeps the photo.",
204
+ data: {
205
+ provider: "nano-banana-2-1",
206
+ aspectRatio: "auto",
207
+ resolution: "2K",
208
+ promptPrefix: `Keep this photo as it is and take out only what makes it look rendered: smoothed, waxy or airbrushed skin, a glow, over-sharpened edges, hair too perfect to be real. ${REALISM_FINISH} Otherwise the same people, framing, pose and background.`,
209
+ },
210
+ },
211
+ {
212
+ slug: "phone-camera-look",
213
+ name: "Phone Camera Look",
214
+ description: "Re-finishes a photo as an everyday snapshot.",
215
+ data: {
216
+ provider: "nano-banana-2-1",
217
+ aspectRatio: "auto",
218
+ resolution: "2K",
219
+ promptPrefix: `Re-finish this photo, changing nothing in it: ${REALISM_FINISH} Same people, framing, pose and background.`,
220
+ },
221
+ },
222
+ {
223
+ slug: "undo-beauty-filter",
224
+ name: "Undo Beauty Filter",
225
+ description: "Reverses smoothing and reshaping on a real photo.",
226
+ data: {
227
+ provider: "seedream-5-pro",
228
+ // "auto" keeps the photo's shape: this model lists no native "auto", so on either node the
229
+ // run takes its listed ratio nearest the connected photo.
230
+ aspectRatio: "auto",
231
+ quality: "high",
232
+ promptPrefix: "Undo the beauty filter on this photo: bring back the person's real skin texture, the natural shape of the face, jaw, nose and eyes, and the real colour of skin and lips. Change nothing else: the same person, expression, hair, clothing, framing and background.",
233
+ },
234
+ },
196
235
  ]
197
236
 
198
237
  /** Build the Edits presets for a node type (id = `<nodeType>/<slug>`). */
@@ -202,6 +241,6 @@ export function editsFor(nodeType: string): FactoryPreset[] {
202
241
  name: p.name,
203
242
  description: p.description,
204
243
  group: "Edits",
205
- data: p.data,
244
+ data: { provider: editProviderFor(nodeType), ...p.data },
206
245
  }))
207
246
  }
@@ -318,4 +318,15 @@ export const IMAGE_TO_TEXT_PRESETS: readonly FactoryPreset[] = [
318
318
  group: "Creative",
319
319
  data: { detailLevel: "detailed", customPrompt: "Describe this image as a detailed text-to-image generation prompt: subject, style, lighting, composition and lens. Return only the prompt." },
320
320
  },
321
+ {
322
+ id: "image-to-text/realism-audit",
323
+ name: "Realism Audit",
324
+ description: "Lists the signs an image was generated, with a count.",
325
+ group: "Creative",
326
+ data: {
327
+ detailLevel: "structured",
328
+ customPrompt:
329
+ "Check this image for signs that it was generated rather than photographed. Answer each question yes or no, with a few words why. 1. Is the skin smooth, waxy or airbrushed, with no visible pores? 2. Is the face unusually symmetrical, or are the teeth unnaturally even and white? 3. Are any hands or fingers malformed, merged or the wrong count? 4. Is any text, logo or sign garbled? 5. Is the background blurred far more than an everyday snapshot would be? 6. Are the catchlights in the eyes perfect rings, or identical in both eyes? 7. Is the hair uniformly perfect, with no flyaways? 8. Do edges look over-sharpened or haloed? 9. Is there a glow or bloom on the skin? 10. Do shadows or reflections disagree with the light? Finish with one line: \"Tells found: N of 10\".",
330
+ },
331
+ },
321
332
  ]
@@ -13,8 +13,12 @@
13
13
  * Source of truth for the semantics documented here (verified 2026-08-25):
14
14
  * - `IMAGE_TOKEN_PATTERN` = `/\{image:(\d+)(?::([^}\n]+))?\}/gi`
15
15
  * - `expandImageRefTokens`: `{image:N:label}` → `Image N (label)`,
16
- * `{image:N}` → `Image N`; out-of-range tokens are left untouched so the
17
- * author can see and fix them.
16
+ * `{image:N}` → `Image N`.
17
+ * - A token with no reference at N follows the video resolver's rule
18
+ * (`dropUnwiredImageTokens` in `prompt-builder.ts`): `{image:N:label}` →
19
+ * `label`, a bare `{image:N}` is dropped. Two places keep a token as typed,
20
+ * on purpose: a wired token in a hybrid `Avoid:` line, and the native
21
+ * negative prompt of models with their own negative field (e.g. ideogram-v3).
18
22
  * - N is the reference's 1-based position in the assembled reference list
19
23
  * (connection order on the `references` handle).
20
24
  */
@@ -32,7 +36,7 @@ export const IMAGE_REFERENCE_PROMPT_DOCTRINE: ImageReferenceDoctrine = {
32
36
 
33
37
  - \`{image:N:label}\` → expands server-side to \`Image N (label)\`, aligned with the numbered reference list sent to the provider.
34
38
  - \`{image:N}\` → \`Image N\` (no role named).
35
- - A token whose N has no wired reference is left as literal text in the final prompt — visible on purpose, so fix the numbering instead of ignoring it.
39
+ - A token whose N has no wired reference falls back to its label (\`{image:3:dog}\` → \`dog\`); a bare \`{image:N}\` is dropped. The prompt still runs but binds nothing at N, so check the numbering against what is wired.
36
40
 
37
41
  **How to compose**
38
42
  - The prompt should be little more than tokens plus glue words: \`{image:1:person} with {image:2:face}\`. The label tells the model what to TAKE from that image — \`person\`, \`face\`, \`background\`, \`settings\`, or a concrete garment/prop name (\`jacket\`).
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ export { stripAudioTags } from "./audio-tags.js"
90
90
  export * from "./node-prompt-fields.js"
91
91
  export * from "./factory-presets.js"
92
92
  export * from "./caption-treatments.js"
93
+ export * from "./realism-wording.js"
93
94
  export * from "./style-presets.js"
94
95
  export * from "./object-asset-presets.js"
95
96
  export * from "./factory-snippets/index.js"
@@ -25,6 +25,7 @@ import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromR
25
25
  import type { CharacterDef, ConnectedReference, DescribedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
26
26
  import { appendReferenceLines, referenceDescriptionLine, renderDescribedReferenceLines } from "./described-references.js"
27
27
  import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
28
+ import { tidyReferenceTokenGaps, unboundReferenceTokenText } from "./unbound-reference-tokens.js"
28
29
 
29
30
  export interface ResolveCharacterMentionsResult {
30
31
  /** Prompt with @-tokens replaced by display names + "Use these characters:" directive prepended. */
@@ -3068,7 +3069,16 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
3068
3069
 
3069
3070
  const refsToSend = supportsRefs && finalUrls.length > 0 ? finalUrls : undefined
3070
3071
 
3071
- return { prompt: finalPrompt, nativeNegativePrompt, referenceImageUrls: refsToSend }
3072
+ // LAST, after the reorder (as the video core runs `resolveReferenceTokens`
3073
+ // last): an `{image:N}` with no image at N becomes its label, or nothing.
3074
+ // The binding test is the one both expanders above use, so a token that
3075
+ // binds is never touched here and every wired output is unchanged.
3076
+ const assembledPrompt = dropUnwiredImageTokens(
3077
+ finalPrompt,
3078
+ (n) => Boolean(tokenFinalSlot(n, nonCharacterRefs, finalIndexByUrl)),
3079
+ )
3080
+
3081
+ return { prompt: assembledPrompt, nativeNegativePrompt, referenceImageUrls: refsToSend }
3072
3082
  }
3073
3083
 
3074
3084
  // -------------------------------------------------------------------------
@@ -3160,6 +3170,9 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
3160
3170
 
3161
3171
  // Expand {image:N} position references in prompt (legacy: → "[reference image N]")
3162
3172
  prompt = expandImagePositionRefs(prompt, allRefs.length)
3173
+ // Then the unwired rule for a token past the last image — same pass, same
3174
+ // binding test, as the connected-reference return above.
3175
+ prompt = dropUnwiredImageTokens(prompt, (n) => hasImageAt(n, allRefs.length))
3163
3176
 
3164
3177
  return { prompt, nativeNegativePrompt, referenceImageUrls: refsToSend }
3165
3178
  }
@@ -3221,6 +3234,61 @@ const STRICT_DEFAULT_SOURCES: ReadonlySet<ReferenceSource> = new Set([
3221
3234
  // newline or `}` so the match can't run away across lines / token boundaries.
3222
3235
  const IMAGE_TOKEN_PATTERN = /\{image:(\d+)(?::([^}\n]+))?\}/gi
3223
3236
 
3237
+ /**
3238
+ * The final 1-based slot an `{image:N}` token binds to on the connected-reference
3239
+ * path — the slot of `refs[N - 1]`'s URL — or `undefined` when no image sits at
3240
+ * N (no such reference, or one without a URL). The ONE binding test shared by
3241
+ * both token expanders and the unwired-token pass, so "this token is wired"
3242
+ * means the same thing to all three.
3243
+ */
3244
+ function tokenFinalSlot(
3245
+ n: number,
3246
+ refs: readonly ConnectedReference[],
3247
+ finalIndexByUrl: ReadonlyMap<string, number>,
3248
+ ): number | undefined {
3249
+ const ref = refs[n - 1]
3250
+ return ref?.url ? finalIndexByUrl.get(ref.url) : undefined
3251
+ }
3252
+
3253
+ /** Whether position N has an image among `count` flat references (the
3254
+ * no-`connectedReferences` path). Shared by its expander and the unwired pass. */
3255
+ function hasImageAt(n: number, count: number): boolean {
3256
+ return n >= 1 && n <= count
3257
+ }
3258
+
3259
+ /**
3260
+ * The last step of image prompt assembly. Every `{image:N}` token still in the
3261
+ * prompt whose N has no image follows the unwired rule SHARED with the video
3262
+ * resolver (`resolveReferenceTokens`): a labelled token becomes its label, an
3263
+ * unlabelled one is dropped, and the horizontal gap left behind is collapsed
3264
+ * and the prompt trimmed.
3265
+ *
3266
+ * WIRED OUTPUT IS UNCHANGED BY CONSTRUCTION, on two counts:
3267
+ * - a token whose N binds (`isWired`) is returned verbatim — the expanders
3268
+ * already rendered every such token they own;
3269
+ * - the tidy runs only when at least one token was dropped, so a prompt whose
3270
+ * tokens all bind keeps every doubled space and indent its author typed.
3271
+ * (The video resolver tidies unconditionally; the image path never did, and
3272
+ * a wired image prompt stays byte-identical.)
3273
+ *
3274
+ * TWO PLACES KEEP A TOKEN AS TYPED, on purpose:
3275
+ * - a WIRED token the hybrid format never expands — its expansion covers the
3276
+ * scene only, so one typed into the negative prompt keeps its text in the
3277
+ * `Avoid:` line (rewriting it would move a wired output);
3278
+ * - the native negative prompt (`nativeNegativePrompt`, the models in
3279
+ * `NATIVE_NEGATIVE_PROMPT_MODELS` such as ideogram-v3) is never read by this
3280
+ * pass — it goes to the provider's own negative field as typed, wired or not.
3281
+ */
3282
+ function dropUnwiredImageTokens(prompt: string, isWired: (n: number) => boolean): string {
3283
+ let dropped = false
3284
+ const out = prompt.replace(IMAGE_TOKEN_PATTERN, (match: string, num: string, label?: string) => {
3285
+ if (isWired(parseInt(num, 10))) return match
3286
+ dropped = true
3287
+ return unboundReferenceTokenText(label)
3288
+ })
3289
+ return dropped ? tidyReferenceTokenGaps(out) : prompt
3290
+ }
3291
+
3224
3292
  interface ResolvedIdentity {
3225
3293
  imageIndex: number
3226
3294
  label: string // empty string = bare positional ref (no role)
@@ -3506,7 +3574,9 @@ function buildNonCharacterDirectives(
3506
3574
  * consistent identifier in both the bulleted list and the scene description.
3507
3575
  * Numeric indices match the user-typed slug format (`@kira:1:smile`).
3508
3576
  *
3509
- * Out-of-range indices are left untouched so they're visible in the output.
3577
+ * Out-of-range indices are left untouched by this helper. `buildImagePrompt`
3578
+ * then renders any such token by the unwired rule (`dropUnwiredImageTokens`):
3579
+ * its label, or nothing — never the raw token.
3510
3580
  */
3511
3581
  export function expandImageRefTokens(prompt: string, imageCount: number): string {
3512
3582
  return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
@@ -3525,8 +3595,9 @@ export function expandImageRefTokens(prompt: string, imageCount: number): string
3525
3595
  *
3526
3596
  * When the final slot equals the token index (no Phase-0 prefix shifting the
3527
3597
  * positions), the output is byte-identical to `expandImageRefTokens`.
3528
- * Out-of-range tokens (or refs without a URL) are left untouched, matching the
3529
- * "visible so the author can fix it" contract.
3598
+ * Out-of-range tokens (or refs without a URL) are left untouched here; the
3599
+ * assembly's last step, `dropUnwiredImageTokens`, renders them by the unwired
3600
+ * rule with the same `tokenFinalSlot` binding test.
3530
3601
  */
3531
3602
  function expandImageRefTokensForRefs(
3532
3603
  prompt: string,
@@ -3534,9 +3605,7 @@ function expandImageRefTokensForRefs(
3534
3605
  finalIndexByUrl: ReadonlyMap<string, number>,
3535
3606
  ): string {
3536
3607
  return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
3537
- const n = parseInt(num, 10)
3538
- const ref = refs[n - 1]
3539
- const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
3608
+ const finalIdx = tokenFinalSlot(parseInt(num, 10), refs, finalIndexByUrl)
3540
3609
  if (!finalIdx) return match
3541
3610
  return label ? `Image ${finalIdx} (${label})` : `Image ${finalIdx}`
3542
3611
  })
@@ -3571,8 +3640,8 @@ function hybridRolePhrase(label: string, letter: string): string {
3571
3640
  /**
3572
3641
  * Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
3573
3642
  * ("the subject from reference image A") instead of the legacy "Image N
3574
- * (label)". Out-of-range tokens / URL-less refs are left untouched — visible so
3575
- * the author can fix them.
3643
+ * (label)". Out-of-range tokens / URL-less refs are left untouched here and
3644
+ * rendered by the unwired rule at the end of assembly (`dropUnwiredImageTokens`).
3576
3645
  *
3577
3646
  * A `{image:N:label}` pill is a mention chip like any other: `buildRefPillNodes`
3578
3647
  * appends its own trailing space to EVERY pill it builds, the positional
@@ -3591,9 +3660,7 @@ function expandImageRefTokensHybrid(
3591
3660
  ): string {
3592
3661
  const splices: Array<{ offset: number; length: number; phrase: string }> = []
3593
3662
  for (const m of prompt.matchAll(IMAGE_TOKEN_PATTERN)) {
3594
- const n = parseInt(m[1], 10)
3595
- const ref = refs[n - 1]
3596
- const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
3663
+ const finalIdx = tokenFinalSlot(parseInt(m[1], 10), refs, finalIndexByUrl)
3597
3664
  if (!finalIdx) continue
3598
3665
  splices.push({
3599
3666
  offset: m.index ?? 0,
@@ -3669,7 +3736,7 @@ export function expandImagePositionRefs(
3669
3736
  ): string {
3670
3737
  return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
3671
3738
  const n = parseInt(num, 10)
3672
- if (n < 1 || n > imageCount) return match
3739
+ if (!hasImageAt(n, imageCount)) return match
3673
3740
  if (label) return `Image ${n} (${label})`
3674
3741
  if (names && names[n - 1]) return names[n - 1]
3675
3742
  return `Image ${n}`
@@ -178,6 +178,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
178
178
  "seedream": "Artistic, painterly styles, creative interpretation",
179
179
  "seedream-5-lite": "Lighter Seedream, faster artistic generation",
180
180
  "seedream-5-pro": "Flagship Seedream — strongest instruction following, 2K via high quality",
181
+ "seedream-5-flash": "Fast, low-cost Seedream 5 — quick drafts and iteration at 1K or 2K",
181
182
  "z-image": "Experimental, novel generation approaches",
182
183
  "wan-2.7": "Wan 2.7 T2I — 1K/2K/4K, up to 9 ref images",
183
184
  "wan-2.7-pro": "Wan 2.7 Pro T2I — higher quality, 1K/2K/4K",
@@ -202,6 +203,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
202
203
  "seedream-edit": "Artistic style editing",
203
204
  "seedream-5-lite-i2i": "Light artistic transformation",
204
205
  "seedream-5-pro-i2i": "Pro-grade instruction-based edits, multi-reference",
206
+ "seedream-5-flash-i2i": "Fast, low-cost Seedream edits — up to 10 input images",
205
207
  "flux-kontext": "Character-consistent edits with reference awareness",
206
208
  "flux-kontext-max": "Premium character-consistent editing",
207
209
  "kontext-multi": "Multi-image Kontext via Replicate — up to 4 refs, no safety filter",
@@ -226,6 +228,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
226
228
  "seedream-edit": "Artistic style editing",
227
229
  "seedream-5-lite-i2i": "Light artistic transformation",
228
230
  "seedream-5-pro-i2i": "Pro-grade instruction-based edits, multi-reference",
231
+ "seedream-5-flash-i2i": "Fast, low-cost Seedream edits — up to 10 input images",
229
232
  "flux-kontext": "Character-consistent edits with reference awareness",
230
233
  "flux-kontext-max": "Premium character-consistent editing",
231
234
  "kontext-multi": "Multi-image Kontext via Replicate — up to 4 refs, no safety filter",
@@ -249,7 +252,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
249
252
  "seedance-2-fast": "Seedance 2.0 Fast — same multimodal + audio capabilities, cheaper and quicker",
250
253
  "seedance-2-mini": "Seedance 2.0 Mini — same multimodal + audio capabilities, budget tier, 480p/720p, 4-15s",
251
254
  "seedance-2-5": "Seedance 2.5 — up to 30s in ONE shot (no stitching), wider multimodal refs (30 images / 10 videos / 10 audio), native audio, 480p/720p/1080p",
252
- "minimax-h3": "MiniMax Hailuo 3 — premium multimodal refs (9 images / 3 videos / 3 audio), always-on audio, 2K or 768P, 4-15s per-second pricing",
255
+ "minimax-h3": "MiniMax H3 — premium multimodal refs (9 images / 3 videos / 3 audio), always-on audio, 2K or 768P, 4-15s per-second pricing",
253
256
  "wan": "Versatile, good for animations and transformations",
254
257
  "wan-turbo": "Faster Wan generation",
255
258
  "hailuo-standard": "Standard quality, cost-effective",
@@ -280,7 +283,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
280
283
  "seedance-2-fast": "Seedance 2.0 Fast — same capabilities, cheaper and quicker",
281
284
  "seedance-2-mini": "Seedance 2.0 Mini — same capabilities, budget tier, 480p/720p",
282
285
  "seedance-2-5": "Seedance 2.5 — start/end frame + wide multimodal refs, native audio, up to 30s, 480p/720p/1080p",
283
- "minimax-h3": "MiniMax Hailuo 3 — first/last frame + multimodal refs, always-on audio, 2K or 768P, 4-15s",
286
+ "minimax-h3": "MiniMax H3 — first/last frame + multimodal refs, always-on audio, 2K or 768P, 4-15s",
284
287
  "hailuo-2.3-pro": "Premium Hailuo animation",
285
288
  "hailuo-2.3": "Standard Hailuo animation",
286
289
  "hailuo-standard": "Cost-effective animation",
@@ -23,7 +23,7 @@
23
23
  * - KIE API docs https://docs.kie.ai/market/kling/kling-3-0
24
24
  * - Live KIE-path dialogue probe 2026-07-16 (scripted lines spoken verbatim,
25
25
  * lip-synced, on both kling-2.6 and kling-3.0 with sound=true)
26
- * MiniMax Hailuo 3:
26
+ * MiniMax H3:
27
27
  * - KIE API docs https://docs.kie.ai/market/minimax-h3 (text-to-video /
28
28
  * image-to-video / reference-to-video — the API contract is the doctrine
29
29
  * source: limits, modes, aspect rules, pricing dimensions). No live probe
@@ -160,7 +160,7 @@ const KLING_AUDIO_DOCTRINE: ProviderPromptDoctrine = {
160
160
 
161
161
  const MINIMAX_H3_DOCTRINE: ProviderPromptDoctrine = {
162
162
  providers: ["minimax-h3"],
163
- heading: "MiniMax Hailuo 3 (minimax-h3)",
163
+ heading: "MiniMax H3 (minimax-h3)",
164
164
  tips: [
165
165
  "Natural-language prompts up to 7000 chars. Front-load what matters: subject → action → scene/environment → lighting → camera move → style. One camera movement per shot.",
166
166
  "Output 2K (default) or 768P (cheaper tier). Aspect: pure text-to-video needs a concrete ratio (21:9/16:9/4:3/1:1/3:4/9:16); reference runs default to adaptive (match the input).",
@@ -0,0 +1,39 @@
1
+ // packages/prompts/src/realism-wording.ts
2
+ /**
3
+ * Realism and viewpoint wording for everyday-photo prompts: skin and finish
4
+ * sentences, the identity-keeping realism instruction for an edit, and the eye-level
5
+ * viewpoint and empty-hands sentences, worded for any person.
6
+ */
7
+
8
+ /** Who a sentence is about: gendered ("she" / "her") or neutral ("the person" / "their"). */
9
+ export interface PersonWords {
10
+ readonly subject: string
11
+ readonly possessive: string
12
+ }
13
+
14
+ export const NEUTRAL_PERSON: PersonWords = { subject: "the person", possessive: "their" }
15
+
16
+ const cap = (s: string): string => (s.length === 0 ? s : `${s[0]!.toUpperCase()}${s.slice(1)}`)
17
+
18
+ export const REALISM_SKIN =
19
+ "Skin shows its real structure: visible pores that vary in size across the nose and cheeks, fine vellus hair catching the light, faint uneven pigmentation, a little redness around the nostrils, natural oil on the forehead."
20
+
21
+ export const REALISM_FINISH =
22
+ "The plain look of an everyday small-sensor snapshot: colour left as it was recorded, deep focus from the nearest shoulder to the background, a fine grain in the darker areas, bright areas that fade rather than clip."
23
+
24
+ export const REALISM_PASS_INSTRUCTION =
25
+ "Make this photo look like an unretouched everyday snapshot: skin that shows its pores, its " +
26
+ "fine hairs and the small changes in tone real skin has, colour left as it was recorded, everything " +
27
+ "sharp from the shoulders to the background, a little grain in the shadows. Take out anything that makes it " +
28
+ "look rendered — smoothed, waxy or airbrushed skin, a glow, over-sharpened edges — and add no text or icons. " +
29
+ "Otherwise it stays the same person in the same frame: the face and its proportions, hair, clothing, pose and " +
30
+ "background as they are."
31
+
32
+ /** The frame by its viewpoint only — never by a device. */
33
+ export function realismViewpoint(p: PersonWords): string {
34
+ return `${cap(p.subject)} is looking straight at the viewer from eye level, mid-sentence: ${p.possessive} face and shoulders close up, the framing loose and a little crooked, both arms relaxed and down out of the frame. The whole image is the photograph itself, edge to edge, with no text or icons anywhere.`
35
+ }
36
+
37
+ export function realismEmptyHands(p: PersonWords): string {
38
+ return `${cap(p.possessive)} hands are empty; ${p.subject} is holding nothing.`
39
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The unwired half of the editor reference-token contract — what a
3
+ * `{image:N:label}` / `{video:N:label}` / `{audio:N:label}` token becomes when
4
+ * no reference of its kind sits at its position. ONE rule for every surface:
5
+ * the video resolver (`resolveReferenceTokens`) and the image prompt builder
6
+ * (`buildImagePrompt`) both render an unwired token through these two
7
+ * functions, so the image and video paths cannot drift apart on it.
8
+ *
9
+ * Each caller keeps its own token grammar and its own WIRED rendering (video:
10
+ * `the person from @image_1`; image: `Image 1 (person)` /
11
+ * `the person from reference image A`); only the unwired outcome is shared.
12
+ */
13
+
14
+ /**
15
+ * The text an unwired token renders to: its label as written, or nothing for a
16
+ * label-less token. Text a resolver does not rewrite keeps its tokens as typed
17
+ * — on image prompts a wired token in a hybrid `Avoid:` line and the native
18
+ * negative prompt, both on purpose (see `dropUnwiredImageTokens`).
19
+ */
20
+ export function unboundReferenceTokenText(label: string | undefined): string {
21
+ return label ?? ""
22
+ }
23
+
24
+ /**
25
+ * Tidy the gap a dropped token leaves: collapse every run of 2+ HORIZONTAL
26
+ * whitespace to one space, then trim. The class is `[^\S\r\n]` (NOT `\s`) so
27
+ * the `\n` / `\n\n` separators between assembled blocks survive — a `\s{2,}`
28
+ * collapse would merge paragraphs.
29
+ */
30
+ export function tidyReferenceTokenGaps(prompt: string): string {
31
+ return prompt.replace(/[^\S\r\n]{2,}/g, " ").trim()
32
+ }