@nodaro/prompts 1.38.0 → 1.40.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 (130) hide show
  1. package/dist/chunk-2EW5B7ZV.cjs +86 -0
  2. package/dist/chunk-2EW5B7ZV.cjs.map +1 -0
  3. package/dist/chunk-2P26SZX5.cjs +414 -0
  4. package/dist/chunk-2P26SZX5.cjs.map +1 -0
  5. package/dist/{chunk-7XKHDXCL.cjs → chunk-3U7X6JZN.cjs} +13 -12
  6. package/dist/chunk-3U7X6JZN.cjs.map +1 -0
  7. package/dist/chunk-4GKTOVFM.cjs +8 -0
  8. package/dist/chunk-4GKTOVFM.cjs.map +1 -0
  9. package/dist/{chunk-HRBBNODV.js → chunk-6GVFJLDL.js} +4 -3
  10. package/dist/chunk-6GVFJLDL.js.map +1 -0
  11. package/dist/{chunk-LVSH7K7R.cjs → chunk-C3NHNECH.cjs} +165 -88
  12. package/dist/chunk-C3NHNECH.cjs.map +1 -0
  13. package/dist/{chunk-CEPVQQVO.js → chunk-C46256GZ.js} +43 -5
  14. package/dist/chunk-C46256GZ.js.map +1 -0
  15. package/dist/{chunk-JKCR6WRZ.cjs → chunk-DQFQJECU.cjs} +43 -5
  16. package/dist/chunk-DQFQJECU.cjs.map +1 -0
  17. package/dist/{chunk-QDEWBBUR.js → chunk-DUXX6CBX.js} +12 -143
  18. package/dist/chunk-DUXX6CBX.js.map +1 -0
  19. package/dist/chunk-EAYEZXWT.js +388 -0
  20. package/dist/chunk-EAYEZXWT.js.map +1 -0
  21. package/dist/chunk-GILUVQQJ.js +6 -0
  22. package/dist/chunk-GILUVQQJ.js.map +1 -0
  23. package/dist/{chunk-KIYNOOPJ.js → chunk-GTDKDD7Y.js} +12 -6
  24. package/dist/chunk-GTDKDD7Y.js.map +1 -0
  25. package/dist/{chunk-QBEZMR2F.js → chunk-HQTHDTHN.js} +137 -62
  26. package/dist/chunk-HQTHDTHN.js.map +1 -0
  27. package/dist/{chunk-TLVX5PXD.cjs → chunk-IUCNJAHW.cjs} +13 -7
  28. package/dist/chunk-IUCNJAHW.cjs.map +1 -0
  29. package/dist/{chunk-ZFIXQRBZ.cjs → chunk-LXPKTI2K.cjs} +12 -83
  30. package/dist/chunk-LXPKTI2K.cjs.map +1 -0
  31. package/dist/{chunk-CVPB2N3Z.cjs → chunk-LZ53F5BN.cjs} +103 -5
  32. package/dist/chunk-LZ53F5BN.cjs.map +1 -0
  33. package/dist/{chunk-4FDNVNJE.js → chunk-N6VMPG5S.js} +12 -79
  34. package/dist/chunk-N6VMPG5S.js.map +1 -0
  35. package/dist/{chunk-LK2CZK5U.cjs → chunk-PWWM7Q3T.cjs} +7 -7
  36. package/dist/{chunk-LK2CZK5U.cjs.map → chunk-PWWM7Q3T.cjs.map} +1 -1
  37. package/dist/{chunk-N7TOWXDJ.js → chunk-QDUTMOF4.js} +3 -3
  38. package/dist/{chunk-N7TOWXDJ.js.map → chunk-QDUTMOF4.js.map} +1 -1
  39. package/dist/{chunk-NO6XHX4R.js → chunk-RB2F5JQF.js} +100 -7
  40. package/dist/chunk-RB2F5JQF.js.map +1 -0
  41. package/dist/{chunk-MENBHEAY.cjs → chunk-WRM52ZBN.cjs} +19 -161
  42. package/dist/chunk-WRM52ZBN.cjs.map +1 -0
  43. package/dist/chunk-YS6HOWMW.js +80 -0
  44. package/dist/chunk-YS6HOWMW.js.map +1 -0
  45. package/dist/entries/assembly.cjs +34 -25
  46. package/dist/entries/assembly.d.cts +121 -37
  47. package/dist/entries/assembly.d.ts +121 -37
  48. package/dist/entries/assembly.js +5 -4
  49. package/dist/entries/doctrine.cjs +46 -46
  50. package/dist/entries/doctrine.d.cts +3 -3
  51. package/dist/entries/doctrine.d.ts +3 -3
  52. package/dist/entries/doctrine.js +1 -1
  53. package/dist/entries/picker-catalogs.cjs +42 -42
  54. package/dist/entries/picker-catalogs.js +3 -3
  55. package/dist/entries/presets.cjs +49 -26
  56. package/dist/entries/presets.d.cts +53 -1
  57. package/dist/entries/presets.d.ts +53 -1
  58. package/dist/entries/presets.js +4 -1
  59. package/dist/entries/prompt-builder.cjs +135 -98
  60. package/dist/entries/prompt-builder.d.cts +22 -11
  61. package/dist/entries/prompt-builder.d.ts +22 -11
  62. package/dist/entries/prompt-builder.js +4 -3
  63. package/dist/entries/prompt-fields.cjs +41 -40
  64. package/dist/entries/prompt-fields.d.cts +2 -1
  65. package/dist/entries/prompt-fields.d.ts +2 -1
  66. package/dist/entries/prompt-fields.js +2 -1
  67. package/dist/index.cjs +339 -273
  68. package/dist/index.d.cts +4 -4
  69. package/dist/index.d.ts +4 -4
  70. package/dist/index.js +11 -9
  71. package/dist/{subject-registry-BUswuEVD.d.cts → subject-registry-CQi_CZfJ.d.cts} +78 -9
  72. package/dist/{subject-registry-D7V8PEG-.d.ts → subject-registry-DFiGx49v.d.ts} +78 -9
  73. package/package.json +2 -2
  74. package/src/__tests__/assemble-image-input-style-references.test.ts +173 -0
  75. package/src/__tests__/catalog-id-guard.test.ts +30 -0
  76. package/src/__tests__/factory-preset-previews.test.ts +123 -0
  77. package/src/__tests__/factory-preset-references.test.ts +222 -0
  78. package/src/__tests__/factory-presets-prototype-keys.test.ts +43 -0
  79. package/src/__tests__/frame-reference-slots.test.ts +43 -0
  80. package/src/__tests__/gemini-omni-inputs.test.ts +12 -10
  81. package/src/__tests__/provider-prompt-doctrine.test.ts +14 -0
  82. package/src/__tests__/user-direction-refs.test.ts +180 -0
  83. package/src/__tests__/veo-i2v-inputs.test.ts +16 -11
  84. package/src/assemble-image-input.ts +114 -13
  85. package/src/catalog-id-guard.ts +18 -2
  86. package/src/color-names.ts +192 -0
  87. package/src/direction-registry.ts +66 -5
  88. package/src/entries/assembly.ts +1 -0
  89. package/src/entries/prompt-builder.ts +2 -0
  90. package/src/factory-presets/generate-image.ts +29 -0
  91. package/src/factory-presets/index.ts +6 -1
  92. package/src/factory-presets/preview-files.generated.ts +4 -0
  93. package/src/factory-presets/previews.ts +40 -0
  94. package/src/factory-presets/references.ts +81 -0
  95. package/src/factory-presets/shared-image.ts +4 -0
  96. package/src/factory-presets/types.ts +8 -0
  97. package/src/frame-reference-slots.ts +92 -0
  98. package/src/gemini-omni-inputs.ts +23 -22
  99. package/src/image-reference-doctrine.ts +3 -3
  100. package/src/image-token-pattern.ts +13 -0
  101. package/src/node-prompt-fields.ts +5 -3
  102. package/src/own-entry.ts +11 -0
  103. package/src/prompt-builder.ts +4 -5
  104. package/src/prompt-style-section.ts +3 -3
  105. package/src/prompt-wizard-categories.ts +2 -0
  106. package/src/provider-prompt-doctrine.ts +45 -3
  107. package/src/ref-binding.ts +28 -3
  108. package/src/resolve-prompt.ts +2 -1
  109. package/src/seedance-2-inputs.ts +15 -40
  110. package/src/user-direction-refs.ts +110 -0
  111. package/src/veo-i2v-inputs.ts +24 -17
  112. package/src/video-reference-resolver.ts +1 -1
  113. package/dist/chunk-4FDNVNJE.js.map +0 -1
  114. package/dist/chunk-7XKHDXCL.cjs.map +0 -1
  115. package/dist/chunk-CEPVQQVO.js.map +0 -1
  116. package/dist/chunk-CVPB2N3Z.cjs.map +0 -1
  117. package/dist/chunk-HRBBNODV.js.map +0 -1
  118. package/dist/chunk-JKCR6WRZ.cjs.map +0 -1
  119. package/dist/chunk-KIYNOOPJ.js.map +0 -1
  120. package/dist/chunk-LVSH7K7R.cjs.map +0 -1
  121. package/dist/chunk-MENBHEAY.cjs.map +0 -1
  122. package/dist/chunk-NO6XHX4R.js.map +0 -1
  123. package/dist/chunk-QBEZMR2F.js.map +0 -1
  124. package/dist/chunk-QDEWBBUR.js.map +0 -1
  125. package/dist/chunk-TLVX5PXD.cjs.map +0 -1
  126. package/dist/chunk-UJKFHPZ7.js +0 -37
  127. package/dist/chunk-UJKFHPZ7.js.map +0 -1
  128. package/dist/chunk-W5SCZIOB.cjs +0 -43
  129. package/dist/chunk-W5SCZIOB.cjs.map +0 -1
  130. package/dist/chunk-ZFIXQRBZ.cjs.map +0 -1
@@ -0,0 +1,81 @@
1
+ import { IMAGE_TOKEN_PATTERN } from "../image-token-pattern.js"
2
+ import { getPromptFields, nodeSupportsPromptAffixes, promptAffixCoreField } from "../node-prompt-fields.js"
3
+ import { NODE_PROMPT_CANDIDATE_FIELDS } from "../resolve-prompt.js"
4
+ import { ownEntry } from "../own-entry.js"
5
+ import { MODELS_WITH_REFERENCE_IMAGE_SUPPORT, imageReferenceLimit } from "@nodaro/shared/models"
6
+
7
+ /**
8
+ * The most reference images any image model takes. A preset can never need
9
+ * more than some model could be sent, so a count is clamped to it — a typo
10
+ * like `{image:99999}` must not tell a client to ask for 99,999 photos.
11
+ */
12
+ export const MAX_PRESET_REFERENCES: number = Math.max(
13
+ 1,
14
+ ...[...MODELS_WITH_REFERENCE_IMAGE_SUPPORT].map((provider) => imageReferenceLimit(provider)),
15
+ )
16
+
17
+ /**
18
+ * The data fields of `nodeType` a reference token can sit in — every field the
19
+ * run reads its prompt from: the candidate fields `computeNodePrompt` joins
20
+ * (`NODE_PROMPT_CANDIDATE_FIELDS` — how a preset's prompt is resolved when it
21
+ * runs, on the canvas and through `presetId` alike), the prompt-field
22
+ * registry's own prompt and negative fields, the field the run wraps with
23
+ * pre/post text, and the pre/post text itself where the node takes it. A node
24
+ * whose prompt lives elsewhere (`llm-chat` → `userInput`) is read where its
25
+ * prompt really is; a type with no prompt (an app namespace) has none.
26
+ */
27
+ function promptFieldsOf(nodeType: string): string[] {
28
+ const spec = getPromptFields(nodeType)
29
+ const candidates = ownEntry(NODE_PROMPT_CANDIDATE_FIELDS, nodeType) ?? []
30
+ if (!spec && candidates.length === 0) return []
31
+ const fields = new Set<string>(candidates)
32
+ if (spec) {
33
+ fields.add(spec.prompt)
34
+ if (spec.negative) fields.add(spec.negative)
35
+ }
36
+ const core = promptAffixCoreField(nodeType)
37
+ if (core) fields.add(core)
38
+ if (nodeSupportsPromptAffixes(nodeType)) {
39
+ fields.add("promptPrefix")
40
+ fields.add("promptSuffix")
41
+ }
42
+ return [...fields]
43
+ }
44
+
45
+ /** The highest `{image:N}` / `{image:N:label}` position in `text`, or 0. */
46
+ function highestImageToken(text: string): number {
47
+ let highest = 0
48
+ for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
49
+ const n = Number.parseInt(match[1] ?? "", 10)
50
+ if (Number.isInteger(n) && n > highest) highest = n
51
+ }
52
+ return highest
53
+ }
54
+
55
+ /**
56
+ * How many connected reference images a preset works from — 0 for a preset
57
+ * that needs none. A client shows a preset with a non-zero count as "needs a
58
+ * photo" (`needsImage`) and asks for that many before running it.
59
+ *
60
+ * Two sources, and the larger wins:
61
+ * - the `{image:N}` / `{image:N:label}` tokens in the preset's prompt fields
62
+ * (the highest N): a template that names its references always reports them;
63
+ * - `declared` — the factory preset's own `minReferences`, for presets whose
64
+ * prompt refers to "the attached photo" in words, which no data field marks.
65
+ * A user preset has no declaration, so its count comes from its tokens.
66
+ *
67
+ * Pure; never throws. A declaration that is not a positive whole number is
68
+ * ignored, and the result is at most {@link MAX_PRESET_REFERENCES}.
69
+ */
70
+ export function presetMinReferences(
71
+ nodeType: string,
72
+ data: Readonly<Record<string, unknown>>,
73
+ declared?: number,
74
+ ): number {
75
+ let count = typeof declared === "number" && Number.isInteger(declared) && declared > 0 ? declared : 0
76
+ for (const field of promptFieldsOf(nodeType)) {
77
+ const value = data[field]
78
+ if (typeof value === "string") count = Math.max(count, highestImageToken(value))
79
+ }
80
+ return Math.min(count, MAX_PRESET_REFERENCES)
81
+ }
@@ -89,6 +89,8 @@ export function stylizedSubjectFor(nodeType: string): FactoryPreset[] {
89
89
  description: p.description,
90
90
  group: "Stylized Subject",
91
91
  data: { provider: editProviderFor(nodeType), ...p.data },
92
+ // Restyles the person in the connected photo ("the reference image").
93
+ minReferences: 1,
92
94
  }))
93
95
  }
94
96
 
@@ -242,5 +244,7 @@ export function editsFor(nodeType: string): FactoryPreset[] {
242
244
  description: p.description,
243
245
  group: "Edits",
244
246
  data: { provider: editProviderFor(nodeType), ...p.data },
247
+ // Every edit operates on the connected photo.
248
+ minReferences: 1,
245
249
  }))
246
250
  }
@@ -12,6 +12,14 @@ export interface FactoryPreset {
12
12
  readonly groupKind?: "folder" | "section"
13
13
  /** Capture-shaped config (no label / fieldMappings / runtime keys). */
14
14
  readonly data: Readonly<Record<string, unknown>>
15
+ /**
16
+ * How many connected reference images the preset works from, stated for a
17
+ * preset whose prompt refers to "the attached photo" in words (Reference
18
+ * Sheet, Portrait Transformations, Stylized Subject, Edits, …). Leave it out
19
+ * otherwise: `{image:N}` tokens in the prompt are counted on their own, and
20
+ * the count a client sees is the larger of the two (`presetMinReferences`).
21
+ */
22
+ readonly minReferences?: number
15
23
  }
16
24
 
17
25
  /** A render-ready bucket of factory presets sharing one `group` (or the leading
@@ -0,0 +1,92 @@
1
+ import { identityRefsSentence, imageBindingFor } from "./ref-binding.js"
2
+
3
+ /**
4
+ * WHERE A START / END FRAME GOES WHEN IT SHARES A MODEL'S REFERENCE LIST — the
5
+ * one platform convention (2026-10-18), for every video model whose frames and
6
+ * reference images travel in a single image array (`videoProviderAppendsFrames`
7
+ * in @nodaro/shared):
8
+ *
9
+ * - the caller's own reference images come FIRST, in their order, so the
10
+ * caller's image token N (`{image:N}`, `{ref:<id>}`, a typed ordinal)
11
+ * always means their Nth reference;
12
+ * - the frames are appended AFTER them (start, then end) and named in the
13
+ * prompt as the opening / closing frame, in the provider's own syntax;
14
+ * - at the image cap the frames are KEPT — they are explicit intent — and
15
+ * trailing caller references are dropped and reported.
16
+ *
17
+ * `bindIdentities` adds the identity sentence the flat-image-list models carry
18
+ * (Gemini Omni, VEO, Grok, HappyHorse: nothing in their payload says which
19
+ * entries are identities, so the prompt says it). The Seedance-family resolver
20
+ * calls this without it — byte for byte what it always emitted.
21
+ */
22
+ export interface FrameReferenceSlotsArgs {
23
+ /** Consulted only to skip a duplicate opening-frame sentence. */
24
+ prompt?: string
25
+ firstFrameUrl?: string
26
+ lastFrameUrl?: string
27
+ /** The caller's own reference images, in priority order. */
28
+ refImageUrls?: readonly (string | undefined)[]
29
+ /** The provider's image cap — frames and references together. */
30
+ imageCap: number
31
+ /** The provider's reference syntax (`videoProviderReferenceSyntax`). */
32
+ syntax?: "@image_N" | "@image(n)" | "[Image N]"
33
+ bindIdentities?: boolean
34
+ }
35
+
36
+ export interface FrameReferenceSlotsResult {
37
+ /** `[...keptRefs, start?, end?]` — never longer than `imageCap` (bar the frames themselves). */
38
+ imageUrls: string[]
39
+ promptSuffix: string
40
+ /** Trailing caller references dropped to keep the frames. */
41
+ droppedRefImages: number
42
+ /** 1-based seats of the frames in `imageUrls`; 0 when absent. */
43
+ firstFrameOrdinal: number
44
+ lastFrameOrdinal: number
45
+ }
46
+
47
+ export function placeFramesAfterReferences(args: FrameReferenceSlotsArgs): FrameReferenceSlotsResult {
48
+ const refs = (args.refImageUrls ?? []).filter((u): u is string => typeof u === "string" && u.trim().length > 0)
49
+ const frameCount = (args.firstFrameUrl ? 1 : 0) + (args.lastFrameUrl ? 1 : 0)
50
+ const kept = refs.slice(0, Math.max(0, args.imageCap - frameCount))
51
+ const droppedRefImages = refs.length - kept.length
52
+
53
+ const imageUrls = [...kept]
54
+ let firstFrameOrdinal = 0
55
+ let lastFrameOrdinal = 0
56
+ if (args.firstFrameUrl) { imageUrls.push(args.firstFrameUrl); firstFrameOrdinal = imageUrls.length }
57
+ if (args.lastFrameUrl) { imageUrls.push(args.lastFrameUrl); lastFrameOrdinal = imageUrls.length }
58
+
59
+ const syntax = args.syntax ?? "@image_N"
60
+ const binding = imageBindingFor(syntax)
61
+ // The duplicate-binding check reads the platform's `@image_N` form, which is
62
+ // the only form a caller's prompt can already carry it in.
63
+ const promptBindsStart = syntax === "@image_N" && promptBindsFirstFrame(args.prompt)
64
+ let frameSentence = ""
65
+ if (promptBindsStart && firstFrameOrdinal > 0 && lastFrameOrdinal === 0) {
66
+ // The prompt already binds the first frame at its own (working) position.
67
+ } else if (firstFrameOrdinal > 0 && lastFrameOrdinal > 0) {
68
+ frameSentence = `Use ${binding.ordinal(firstFrameOrdinal)} as the opening (first) frame and ${binding.ordinal(lastFrameOrdinal)} as the closing (last) frame of the video.`
69
+ } else if (firstFrameOrdinal > 0) {
70
+ frameSentence = binding.frame(firstFrameOrdinal, "opening")
71
+ } else if (lastFrameOrdinal > 0) {
72
+ frameSentence = binding.frame(lastFrameOrdinal, "closing")
73
+ }
74
+ const identitySentence = args.bindIdentities && kept.length > 0 && frameCount > 0
75
+ ? identityRefsSentence(1, kept.length, binding.ordinal)
76
+ : ""
77
+ const promptSuffix = [frameSentence, identitySentence].filter(Boolean).join(" ")
78
+ return { imageUrls, promptSuffix, droppedRefImages, firstFrameOrdinal, lastFrameOrdinal }
79
+ }
80
+
81
+ /** True when the prompt ALREADY binds an image as the first/opening frame —
82
+ * e.g. the gvp overlap transport's colon-position directive ("use @image_1
83
+ * as the first frame, it is the last keyframe of @video_1"). Field finding
84
+ * (2026-07-20): the first-frame directive only works adjacent to the extend
85
+ * colon; a duplicate sentence at the END of the prompt (this resolver's
86
+ * default position) dilutes it back into coin-flip behavior — so when the
87
+ * prompt carries its own binding, the suffix is suppressed (the frame image
88
+ * itself still rides the reference list). */
89
+ export function promptBindsFirstFrame(prompt: string | undefined): boolean {
90
+ if (!prompt) return false
91
+ return /@image_\d+\s+as\s+the\s+(first|opening)\s*(\(first\))?\s*frame/i.test(prompt)
92
+ }
@@ -1,30 +1,33 @@
1
1
  import { VIDEO_REF_LIMITS_BY_PROVIDER } from "@nodaro/shared/models"
2
2
 
3
- import { promptBindsFirstFrame } from "./seedance-2-inputs.js"
4
- import { identityRefsSentence, REF_BINDING } from "./video-reference-resolver.js"
3
+ import { placeFramesAfterReferences } from "./frame-reference-slots.js"
5
4
 
6
5
  /**
7
6
  * Gemini Omni Video i2v input resolution — the sibling of
8
7
  * `resolveSeedance2Inputs` for a model whose multimodal channel is ONE flat
9
8
  * `image_urls` list.
10
9
  *
10
+ * THE PLATFORM CONVENTION (2026-10-18, `placeFramesAfterReferences`): the
11
+ * caller's identity references come first, so their image token N is their Nth
12
+ * reference, and the start frame is appended after them. Until then the frame
13
+ * led the list, so a caller's `@image_1` landed on the frame.
14
+ *
11
15
  * WHY BINDING IS LOAD-BEARING: Gemini Omni receives the start frame and the
12
16
  * identity references in the same array, with nothing in the payload marking
13
17
  * which is which. A multimodal model treats unbound images as loose context —
14
18
  * field finding (recast keyframes run, 2026-08-14): the identity references
15
19
  * rode every call and the cast still drifted part to part, because the prompt
16
20
  * never said the images WERE identities to keep. So the resolver names the
17
- * roles in a prompt suffix, through the same `REF_BINDING` swap-point every
18
- * other video binding uses: image 1 is the opening frame; the rest are
19
- * identity references, explicitly not frames.
21
+ * roles in a prompt suffix: the references are identity references, explicitly
22
+ * not frames, and the last image is the opening frame.
20
23
  *
21
24
  * BUDGETED, NEVER REJECTED, for the list this resolver assembles: KIE's quota
22
25
  * is `images + 2×videos ≤ 7`, and `runGeminiOmni` hard-rejects overflow. That
23
26
  * reject is right for a caller-assembled list (the user's own images should
24
27
  * not silently thin out) and wrong for THIS merge, where the overflow is our
25
28
  * own construction — so trailing references are dropped to fit, the start
26
- * frame always kept, mirroring `resolveSeedance2Inputs`' drop-trailing
27
- * convention, and the drop count is reported for the caller to log.
29
+ * frame always kept (the convention's tie-break), and the drop count is
30
+ * reported for the caller to log.
28
31
  *
29
32
  * BYTE-IDENTICAL when there is nothing to bind: no references ⇒ no suffix and
30
33
  * a single-image list — exactly what every plain gemini-omni i2v call has
@@ -34,7 +37,7 @@ import { identityRefsSentence, REF_BINDING } from "./video-reference-resolver.js
34
37
  export interface GeminiOmniI2vInputsArgs {
35
38
  /** The composed prompt, used only to detect an existing first-frame binding. */
36
39
  prompt?: string
37
- /** The start frame — always kept, always first in the list. */
40
+ /** The start frame — always kept, appended after the references. */
38
41
  firstFrameUrl: string
39
42
  /** Identity references, in priority order (trailing ones drop first). */
40
43
  refImageUrls?: Array<string | undefined>
@@ -48,7 +51,7 @@ export interface GeminiOmniI2vInputsArgs {
48
51
  }
49
52
 
50
53
  export interface GeminiOmniI2vInputsResult {
51
- /** `[firstFrameUrl, ...keptRefs]` — the `image_urls` payload, quota-fitted. */
54
+ /** `[...keptRefs, firstFrameUrl]` — the `image_urls` payload, quota-fitted. */
52
55
  imageUrls: string[]
53
56
  /** The role-binding sentences; empty when no reference survived the budget. */
54
57
  promptSuffix: string
@@ -64,18 +67,16 @@ function geminiOmniInputSlots(provider: string | undefined): number {
64
67
  }
65
68
 
66
69
  export function resolveGeminiOmniI2vInputs(args: GeminiOmniI2vInputsArgs): GeminiOmniI2vInputsResult {
67
- const refs = (args.refImageUrls ?? []).filter((u): u is string => typeof u === "string" && u.length > 0)
68
70
  const slots = geminiOmniInputSlots(args.provider) - (args.videoConnected ? 2 : 0)
69
- const refSlots = Math.max(0, slots - 1)
70
- const kept = refs.slice(0, refSlots)
71
- const droppedRefImages = refs.length - kept.length
72
- const imageUrls = [args.firstFrameUrl, ...kept]
73
- if (kept.length === 0) return { imageUrls, promptSuffix: "", droppedRefImages }
74
-
75
- // The opening-frame sentence is suppressed when the prompt already binds it
76
- // at its own (working) position — same field-finding rule as seedance-2: a
77
- // duplicate directive at the end dilutes the one that works.
78
- const frameSentence = promptBindsFirstFrame(args.prompt) ? "" : REF_BINDING.frame(1, "opening")
79
- const promptSuffix = [frameSentence, identityRefsSentence(2, kept.length + 1)].filter(Boolean).join(" ")
80
- return { imageUrls, promptSuffix, droppedRefImages }
71
+ const placed = placeFramesAfterReferences({
72
+ prompt: args.prompt,
73
+ firstFrameUrl: args.firstFrameUrl,
74
+ refImageUrls: args.refImageUrls,
75
+ imageCap: Math.max(1, slots),
76
+ bindIdentities: true,
77
+ })
78
+ if (placed.imageUrls.length === 1) {
79
+ return { imageUrls: placed.imageUrls, promptSuffix: "", droppedRefImages: placed.droppedRefImages }
80
+ }
81
+ return { imageUrls: placed.imageUrls, promptSuffix: placed.promptSuffix, droppedRefImages: placed.droppedRefImages }
81
82
  }
@@ -6,9 +6,9 @@
6
6
  * the video node skills.
7
7
  *
8
8
  * Unlike the per-provider video doctrines, this is a PLATFORM mechanism: the
9
- * token grammar and expansion are Nodaro's own (`prompt-builder.ts` —
10
- * `IMAGE_TOKEN_PATTERN`, `expandImageRefTokens`), so there is one doctrine,
11
- * not one per model family.
9
+ * token grammar and expansion are Nodaro's own (`image-token-pattern.ts` —
10
+ * `IMAGE_TOKEN_PATTERN`; `prompt-builder.ts` — `expandImageRefTokens`), so
11
+ * there is one doctrine, not one per model family.
12
12
  *
13
13
  * Source of truth for the semantics documented here (verified 2026-08-25):
14
14
  * - `IMAGE_TOKEN_PATTERN` = `/\{image:(\d+)(?::([^}\n]+))?\}/gi`
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The `{image:N}` / `{image:N:label}` reference-token grammar — one pattern for
3
+ * the image prompt assembly (`prompt-builder.ts`) and for the preset metadata
4
+ * that reads which references a template names (`factory-presets/references.ts`).
5
+ *
6
+ * Group 1 = position (1-based), group 2 = optional label. The label allows
7
+ * spaces (multi-word labels like "clothes and shoes") but never a newline or
8
+ * `}`, so a match can't run away across lines or token boundaries.
9
+ *
10
+ * Global on purpose (callers use `.replace` / `.matchAll`, which never leave a
11
+ * `lastIndex` behind); never call `.test` / `.exec` on it.
12
+ */
13
+ export const IMAGE_TOKEN_PATTERN = /\{image:(\d+)(?::([^}\n]+))?\}/gi
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type { SnippetMedia } from "./factory-snippets/types.js"
27
27
  import { NODE_PROMPT_CANDIDATE_FIELDS } from "./resolve-prompt.js"
28
+ import { ownEntry } from "./own-entry.js"
28
29
 
29
30
  /** Which lucide glyph a node's prompt affordance uses. Kept as a string so this
30
31
  * module stays icon-library-free; mapped to a component in the strip button. */
@@ -136,9 +137,10 @@ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
136
137
  "edit-plan": { prompt: "instructions", promptLabel: "Editing instructions", media: "video", inline: false },
137
138
  }
138
139
 
139
- /** The prompt-field spec for a node type, or undefined if it has none. */
140
+ /** The prompt-field spec for a node type, or undefined if it has none (an
141
+ * Object.prototype member name such as `constructor` has none). */
140
142
  export function getPromptFields(nodeType: string | undefined): PromptFieldSpec | undefined {
141
- return nodeType ? NODE_PROMPT_FIELDS[nodeType] : undefined
143
+ return nodeType ? ownEntry(NODE_PROMPT_FIELDS, nodeType) : undefined
142
144
  }
143
145
 
144
146
  /** True when this node type has a registered, quick-editable prompt field. */
@@ -206,7 +208,7 @@ export function promptFieldCarriesAffixes(nodeType: string | undefined, promptFi
206
208
  const core = promptAffixCoreField(nodeType)
207
209
  if (core === undefined) return false
208
210
  if (promptField === undefined) return getPromptFields(nodeType)!.prompt === core
209
- return promptField === core || (NODE_PROMPT_CANDIDATE_FIELDS[nodeType!] ?? []).includes(promptField)
211
+ return promptField === core || (ownEntry(NODE_PROMPT_CANDIDATE_FIELDS, nodeType) ?? []).includes(promptField)
210
212
  }
211
213
 
212
214
  /** The affix-capable node types, derived from the registry (never hand-listed). */
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A registry keyed by node type is a plain object, and the key is
3
+ * caller-supplied (`?nodeType=` on a public route, a preset row's `node_type`,
4
+ * workflow JSON). A plain `record[key] ?? fallback` reads an Object.prototype
5
+ * member for `"constructor"`, `"toString"` or `"__proto__"` — a function or
6
+ * the prototype itself, which the caller then maps or iterates and throws on.
7
+ * This reads an OWN entry only, so such a name is "no such type".
8
+ */
9
+ export function ownEntry<T>(record: Readonly<Record<string, T>>, key: string | undefined): T | undefined {
10
+ return key !== undefined && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined
11
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { joinSentences } from "./hint-join.js"
8
+ import { IMAGE_TOKEN_PATTERN } from "./image-token-pattern.js"
8
9
  import { resolveTemplate, applyTemplate } from "./prompt-templates.js"
9
10
  import { NATIVE_NEGATIVE_PROMPT_MODELS, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, imageReferenceLimit, getMaxImagePromptChars, getMaxNegativePromptChars } from "@nodaro/shared/models"
10
11
  import { getStylePromptHint, isDeniedStyleId } from "./style.js"
@@ -3276,11 +3277,9 @@ const STRICT_DEFAULT_SOURCES: ReadonlySet<ReferenceSource> = new Set([
3276
3277
  "wired-location",
3277
3278
  ])
3278
3279
 
3279
- /** Matches `{image:N}` and `{image:N:label}` tokens.
3280
- * Group 1 = position, group 2 = optional label. */
3281
- // Label allows spaces (multi-word labels like "clothes and shoes") but never a
3282
- // newline or `}` so the match can't run away across lines / token boundaries.
3283
- const IMAGE_TOKEN_PATTERN = /\{image:(\d+)(?::([^}\n]+))?\}/gi
3280
+ // `IMAGE_TOKEN_PATTERN` (`{image:N}` / `{image:N:label}`) lives in
3281
+ // image-token-pattern.ts, shared with the preset metadata that reads which
3282
+ // references a template names.
3284
3283
 
3285
3284
  /**
3286
3285
  * The final 1-based slot an `{image:N}` token binds to on the connected-reference
@@ -51,7 +51,7 @@ import {
51
51
  renderDirectionHintClauses,
52
52
  type DirectionFamily,
53
53
  type DirectionFields,
54
- type DirectionHintMode,
54
+ type DirectionRenderOptions,
55
55
  type DirectionStyleGroup,
56
56
  } from "./direction-registry.js"
57
57
 
@@ -159,7 +159,7 @@ export function asBodyClauses(texts: readonly string[]): SlottedPromptClause[] {
159
159
  */
160
160
  export function partitionStyleClauses(
161
161
  direction: DirectionFields | undefined,
162
- opts: { surface: "image" | "video"; mode?: DirectionHintMode },
162
+ opts: DirectionRenderOptions,
163
163
  ): SlottedPromptClause[] {
164
164
  return renderDirectionHintClauses(direction, opts).map((clause) => ({
165
165
  text: clause.text,
@@ -185,7 +185,7 @@ export function styleSectionFromClauses(clauses: readonly SlottedPromptClause[])
185
185
  */
186
186
  export function renderStyleSection(
187
187
  direction: DirectionFields | undefined,
188
- opts: { surface: "image" | "video"; mode?: DirectionHintMode },
188
+ opts: DirectionRenderOptions,
189
189
  ): string {
190
190
  return styleSectionFromClauses(partitionStyleClauses(direction, opts))
191
191
  }
@@ -267,6 +267,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
267
267
  "gemini-omni-flash": "Google Gemini Omni Flash — faster, cheaper Omni tier; multimodal video with native audio, 4–10s, up to 4K.",
268
268
  "wan-3": "Wan 3.0 — multimodal refs (10 images / 5 videos / 5 audio) or first+last frame, native audio, 2–30s, 480p/720p/1080p",
269
269
  "wan-3-prime": "Wan 3.0 Prime — high-speed Wan 3.0 tier; same surface, faster turnaround at a higher rate",
270
+ "vidu-q4": "Vidu Q4 — start frame or up to 15 reference images + 3 MP3 audio refs, native audio, 3–16s, 540p–4K (image required)",
270
271
  "grok-imagine-video-1.5": "Grok Imagine 1.5 — image-to-video only; requires an input image",
271
272
  },
272
273
  "image-to-video": {
@@ -303,6 +304,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
303
304
  "gemini-omni-flash": "Google Gemini Omni Flash — faster, cheaper Omni tier; multimodal video with native audio, 4–10s, up to 4K.",
304
305
  "wan-3": "Wan 3.0 — multimodal refs (10 images / 5 videos / 5 audio) or first+last frame, native audio, 2–30s, 480p/720p/1080p",
305
306
  "wan-3-prime": "Wan 3.0 Prime — high-speed Wan 3.0 tier; same surface, faster turnaround at a higher rate",
307
+ "vidu-q4": "Vidu Q4 — animate a start frame, or direct up to 15 reference images + 3 MP3 audio refs; native audio, 3–16s, 540p–4K",
306
308
  "grok-imagine-video-1.5": "Grok Imagine 1.5 — stylized animation, 1–15s, 480p/720p (image required)",
307
309
  },
308
310
  "video-to-video": {
@@ -245,7 +245,7 @@ const GEMINI_OMNI_DOCTRINE: ProviderPromptDoctrine = {
245
245
  "Multimodal Google video with native audio: text-to-video, image-to-video, and video-edit through the same prompt surface. 4/6/8/10s; 720p/1080p or 4K tier.",
246
246
  "Structure like the platform default: subject → action → scene → lighting → camera → style. Quote dialogue lines to have them spoken; describe SFX/ambience plainly in the prompt.",
247
247
  "Text-to-video REQUIRES a concrete aspect ratio (the API hard-rejects a missing one); image runs infer aspect from the input.",
248
- "Reference images ride along as additional imageUrls — bind them in the prompt ('the woman from the first image'). Video-edit: wire a source clip and describe the change, not the whole scene.",
248
+ "Reference images ride in image_urls, your references first — bind them by ordinal ('the woman from @image_1'); a wired start frame is appended after them and bound as the opening frame. Video-edit: describe the change.",
249
249
  ],
250
250
  doctrine: `Prompt structure (no public Google prompt guide exists for the Omni video endpoint —
251
251
  the API contract is the doctrine source, like MiniMax H3; structure guidance mirrors the
@@ -254,7 +254,7 @@ subject → action → scene/environment → lighting → camera movement → st
254
254
 
255
255
  **Modes (picked from the wired inputs)**
256
256
  - Nothing visual → text-to-video. A concrete aspect ratio is REQUIRED — the API hard-rejects a missing one (Nodaro sends the node's ratio; there is no adaptive).
257
- - Image(s) wired → image-to-video: the first image anchors the scene; extra images are references — bind each in the prompt ("the woman from the first image", "the interior from the second image").
257
+ - Image(s) wired → image-to-video. Your reference images come FIRST, in your order (\`@image_1\`, \`@image_2\`, …) — bind each in the prompt ("the woman from @image_1", "the interior from @image_2"); a wired start frame is appended after them and the platform names it as the opening frame.
258
258
  - Source video wired → video-edit (served through the same handle): describe the CHANGE ("replace the daylight with dusk, keep the motion and framing"), not a full re-description.
259
259
 
260
260
  **Audio (native)**
@@ -284,6 +284,7 @@ descriptions of static/unchanged parts — spend the words on what MOVES.
284
284
  **Image-to-video (the primary mode)**
285
285
  - The input image is the FIRST FRAME, not a loose reference: composition, subject identity, and visual style carry over. Describe motion and camera only ("she turns toward the window as the camera slowly pushes in"); re-describing the still wastes adherence.
286
286
  - grok-imagine-video-1.5 accepts up to 7 images (identity/scene references beyond the first frame); at 1080p only ONE image is allowed.
287
+ - grok-i2v with reference images: images are addressed \`@image(n)\` in array order. Your references come first (\`@image(1)\` is your first reference); a wired start frame is appended after them and the platform names it as the opening frame.
287
288
 
288
289
  **Audio (video-1.5)**
289
290
  - Native audio generates with the clip — background music, SFX, and lip-synced dialogue. Quote the spoken line; describe the music/SFX plainly. There is no audio toggle on the KIE contract — cue (or exclude) sound in the prompt text.
@@ -382,6 +383,46 @@ Source: KIE Wan 3.0 market contract (docs.kie.ai/market/wan/3-0-video,
382
383
  docs.kie.ai/market/wan/3-0-video-prime). Captured 2026-09-01.`,
383
384
  }
384
385
 
386
+ // Vidu Q4 — no public Q4 prompt guide is captured, so like Wan 3.0 / MiniMax H3
387
+ // the body is KIE contract facts plus the platform-standard structure. The
388
+ // contract names references "reference image 2" / "reference audio 1"; the
389
+ // editor's reference tokens emit the platform-wide `@image_N` / `@audio_N`
390
+ // binding instead (see ref-binding.ts), which is what this doctrine teaches.
391
+ const VIDU_Q4_DOCTRINE: ProviderPromptDoctrine = {
392
+ providers: ["vidu-q4"],
393
+ heading: "Vidu Q4 (vidu-q4)",
394
+ tips: [
395
+ "A start frame alone animates that frame (prompt optional). Any reference image or audio switches to reference mode: the prompt is REQUIRED and the start frame joins the reference images.",
396
+ "Reference mode: 1-15 images, up to 3 audio clips (MP3 only, 3-12s each). Name each by ordinal — \"the man from @image_2 says … in the voice of @audio_1\".",
397
+ "No text-only runs and no end frame: wire a start frame or at least one reference image.",
398
+ "3-16s (default 5); 540p / 720p (default) / 1080p / 2K / 4K; reference runs take 16:9 / 9:16 / 1:1 / 4:3 / 3:4. Prompt cap 20,000 chars.",
399
+ "`audio` is ON by default (synchronized sound). Cue the sound you want or state the exclusion; dialogue is not verified here, so plan key lines as a TTS + lip-sync pass.",
400
+ ],
401
+ doctrine: `Prompt structure (no public Vidu Q4 prompt guide is captured — the KIE API contract is the
402
+ doctrine source; platform-standard structure applies):
403
+ subject → action → scene/environment → lighting → camera movement → style → constraints.
404
+
405
+ **Modes (picked by what is wired)**
406
+ - Start frame only → image-to-video. The frame anchors the shot and its aspect ratio; describe MOTION and camera, not the still. The prompt is optional.
407
+ - Any reference image or reference audio → reference-to-video. The prompt is REQUIRED. A wired start frame cannot be sent as a frame here, so the platform adds it to the reference images (after the caller's own, ordinals unchanged) and names it in the prompt as the opening frame.
408
+ - There is no text-only mode and no first+last frame mode.
409
+
410
+ **Reference binding**
411
+ - Refer to inputs by ordinal in attachment order. In the editor the reference tokens emit "@image_N" / "@audio_N" — write the binding explicitly ("the woman from @image_1 turns to the man from @image_2 and says …, in the voice of @audio_1").
412
+ - Caps: 1-15 reference images (JPG / PNG / WEBP) and 0-3 reference audio clips. Audio must be MP3, each 3-12 seconds — the platform refuses any other format or length before the run starts.
413
+
414
+ **Duration, resolution, aspect**
415
+ - 3-16 seconds (default 5). 540p / 720p (default) / 1080p / 2K / 4K.
416
+ - Image-to-video has no aspect setting — the output inherits the start frame's shape. Reference runs take 16:9 (default) / 9:16 / 1:1 / 4:3 / 3:4.
417
+ - Prompts up to 20,000 characters.
418
+
419
+ **Audio**
420
+ - The "audio" boolean defaults ON and generates synchronized sound with the clip (the contract says "dialogue and sound effects"). Describe the soundscape plainly or state the exclusion, or turn the toggle off for a silent clip.
421
+
422
+ Source: KIE Vidu Q4 market contracts (docs.kie.ai/market/vidu-q4/image-to-video,
423
+ docs.kie.ai/market/vidu-q4/reference-to-video). Captured 2026-10-17.`,
424
+ }
425
+
385
426
  const HAPPYHORSE_DOCTRINE: ProviderPromptDoctrine = {
386
427
  providers: ["happyhorse", "happyhorse-i2v", "happyhorse-ref2v", "happyhorse-edit"],
387
428
  heading: "HappyHorse 1.1 (happyhorse, happyhorse-i2v, happyhorse-ref2v)",
@@ -398,7 +439,7 @@ subject → action → scene/environment → lighting → camera movement → st
398
439
  **Contract facts (KIE, per-mode pages)**
399
440
  - Prompts: any language, up to 5000 non-Chinese / 2500 Chinese characters — excess is TRUNCATED silently, so put the load-bearing content first.
400
441
  - Duration 3-15s (default 5), billed per second. Resolution 720p / 1080p (default). Aspect 16:9 (default) / 9:16 / 1:1 / 4:3 / 3:4.
401
- - Modes: text-to-video (happyhorse), image-to-video (happyhorse-i2v), reference-to-video (happyhorse-ref2v) — ref2v preserves wired identities; name each reference in the prompt so the binding is explicit.
442
+ - Modes: text-to-video (happyhorse), image-to-video (happyhorse-i2v), reference-to-video (happyhorse-ref2v) — ref2v preserves wired identities; name each reference in the prompt so the binding is explicit, as \`[Image N]\` in array order: your references come first (\`[Image 1]\` is your first), and a wired start frame is appended after them and named as the opening frame.
402
443
 
403
444
  **Style guidance (platform-standard, honestly generic)**
404
445
  - One camera movement per shot; physical, quantified action ("slowly raises a hand") over abstract emotion words; state exclusions ("no on-screen text, no watermark") in the prompt.
@@ -440,6 +481,7 @@ export const PROVIDER_PROMPT_DOCTRINES: readonly ProviderPromptDoctrine[] = [
440
481
  GROK_IMAGINE_DOCTRINE,
441
482
  WAN_DOCTRINE,
442
483
  WAN_3_DOCTRINE,
484
+ VIDU_Q4_DOCTRINE,
443
485
  HAPPYHORSE_DOCTRINE,
444
486
  RUNWAY_KIE_DOCTRINE,
445
487
  ]
@@ -28,10 +28,14 @@
28
28
  * says the two things a multimodal model needs to hear — match exactly, and
29
29
  * these are not frames. One spelling; both resolvers ride it.
30
30
  */
31
- export function identityRefsSentence(firstOrdinal: number, lastOrdinal: number): string {
31
+ export function identityRefsSentence(
32
+ firstOrdinal: number,
33
+ lastOrdinal: number,
34
+ ordinal: (n: number) => string = REF_BINDING.ordinal,
35
+ ): string {
32
36
  return firstOrdinal === lastOrdinal
33
- ? `${REF_BINDING.ordinal(firstOrdinal)} is an identity reference for this shot's subjects — match its subject's exact appearance; it is not a frame.`
34
- : `${REF_BINDING.ordinal(firstOrdinal)} through ${REF_BINDING.ordinal(lastOrdinal)} are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.`
37
+ ? `${ordinal(firstOrdinal)} is an identity reference for this shot's subjects — match its subject's exact appearance; it is not a frame.`
38
+ : `${ordinal(firstOrdinal)} through ${ordinal(lastOrdinal)} are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.`
35
39
  }
36
40
 
37
41
  export const REF_BINDING = {
@@ -44,6 +48,27 @@ export const REF_BINDING = {
44
48
  `Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
45
49
  } as const
46
50
 
51
+ /** A binding sentence's ordinal + frame directive in one provider syntax. */
52
+ export interface ImageBinding {
53
+ ordinal: (n: number) => string
54
+ frame: (n: number, role: "opening" | "closing") => string
55
+ }
56
+
57
+ /**
58
+ * The image binding in a provider's OWN reference syntax
59
+ * (`videoProviderReferenceSyntax` in @nodaro/shared). `@image_N` IS
60
+ * {@link REF_BINDING} — the platform swap point, byte for byte; the other two
61
+ * spell the same sentence in the form the provider documents.
62
+ */
63
+ export function imageBindingFor(syntax: "@image_N" | "@image(n)" | "[Image N]"): ImageBinding {
64
+ if (syntax === "@image_N") return { ordinal: REF_BINDING.ordinal, frame: REF_BINDING.frame }
65
+ const ordinal = syntax === "@image(n)" ? (n: number) => `@image(${n})` : (n: number) => `[Image ${n}]`
66
+ return {
67
+ ordinal,
68
+ frame: (n, role) => `Use ${ordinal(n)} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
69
+ }
70
+ }
71
+
47
72
  /**
48
73
  * The instruction that makes Seedance EDIT a wired clip rather than use it as a
49
74
  * style reference — the Video to Video node's Seedance lane prepends it to the
@@ -2,6 +2,7 @@ import { resolveNodeRefs } from "@nodaro/shared/workflow"
2
2
  import { readPromptAffixes, type PromptAffixes } from "@nodaro/shared/nodes"
3
3
  import { SOCIAL_POST_NODE_TYPES } from "@nodaro/shared/social"
4
4
  import { joinSentences } from "./hint-join.js"
5
+ import { ownEntry } from "./own-entry.js"
5
6
 
6
7
  export interface ResolvePromptArgs {
7
8
  override?: string
@@ -165,7 +166,7 @@ export function computeNodePrompt(
165
166
  ? [data.directText as string | undefined]
166
167
  : []
167
168
  } else {
168
- const fields = NODE_PROMPT_CANDIDATE_FIELDS[nodeType] ?? ["prompt"]
169
+ const fields = ownEntry(NODE_PROMPT_CANDIDATE_FIELDS, nodeType) ?? ["prompt"]
169
170
  typed = fields.map((f) => data[f] as string | undefined)
170
171
  }
171
172
  return resolvePrompt({ override, typed, wired, refMap, appendWired, affixes: readPromptAffixes(data) })