@nodaro/prompts 1.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^3.26.0"
23
+ "@nodaro/shared": "^3.27.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -1,7 +1,10 @@
1
1
  // packages/prompts/src/__tests__/creator-presets.test.ts
2
2
  import { describe, it, expect } from "vitest"
3
- import { captionRoutesToRemotion, presetApplyClearKeys } from "@nodaro/shared"
3
+ import { aspectRatioOptionsByKind, captionRoutesToRemotion, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, normalizeNodeModelParams, presetApplyClearKeys, resolveNodeRefs, resolveNormalizedImageGen, type ConnectedReference, type SourceImageSize } from "@nodaro/shared"
4
4
  import { getFactoryPresets, type FactoryPreset } from "../factory-presets.js"
5
+ import { assembleImageInput } from "../assemble-image-input.js"
6
+ import { computeNodePrompt } from "../resolve-prompt.js"
7
+ import { NEUTRAL_PERSON, REALISM_FINISH, REALISM_PASS_INSTRUCTION, REALISM_SKIN, realismEmptyHands, realismViewpoint } from "../realism-wording.js"
5
8
 
6
9
  /** Words that name a recording or viewing device. Plural forms included. */
7
10
  const DEVICE_WORDS = /\b(?:smartphones?|iphones?|phones?|cameras?|lens(?:es)?|webcams?|laptops?|tablets?|screens?|monitors?|displays?|tripods?|selfie[ -]sticks?)\b/i
@@ -149,3 +152,213 @@ describe("Wave 0 — Hook Plate and Body Captions", () => {
149
152
  for (const k of ["backgroundColor", "look", "positionY", "fontWeight", "maxWordsPerLine"]) expect(overClean[k], k).toBeUndefined()
150
153
  })
151
154
  })
155
+
156
+ /** Every prompt-bearing field, joined — what the device and slot checks read. */
157
+ const allText = (d: Readonly<Record<string, unknown>>) =>
158
+ ["promptPrefix", "prompt", "promptSuffix", "negativePrompt"].map((k) => d[k]).filter((v): v is string => typeof v === "string").join(" ")
159
+ const wiredRunPrompts = (d: Readonly<Record<string, unknown>>): Record<"legacy" | "hybrid" | "urlsOnly", string> => {
160
+ const userPrompt = resolveNodeRefs(computeNodePrompt("generate-image", { ...d }, { refMap: new Map() }), new Map())
161
+ const wired = Math.max(0, ...[...userPrompt.matchAll(/\{image:(\d+)[^{}]*\}/gi)].map((m) => Number(m[1])))
162
+ const refs: ConnectedReference[] = Array.from({ length: wired }, (_, i) => ({
163
+ id: `ref-${i + 1}`, defaultName: `Reference ${i + 1}`, source: "wired-image", url: `https://example.com/ref-${i + 1}.jpg`,
164
+ }))
165
+ const provider = d.provider as string
166
+ return {
167
+ legacy: assembleImageInput({ userPrompt, provider, referenceFormat: "legacy", connectedReferences: refs }).prompt,
168
+ hybrid: assembleImageInput({ userPrompt, provider, referenceFormat: "hybrid", connectedReferences: refs }).prompt,
169
+ urlsOnly: assembleImageInput({ userPrompt, provider, extraReferenceImageUrls: refs.map((r) => r.url) }).prompt,
170
+ }
171
+ }
172
+ /** What the image route makes of a preset's levers with `refs` photos connected (and, when known, the size of the photo
173
+ * being transformed): the credit tier, the ratio it runs at and anything it had to change. */
174
+ const imageRouteRun = (node: string, d: Readonly<Record<string, unknown>>, refs: number, sourceImage?: SourceImageSize) =>
175
+ resolveNormalizedImageGen({
176
+ provider: d.provider as string, aspectRatio: d.aspectRatio, resolution: d.resolution, quality: d.quality, refCount: refs, sourceImage,
177
+ // Generate Image moves a text-to-image model to its image-to-image sibling once a photo is connected; Modify Image already names that sibling.
178
+ swapToI2i: node === "generate-image",
179
+ })
180
+ /** The aspect ratio the workflow write step stores for one node of `type` carrying `d`. */
181
+ const storedAspectRatio = (type: string, d: Readonly<Record<string, unknown>>) =>
182
+ normalizeNodeModelParams([{ id: "node", type, data: { ...d } }]).nodes[0]!.data.aspectRatio
183
+ /** A preset's data without the named wording fields — what is left is its levers. */
184
+ const leversOf = (d: Readonly<Record<string, unknown>>, ...wording: string[]) => Object.fromEntries(Object.entries(d).filter(([k]) => !wording.includes(k)))
185
+
186
+ const REALISM_IDS = [
187
+ "unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie",
188
+ "creator-at-home", "video-call-frame", "macro-skin-study", "product-in-hand",
189
+ ].map((s) => `generate-image/${s}`)
190
+ const REALISM_REFERENCE_IDS = ["generate-image/creator-at-home", "generate-image/product-in-hand"]
191
+ /** Each Realism preset's levers, and the credit tier the image route charges for them (its reference connected where it takes one). */
192
+ const REALISM_RUNS: Readonly<Record<string, readonly [levers: Readonly<Record<string, string>>, creditId: string]>> = {
193
+ "generate-image/unretouched-portrait": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
194
+ "generate-image/phone-selfie": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
195
+ "generate-image/candid-snapshot": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
196
+ "generate-image/mirror-selfie": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "9:16" }, "gpt-image-2:2K"],
197
+ "generate-image/creator-at-home": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "9:16" }, "gpt-image-2-i2i:2K"],
198
+ "generate-image/video-call-frame": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "16:9" }, "gpt-image-2:2K"],
199
+ "generate-image/macro-skin-study": [{ provider: "seedream-5-pro", quality: "high", aspectRatio: "1:1" }, "seedream-5-pro:high"],
200
+ "generate-image/product-in-hand": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2-i2i:2K"],
201
+ }
202
+
203
+ describe("Wave 1 — Realism & Authentic", () => {
204
+ const group = () => getFactoryPresets("generate-image").filter((p) => p.group === "Realism & Authentic")
205
+
206
+ it("ships exactly these eight presets, in order, each on its model with that model's one lever, and no negativePrompt", () => {
207
+ expect(group().map((p) => p.id)).toEqual(REALISM_IDS)
208
+ expect(Object.keys(REALISM_RUNS)).toEqual(REALISM_IDS)
209
+ for (const p of group()) {
210
+ expect(leversOf(p.data, "prompt"), p.id).toEqual(REALISM_RUNS[p.id]![0])
211
+ expect(p.data.negativePrompt, p.id).toBeUndefined()
212
+ }
213
+ })
214
+
215
+ it("the image route runs each one on its levers unchanged, at its model's tier", () => {
216
+ for (const id of REALISM_IDS) {
217
+ const run = imageRouteRun("generate-image", preset("generate-image", id).data, REALISM_REFERENCE_IDS.includes(id) ? 1 : 0)
218
+ expect(run.adjustments, id).toEqual([])
219
+ expect(run.identifier, id).toBe(REALISM_RUNS[id]![1])
220
+ }
221
+ })
222
+
223
+ it("composes the selected fragments", () => {
224
+ const text = (slug: string) => preset("generate-image", `generate-image/${slug}`).data.prompt as string
225
+ for (const slug of ["unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie", "creator-at-home", "macro-skin-study"]) expect(text(slug), slug).toContain(REALISM_SKIN)
226
+ for (const slug of ["unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie", "creator-at-home", "video-call-frame", "product-in-hand"]) expect(text(slug), slug).toContain(REALISM_FINISH)
227
+ expect(text("phone-selfie")).toContain(realismViewpoint(NEUTRAL_PERSON))
228
+ expect(text("phone-selfie")).toContain(realismEmptyHands(NEUTRAL_PERSON))
229
+ })
230
+
231
+ it("names no device — the mirror self-portrait may say phone", () => {
232
+ for (const id of REALISM_IDS) {
233
+ const text = allText(preset("generate-image", id).data)
234
+ // A mirror self-portrait shows the phone in hand.
235
+ const scanned = id === "generate-image/mirror-selfie" ? text.replace(/\bphone\b/gi, "") : text
236
+ expect(scanned, id).not.toMatch(DEVICE_WORDS)
237
+ }
238
+ })
239
+
240
+ it("every subject slot has a default naming an adult; every setting slot has a default", () => {
241
+ for (const id of REALISM_IDS) {
242
+ const text = allText(preset("generate-image", id).data)
243
+ for (const m of text.matchAll(/\{subject([^{}]*)\}/g)) {
244
+ expect(m[1], `${id}: {subject} needs a default`).toMatch(/\|\|/)
245
+ expect(m[1]!.split("||")[1], `${id}: the default names an adult`).toMatch(ADULT)
246
+ }
247
+ for (const m of text.matchAll(/\{setting([^{}]*)\}/g)) expect(m[1], `${id}: {setting} needs a default`).toMatch(/\|\|\s*\S/)
248
+ }
249
+ })
250
+
251
+ it("labels every reference token, and reads whole when the references are wired", () => {
252
+ const TOKEN = /\{(?:image|video|audio):\d+(:[^{}]*)?\}/gi
253
+ const count = (s: string, part: string) => s.split(part).length - 1
254
+ for (const id of REALISM_REFERENCE_IDS) expect(MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(preset("generate-image", id).data.provider as string), id).toBe(true)
255
+ const withTokens: string[] = []
256
+ for (const id of REALISM_IDS) {
257
+ const d = preset("generate-image", id).data
258
+ const text = allText(d)
259
+ const labels: string[] = []
260
+ for (const m of text.matchAll(TOKEN)) {
261
+ expect(m[1], `${id}: ${m[0]} needs a label`).toBeTruthy()
262
+ labels.push(m[1]!.slice(1))
263
+ }
264
+ if (labels.length > 0) withTokens.push(id)
265
+ const bare = text.replace(TOKEN, "")
266
+ for (const [path, out] of Object.entries(wiredRunPrompts(d))) {
267
+ const legacyReferenceLine = path === "legacy" ? 1 : 0
268
+ for (const label of new Set(labels)) {
269
+ const tokens = labels.filter((l) => l === label).length
270
+ expect(count(out, label), `${id} (${path}): ${label}`).toBeGreaterThanOrEqual(count(bare, label) + tokens + legacyReferenceLine)
271
+ }
272
+ expect(out, `${id} (${path})`).not.toMatch(/ {2}/)
273
+ expect(out, `${id} (${path})`).not.toMatch(/[{}]/)
274
+ expect(out, `${id} (${path})`).not.toMatch(/\bfrom\s+[.,;:!?]/)
275
+ }
276
+ }
277
+ expect(withTokens).toEqual(REALISM_REFERENCE_IDS)
278
+ })
279
+ })
280
+
281
+ describe("Wave 1 — Skin Detail Sheet", () => {
282
+ it("is a Reference Sheet board on seedream-5-pro at high quality, carrying the never-merge clause in pre/post text and no negativePrompt", () => {
283
+ const p = preset("generate-image", "generate-image/skin-detail-sheet")
284
+ expect(p.group).toBe("Reference Sheet")
285
+ expect(leversOf(p.data, "promptPrefix", "promptSuffix")).toEqual({ provider: "seedream-5-pro", quality: "high", aspectRatio: "3:2" })
286
+ expect(MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(p.data.provider as string)).toBe(true)
287
+ // The connected face photo moves the run to Seedream's image-to-image model, at the same tier.
288
+ const run = imageRouteRun("generate-image", p.data, 1)
289
+ expect(run.adjustments).toEqual([])
290
+ expect(run.identifier).toBe("seedream-5-pro-i2i:high")
291
+ expect(p.data.prompt).toBeUndefined()
292
+ expect(p.data.promptPrefix as string).toContain("never merging or omitting a panel")
293
+ expect(p.data.promptPrefix as string).toContain(REALISM_SKIN)
294
+ expect(p.data.negativePrompt).toBeUndefined()
295
+ expect(allText(p.data)).not.toMatch(DEVICE_WORDS)
296
+ const ids = getFactoryPresets("generate-image").map((x) => x.id)
297
+ expect(ids[ids.indexOf("generate-image/pet-board") + 1]).toBe("generate-image/skin-detail-sheet")
298
+ })
299
+ })
300
+
301
+ describe("Wave 1 — Edits", () => {
302
+ const SLUGS = ["add-skin-realism", "natural-photo-finish", "phone-camera-look", "undo-beauty-filter"]
303
+ const FINISH = { provider: "nano-banana-2-1", aspectRatio: "auto", resolution: "2K" }
304
+ const UNDO = { aspectRatio: "auto", quality: "high" }
305
+ /** Each Edit's levers per node. Modify Image's provider list names Seedream's image-to-image id, not the text-to-image one. */
306
+ const LEVERS: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>> = {
307
+ "generate-image": { "add-skin-realism": FINISH, "natural-photo-finish": FINISH, "phone-camera-look": FINISH, "undo-beauty-filter": { provider: "seedream-5-pro", ...UNDO } },
308
+ "modify-image": { "add-skin-realism": FINISH, "natural-photo-finish": FINISH, "phone-camera-look": FINISH, "undo-beauty-filter": { provider: "seedream-5-pro-i2i", ...UNDO } },
309
+ }
310
+
311
+ it("ships the four Edits on both image nodes after the Doodle Overlay pair, each on its model and levers, as pre-text only", () => {
312
+ for (const node of ["generate-image", "modify-image"]) {
313
+ const ids = getFactoryPresets(node).map((x) => x.id)
314
+ const i = ids.indexOf(`${node}/doodle-overlay-expressive`)
315
+ expect(ids.slice(i + 1, i + 5)).toEqual(SLUGS.map((s) => `${node}/${s}`))
316
+ for (const s of SLUGS) {
317
+ const p = preset(node, `${node}/${s}`)
318
+ expect(p.group).toBe("Edits")
319
+ expect(typeof p.data.promptPrefix, p.id).toBe("string")
320
+ expect(leversOf(p.data, "promptPrefix"), p.id).toEqual(LEVERS[node]![s])
321
+ // Every Edit asks for "auto", to keep the photo's shape. Undo Beauty Filter's model lists no
322
+ // native "auto"; how each node resolves it is pinned in the Undo Beauty Filter test below.
323
+ expect(p.data.aspectRatio, p.id).toBe("auto")
324
+ if (s !== "undo-beauty-filter") expect(aspectRatioOptionsByKind("image")[p.data.provider as string]?.map((o) => o.value), p.id).toContain("auto")
325
+ expect((p.data.promptPrefix as string).length).toBeLessThanOrEqual(2000)
326
+ expect(allText(p.data), p.id).not.toMatch(DEVICE_WORDS)
327
+ }
328
+ }
329
+ })
330
+
331
+ it("Undo Beauty Filter keeps the photo's shape on both nodes", () => {
332
+ // The write step stores "auto" as written, and the run takes the model's listed ratio nearest the
333
+ // connected photo's size, on Seedream 5 Pro's image-to-image model at high quality.
334
+ const photos: ReadonlyArray<readonly [SourceImageSize, string]> = [[{ width: 1104, height: 1472 }, "3:4"], [{ width: 1600, height: 1000 }, "3:2"]]
335
+ for (const node of ["generate-image", "modify-image"]) {
336
+ const d = preset(node, `${node}/undo-beauty-filter`).data
337
+ expect(storedAspectRatio(node, d), node).toBe("auto")
338
+ for (const [photo, ratio] of photos) {
339
+ const run = imageRouteRun(node, d, 1, photo)
340
+ expect(run.aspectRatio, `${node} ${photo.width}x${photo.height}`).toBe(ratio)
341
+ expect(run.modelId, node).toBe("seedream-5-pro-i2i")
342
+ expect(run.identifier, node).toBe("seedream-5-pro-i2i:high")
343
+ }
344
+ }
345
+ })
346
+
347
+ it("Add Skin Realism is the realism instruction verbatim; the finish edits carry the finish sentence", () => {
348
+ expect(preset("generate-image", "generate-image/add-skin-realism").data.promptPrefix).toBe(REALISM_PASS_INSTRUCTION)
349
+ for (const s of ["natural-photo-finish", "phone-camera-look"])
350
+ expect(preset("generate-image", `generate-image/${s}`).data.promptPrefix as string, s).toContain(REALISM_FINISH)
351
+ })
352
+ })
353
+
354
+ describe("Wave 1 — Realism Audit", () => {
355
+ it("is a structured Creative reading that answers ten yes/no questions and ends with a count", () => {
356
+ const p = preset("image-to-text", "image-to-text/realism-audit")
357
+ expect(p.group).toBe("Creative")
358
+ expect(p.data.detailLevel).toBe("structured")
359
+ const prompt = p.data.customPrompt as string
360
+ expect(prompt).toContain("Tells found: N of 10")
361
+ for (let n = 1; n <= 10; n++) expect(prompt).toContain(`${n}. `)
362
+ expect(prompt.length).toBeLessThanOrEqual(2000)
363
+ })
364
+ })
@@ -280,13 +280,30 @@ describe("generate-image factory preset data validity", () => {
280
280
  }
281
281
  })
282
282
 
283
- it("every generate-image preset has a non-empty negativePrompt", () => {
283
+ // These presets carry their whole wording in the prompt; an added negative list would change the wording they were written with.
284
+ const NEGATIVE_EXEMPT: ReadonlySet<string> = new Set([
285
+ "generate-image/unretouched-portrait", "generate-image/phone-selfie", "generate-image/candid-snapshot",
286
+ "generate-image/mirror-selfie", "generate-image/creator-at-home", "generate-image/video-call-frame",
287
+ "generate-image/macro-skin-study", "generate-image/product-in-hand", "generate-image/skin-detail-sheet",
288
+ "generate-image/add-skin-realism", "generate-image/natural-photo-finish", "generate-image/phone-camera-look",
289
+ "generate-image/undo-beauty-filter",
290
+ ])
291
+
292
+ it("every generate-image preset has a non-empty negativePrompt (except the exempt ones, which carry none)", () => {
284
293
  for (const p of presets) {
294
+ if (NEGATIVE_EXEMPT.has(p.id)) {
295
+ expect(p.data.negativePrompt, `${p.id}: carries its whole wording in the prompt`).toBeUndefined()
296
+ continue
297
+ }
285
298
  const neg = (p.data.negativePrompt as string | undefined) ?? ""
286
299
  expect(neg.trim().length, `${p.id}: missing negativePrompt`).toBeGreaterThan(0)
287
300
  }
288
301
  })
289
302
 
303
+ it("every negative-exempt id exists", () => {
304
+ for (const id of NEGATIVE_EXEMPT) expect(presets.some((p) => p.id === id), id).toBe(true)
305
+ })
306
+
290
307
  it("non-style-pinned generate-image prompts are substantive (no thin one-liners)", () => {
291
308
  for (const p of presets) {
292
309
  if (p.data.style !== undefined) continue // style field carries the look; short prompt is fine
@@ -0,0 +1,41 @@
1
+ // packages/prompts/src/__tests__/realism-wording.test.ts
2
+ import { describe, it, expect } from "vitest"
3
+ import * as wording from "../realism-wording.js"
4
+
5
+ // These strings are read at run time by other Nodaro services; changing one changes their output on the next deploy, so a change needs its owner's review.
6
+ describe("realism wording (frozen)", () => {
7
+ it("exports exactly the selected fragments and the two person functions", () => {
8
+ expect(Object.keys(wording).sort()).toEqual(
9
+ ["NEUTRAL_PERSON", "REALISM_FINISH", "REALISM_PASS_INSTRUCTION", "REALISM_SKIN", "realismEmptyHands", "realismViewpoint"].sort(),
10
+ )
11
+ })
12
+
13
+ it("keeps every fragment's text", () => {
14
+ expect(wording.REALISM_SKIN).toBe(
15
+ "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.",
16
+ )
17
+ expect(wording.REALISM_FINISH).toBe(
18
+ "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.",
19
+ )
20
+ expect(wording.REALISM_PASS_INSTRUCTION).toBe(
21
+ "Make this photo look like an unretouched everyday snapshot: skin that shows its pores, its fine hairs and the small changes in tone real skin has, colour left as it was recorded, everything sharp from the shoulders to the background, a little grain in the shadows. Take out anything that makes it look rendered — smoothed, waxy or airbrushed skin, a glow, over-sharpened edges — and add no text or icons. Otherwise it stays the same person in the same frame: the face and its proportions, hair, clothing, pose and background as they are.",
22
+ )
23
+ })
24
+
25
+ it("keeps the viewpoint and empty-hands sentences, neutral and gendered", () => {
26
+ expect(wording.realismViewpoint(wording.NEUTRAL_PERSON)).toBe(
27
+ "The person is looking straight at the viewer from eye level, mid-sentence: their 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.",
28
+ )
29
+ expect(wording.realismViewpoint({ subject: "she", possessive: "her" })).toBe(
30
+ "She is looking straight at the viewer from eye level, mid-sentence: her 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.",
31
+ )
32
+ expect(wording.realismEmptyHands(wording.NEUTRAL_PERSON)).toBe("Their hands are empty; the person is holding nothing.")
33
+ expect(wording.realismEmptyHands({ subject: "he", possessive: "his" })).toBe("His hands are empty; he is holding nothing.")
34
+ })
35
+
36
+ it("names no device", () => {
37
+ const DEVICE_WORDS = /\b(?:smartphones?|iphones?|phones?|cameras?|lens(?:es)?|webcams?|laptops?|tablets?|screens?|monitors?|displays?|tripods?|selfie[ -]sticks?)\b/i
38
+ for (const s of [wording.REALISM_SKIN, wording.REALISM_FINISH, wording.REALISM_PASS_INSTRUCTION, wording.realismViewpoint(wording.NEUTRAL_PERSON), wording.realismEmptyHands(wording.NEUTRAL_PERSON)])
39
+ expect(s).not.toMatch(DEVICE_WORDS)
40
+ })
41
+ })
@@ -1,5 +1,13 @@
1
1
  import type { FactoryPreset } from "./types.js"
2
2
  import { stylizedSubjectFor, editsFor } from "./shared-image.js"
3
+ import { NEUTRAL_PERSON, REALISM_FINISH, REALISM_SKIN, realismEmptyHands, realismViewpoint } from "../realism-wording.js"
4
+
5
+ /** Joins the sentences of one prompt with single spaces. */
6
+ const sentences = (...parts: string[]): string => parts.join(" ")
7
+ /** The model and resolution the Realism & Authentic presets render with. */
8
+ const REALISM_MODEL = { provider: "gpt-image-2", resolution: "2K" } as const
9
+ /** The model and quality the close skin studies render with (Macro Skin Study, Skin Detail Sheet). */
10
+ const SKIN_STUDY_MODEL = { provider: "seedream-5-pro", quality: "high" } as const
3
11
 
4
12
  export const GENERATE_IMAGE_PRESETS: readonly FactoryPreset[] = [
5
13
  // ── Reference Sheet ──────────────────────────────────────────────────────
@@ -168,6 +176,22 @@ export const GENERATE_IMAGE_PRESETS: readonly FactoryPreset[] = [
168
176
  "illustration, cartoon, 3d render, garbled text, misspelled labels, watermark, deformed anatomy, inconsistent markings between panels, wrong breed, blurry, low resolution",
169
177
  },
170
178
  },
179
+ {
180
+ id: "generate-image/skin-detail-sheet",
181
+ name: "Skin Detail Sheet",
182
+ description: "Connect a sharp face photo → a six-panel skin reference.",
183
+ group: "Reference Sheet",
184
+ data: {
185
+ ...SKIN_STUDY_MODEL,
186
+ aspectRatio: "3:2",
187
+ promptPrefix: sentences(
188
+ "Create a single skin detail reference sheet titled \"SKIN DETAIL SHEET\" from the attached photo of the person, the single source of truth for identity, skin tone and features. The same person, the same light and the same unretouched skin in every panel. Six panels — render ALL six panel headings exactly as written, never merging or omitting a panel: PANEL 01 — FULL FACE (front, eye level) · PANEL 02 — LEFT CHEEK & NOSE · PANEL 03 — RIGHT CHEEK & JAW · PANEL 04 — FOREHEAD & HAIRLINE · PANEL 05 — EYES & BROWS · PANEL 06 — LIPS & CHIN. In every panel:",
189
+ REALISM_SKIN,
190
+ "Flat, even daylight, neutral colour, everything sharp.",
191
+ ),
192
+ promptSuffix: "Plain neutral grey background, thin white panel labels in English. Use this sheet as a visual reference for consistent skin detail of this person across generations.",
193
+ },
194
+ },
171
195
  // ── Cast & Consistency (clean grids per the multi-character workflow:
172
196
  // sterile neutral-background reference grids built to be FED BACK as identity
173
197
  // anchors — unlike the editorial boards above, decoration here is noise.
@@ -1703,4 +1727,94 @@ export const GENERATE_IMAGE_PRESETS: readonly FactoryPreset[] = [
1703
1727
  // instruction lives in the prompt, not `style`.
1704
1728
  ...stylizedSubjectFor("generate-image"),
1705
1729
  ...editsFor("generate-image"),
1730
+
1731
+ // ── Realism & Authentic ──────────────────────────────────────────────────
1732
+ {
1733
+ id: "generate-image/unretouched-portrait",
1734
+ name: "Unretouched Portrait",
1735
+ description: "Straight-on head-and-shoulders portrait, real skin, everyday finish.",
1736
+ group: "Realism & Authentic",
1737
+ data: {
1738
+ ...REALISM_MODEL,
1739
+ aspectRatio: "3:4",
1740
+ prompt: sentences("{subject || An adult woman in her thirties with dark shoulder-length hair}, a straight-on head-and-shoulders portrait at eye level against a plain pale wall, soft daylight from one side.", REALISM_SKIN, REALISM_FINISH),
1741
+ },
1742
+ },
1743
+ {
1744
+ id: "generate-image/phone-selfie",
1745
+ name: "Phone Selfie",
1746
+ description: "Eye-level, arm's-length self-portrait look.",
1747
+ group: "Realism & Authentic",
1748
+ data: {
1749
+ ...REALISM_MODEL,
1750
+ aspectRatio: "3:4",
1751
+ prompt: sentences("{subject || An adult woman in her late twenties with her hair tied back}, {setting || on a couch at home, a cushion and a lamp behind}.", realismViewpoint(NEUTRAL_PERSON), realismEmptyHands(NEUTRAL_PERSON), REALISM_SKIN, REALISM_FINISH),
1752
+ },
1753
+ },
1754
+ {
1755
+ id: "generate-image/candid-snapshot",
1756
+ name: "Candid Phone Snapshot",
1757
+ description: "Caught mid-moment, an everyday snapshot.",
1758
+ group: "Realism & Authentic",
1759
+ data: {
1760
+ ...REALISM_MODEL,
1761
+ aspectRatio: "3:4",
1762
+ prompt: sentences("{subject || An adult man in his thirties} caught mid-moment, not posing, glancing away, the framing a little crooked, part of the scene cut by the edge, {setting || at a kitchen table at home}.", REALISM_SKIN, REALISM_FINISH),
1763
+ },
1764
+ },
1765
+ {
1766
+ id: "generate-image/mirror-selfie",
1767
+ name: "Mirror Selfie",
1768
+ description: "Mirror self-portrait, phone in hand.",
1769
+ group: "Realism & Authentic",
1770
+ data: {
1771
+ ...REALISM_MODEL,
1772
+ aspectRatio: "9:16",
1773
+ prompt: sentences("{subject || An adult woman in her twenties} photographs their reflection in a full-length mirror {setting || in a hallway at home}, a phone held at chest height with its back toward the mirror.", REALISM_SKIN, REALISM_FINISH),
1774
+ },
1775
+ },
1776
+ {
1777
+ id: "generate-image/creator-at-home",
1778
+ name: "Creator at Home · Waist-Up",
1779
+ description: "A waist-up reference of the same person — wire a face photo into the references.",
1780
+ group: "Realism & Authentic",
1781
+ data: {
1782
+ ...REALISM_MODEL,
1783
+ aspectRatio: "9:16",
1784
+ prompt: sentences("Waist-up photo of {image:1:person}, an adult, standing at home in front of a plain wall, arms relaxed, empty hands.", REALISM_SKIN, REALISM_FINISH),
1785
+ },
1786
+ },
1787
+ {
1788
+ id: "generate-image/video-call-frame",
1789
+ name: "Webcam / Video-Call Frame",
1790
+ description: "Chest-up video-call framing.",
1791
+ group: "Realism & Authentic",
1792
+ data: {
1793
+ ...REALISM_MODEL,
1794
+ aspectRatio: "16:9",
1795
+ prompt: sentences("{subject || An adult man in his forties}, chest-up, a little off-centre, slightly wide, seen from a fixed point just above eye level about an arm's length away, a softly lit room behind, flat even light from the front.", REALISM_FINISH),
1796
+ },
1797
+ },
1798
+ {
1799
+ id: "generate-image/macro-skin-study",
1800
+ name: "Macro Skin Study",
1801
+ description: "A close study of one area of skin.",
1802
+ group: "Realism & Authentic",
1803
+ data: {
1804
+ ...SKIN_STUDY_MODEL,
1805
+ aspectRatio: "1:1",
1806
+ prompt: sentences("{feature || the left cheek and the side of the nose} of {subject || an adult woman in her thirties}, filling the frame, everything sharp front to back, flat cool daylight, neutral colour, a plain study of the skin, not a glamour shot.", REALISM_SKIN),
1807
+ },
1808
+ },
1809
+ {
1810
+ id: "generate-image/product-in-hand",
1811
+ name: "Product in Hand — phone photo",
1812
+ description: "Wire a photo of the product into the references.",
1813
+ group: "Realism & Authentic",
1814
+ data: {
1815
+ ...REALISM_MODEL,
1816
+ aspectRatio: "3:4",
1817
+ prompt: sentences("{subject || An adult woman in her thirties} holds {image:1:product} at chest height toward the viewer, label facing out, {setting || in a bright room at home}.", REALISM_FINISH),
1818
+ },
1819
+ },
1706
1820
  ]
@@ -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,4 +1,5 @@
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
@@ -190,6 +191,47 @@ const IMAGE_EDITS: ReadonlyArray<{
190
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",
191
192
  },
192
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
+ },
193
235
  ]
194
236
 
195
237
  /** Build the Edits presets for a node type (id = `<nodeType>/<slug>`). */
@@ -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
  ]
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"
@@ -252,7 +252,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
252
252
  "seedance-2-fast": "Seedance 2.0 Fast — same multimodal + audio capabilities, cheaper and quicker",
253
253
  "seedance-2-mini": "Seedance 2.0 Mini — same multimodal + audio capabilities, budget tier, 480p/720p, 4-15s",
254
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",
255
- "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",
256
256
  "wan": "Versatile, good for animations and transformations",
257
257
  "wan-turbo": "Faster Wan generation",
258
258
  "hailuo-standard": "Standard quality, cost-effective",
@@ -283,7 +283,7 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
283
283
  "seedance-2-fast": "Seedance 2.0 Fast — same capabilities, cheaper and quicker",
284
284
  "seedance-2-mini": "Seedance 2.0 Mini — same capabilities, budget tier, 480p/720p",
285
285
  "seedance-2-5": "Seedance 2.5 — start/end frame + wide multimodal refs, native audio, up to 30s, 480p/720p/1080p",
286
- "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",
287
287
  "hailuo-2.3-pro": "Premium Hailuo animation",
288
288
  "hailuo-2.3": "Standard Hailuo animation",
289
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).",