@nodaro/prompts 1.34.0 → 1.35.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.
@@ -4,8 +4,8 @@ import type { FactoryPreset } from "./types.js"
4
4
  * Selective-stylization presets shared by `modify-image` (transform an input photo) and
5
5
  * `generate-image` (use a connected reference image). `modify-image` is slated for deprecation in
6
6
  * 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).
7
+ * when `modify-image` is removed, just drop its key from `FACTORY_PRESETS` below. The provider is
8
+ * set per node by {@link editProviderFor}.
9
9
  */
10
10
  const STYLIZED_SUBJECT: ReadonlyArray<{
11
11
  readonly slug: string
@@ -18,7 +18,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
18
18
  name: "Cartoon Person, Real World",
19
19
  description: "Subject → 3D cartoon, rest stays photoreal.",
20
20
  data: {
21
- provider: "nano-banana-pro",
22
21
  promptPrefix:
23
22
  "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
23
  negativePrompt:
@@ -30,7 +29,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
30
29
  name: "Caricature, Real Photo",
31
30
  description: "Exaggerated cartoon head on a real scene.",
32
31
  data: {
33
- provider: "nano-banana-pro",
34
32
  promptPrefix:
35
33
  "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
34
  negativePrompt:
@@ -42,7 +40,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
42
40
  name: "Anime Person, Real Background",
43
41
  description: "Subject → 2D anime, real environment.",
44
42
  data: {
45
- provider: "nano-banana-pro",
46
43
  promptPrefix:
47
44
  "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
45
  negativePrompt:
@@ -54,7 +51,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
54
51
  name: "Real Person, Cartoon World",
55
52
  description: "Inverse — real subject, stylized world.",
56
53
  data: {
57
- provider: "nano-banana-pro",
58
54
  promptPrefix:
59
55
  "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
56
  negativePrompt:
@@ -66,7 +62,6 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
66
62
  name: "Claymation Figure, Real Set",
67
63
  description: "Subject → clay figure, real surroundings.",
68
64
  data: {
69
- provider: "nano-banana-pro",
70
65
  promptPrefix:
71
66
  "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
67
  negativePrompt:
@@ -76,13 +71,23 @@ const STYLIZED_SUBJECT: ReadonlyArray<{
76
71
  ]
77
72
 
78
73
  /** Build the Stylized Subject presets for a node type (id = `<nodeType>/<slug>`). */
74
+ /**
75
+ * Both preset families are edits of a connected photo, so they run on the platform's edit model,
76
+ * GPT Image 2.5 Flare (decided 2026-10-08 from the image model bake-off: it kept pose, framing and
77
+ * product design on every edit). Modify Image takes the i2i id; Generate Image takes the t2i id,
78
+ * which the platform swaps to the same i2i model whenever a reference image is connected.
79
+ */
80
+ export function editProviderFor(nodeType: string): string {
81
+ return nodeType === "modify-image" ? "gpt-image-2-5-flare-i2i" : "gpt-image-2-5-flare"
82
+ }
83
+
79
84
  export function stylizedSubjectFor(nodeType: string): FactoryPreset[] {
80
85
  return STYLIZED_SUBJECT.map((p) => ({
81
86
  id: `${nodeType}/${p.slug}`,
82
87
  name: p.name,
83
88
  description: p.description,
84
89
  group: "Stylized Subject",
85
- data: p.data,
90
+ data: { provider: editProviderFor(nodeType), ...p.data },
86
91
  }))
87
92
  }
88
93
 
@@ -102,7 +107,6 @@ const IMAGE_EDITS: ReadonlyArray<{
102
107
  name: "Remove Background",
103
108
  description: "Cut out the subject on white.",
104
109
  data: {
105
- provider: "nano-banana-pro",
106
110
  promptPrefix:
107
111
  "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
112
  negativePrompt:
@@ -114,7 +118,6 @@ const IMAGE_EDITS: ReadonlyArray<{
114
118
  name: "Replace Background",
115
119
  description: "Swap in a new scene.",
116
120
  data: {
117
- provider: "nano-banana-pro",
118
121
  prompt:
119
122
  "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
123
  negativePrompt:
@@ -126,7 +129,6 @@ const IMAGE_EDITS: ReadonlyArray<{
126
129
  name: "Colorize B&W Photo",
127
130
  description: "Natural color from grayscale.",
128
131
  data: {
129
- provider: "nano-banana-pro",
130
132
  promptPrefix:
131
133
  "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
134
  negativePrompt:
@@ -138,7 +140,6 @@ const IMAGE_EDITS: ReadonlyArray<{
138
140
  name: "Restore Old Photo",
139
141
  description: "Repair scratches, denoise, sharpen.",
140
142
  data: {
141
- provider: "nano-banana-pro",
142
143
  promptPrefix:
143
144
  "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
145
  negativePrompt:
@@ -150,7 +151,6 @@ const IMAGE_EDITS: ReadonlyArray<{
150
151
  name: "Relight Scene",
151
152
  description: "New lighting, same subject.",
152
153
  data: {
153
- provider: "nano-banana-pro",
154
154
  prompt:
155
155
  "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
156
  negativePrompt:
@@ -162,7 +162,6 @@ const IMAGE_EDITS: ReadonlyArray<{
162
162
  name: "Restyle (whole image)",
163
163
  description: "Apply an art style to everything.",
164
164
  data: {
165
- provider: "nano-banana-pro",
166
165
  prompt:
167
166
  "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
167
  negativePrompt:
@@ -174,7 +173,6 @@ const IMAGE_EDITS: ReadonlyArray<{
174
173
  name: "Doodle Overlay",
175
174
  description: "Hand-drawn marker doodles on top — photo stays untouched.",
176
175
  data: {
177
- provider: "nano-banana-pro",
178
176
  promptPrefix:
179
177
  "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
178
  negativePrompt:
@@ -186,7 +184,6 @@ const IMAGE_EDITS: ReadonlyArray<{
186
184
  name: "Doodle Overlay · Expressive",
187
185
  description: "Looser free-form doodles — the model improvises.",
188
186
  data: {
189
- provider: "nano-banana-pro",
190
187
  promptPrefix:
191
188
  "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
189
  negativePrompt:
@@ -202,6 +199,6 @@ export function editsFor(nodeType: string): FactoryPreset[] {
202
199
  name: p.name,
203
200
  description: p.description,
204
201
  group: "Edits",
205
- data: p.data,
202
+ data: { provider: editProviderFor(nodeType), ...p.data },
206
203
  }))
207
204
  }
@@ -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\`).
@@ -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",
@@ -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
+ }
@@ -42,6 +42,7 @@ import {
42
42
  import { REF_BINDING } from "./ref-binding.js"
43
43
  import { resolveRefIdTokens } from "./ref-id-tokens.js"
44
44
  import { insertBeforeStyleSection } from "./prompt-style-section.js"
45
+ import { tidyReferenceTokenGaps, unboundReferenceTokenText } from "./unbound-reference-tokens.js"
45
46
 
46
47
  // The binding surface string and the id-addressed token resolver live in their
47
48
  // own modules (see them for the contracts); re-exported here so every existing
@@ -76,19 +77,22 @@ const REFERENCE_TOKEN_RE = /\{(image|video|audio):(\d+)(?::([a-zA-Z0-9_ -]+))?\}
76
77
  * - `N < 1` or `N > counts[kind]` (out of range / no such reference) → drop to
77
78
  * the bare `label` (or empty if label-less). This is the legacy
78
79
  * `stripVideoImageTokens` strip behavior: drop the token, keep the label text.
80
+ * The rendering is `unboundReferenceTokenText` — shared with the image prompt
81
+ * builder, which applies the same rule to its own unwired `{image:N}` tokens.
79
82
  * - in range, label present → `REF_BINDING[kind](label, N)`
80
83
  * (e.g. `the person from @image_2`).
81
84
  * - in range, no label → the bare `@${kind}_${N}` (the ref-only default): the
82
85
  * binding lands with no descriptive wrapper.
83
86
  *
84
87
  * Runs of 2+ HORIZONTAL whitespace (left behind by a dropped label-less token)
85
- * collapse to one space, the result is trimmed, and an empty result becomes
86
- * `undefined` — matching the `stripVideoImageTokens` contract so this can replace
87
- * it cleanly. The collapse class is `[^\S\r\n]` (NOT `\s`) on purpose: Task 2.4
88
- * applies this to the FULLY-ASSEMBLED core prompt, which carries `\n\n` block
89
- * separators between the "Use these characters:" directive block and the body —
90
- * a `\s{2,}` collapse would silently merge those paragraphs. Horizontal-only
91
- * collapse preserves newline structure while still tidying the dropped-token gap.
88
+ * collapse to one space, the result is trimmed (`tidyReferenceTokenGaps`, also
89
+ * shared with the image builder), and an empty result becomes `undefined` —
90
+ * matching the `stripVideoImageTokens` contract so this can replace it cleanly.
91
+ * The collapse class is `[^\S\r\n]` (NOT `\s`) on purpose: the core applies this
92
+ * to the FULLY-ASSEMBLED prompt, which carries `\n\n` block separators between
93
+ * the "Use these characters:" directive block and the body — a `\s{2,}` collapse
94
+ * would silently merge those paragraphs. Horizontal-only collapse preserves
95
+ * newline structure while still tidying the dropped-token gap.
92
96
  * The `kind` is lowercased before indexing `counts`/`REF_BINDING`, so a
93
97
  * case-variant token (`{Image:1}`) resolves to the same binding rather than
94
98
  * mis-classifying as out-of-range.
@@ -98,19 +102,15 @@ export function resolveReferenceTokens(
98
102
  counts: ReferenceCounts,
99
103
  ): string | undefined {
100
104
  if (!prompt) return prompt
101
- return (
102
- prompt
103
- .replace(REFERENCE_TOKEN_RE, (_match, rawKind: string, nStr: string, label?: string) => {
104
- const kind = rawKind.toLowerCase() as keyof ReferenceCounts
105
- const n = parseInt(nStr, 10)
106
- if (n < 1 || n > counts[kind]) return label ?? ""
107
- if (label) return REF_BINDING[kind](label, n)
108
- return `@${kind}_${n}`
109
- })
110
- // Horizontal whitespace only — preserve `\n` / `\n\n` block separators.
111
- .replace(/[^\S\r\n]{2,}/g, " ")
112
- .trim() || undefined
113
- )
105
+ return tidyReferenceTokenGaps(
106
+ prompt.replace(REFERENCE_TOKEN_RE, (_match, rawKind: string, nStr: string, label?: string) => {
107
+ const kind = rawKind.toLowerCase() as keyof ReferenceCounts
108
+ const n = parseInt(nStr, 10)
109
+ if (n < 1 || n > counts[kind]) return unboundReferenceTokenText(label)
110
+ if (label) return REF_BINDING[kind](label, n)
111
+ return `@${kind}_${n}`
112
+ }),
113
+ ) || undefined
114
114
  }
115
115
 
116
116