@nodaro/prompts 1.5.0 → 1.7.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.5.0",
3
+ "version": "1.7.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",
@@ -179,6 +179,26 @@ describe("assembleSunoInput — persona spread", () => {
179
179
  })
180
180
  })
181
181
 
182
+ describe("assembleSunoInput — duration pass-through", () => {
183
+ it("carries data.duration onto the result verbatim (the provider client gates the send)", () => {
184
+ const r = assembleSunoInput({
185
+ node: node({ customMode: true, style: "pop", model: "V5_5", duration: 120 }),
186
+ graph: emptyGraph,
187
+ userPrompt: "song",
188
+ })
189
+ expect(r.duration).toBe(120)
190
+ })
191
+
192
+ it("no data.duration → undefined", () => {
193
+ const r = assembleSunoInput({
194
+ node: node({ model: "V5_5" }),
195
+ graph: emptyGraph,
196
+ userPrompt: "song",
197
+ })
198
+ expect(r.duration).toBeUndefined()
199
+ })
200
+ })
201
+
182
202
  describe("assembleSunoInput — || undefined normalization (divergence E)", () => {
183
203
  it("empty model/style/title/negativeStyle normalize to undefined", () => {
184
204
  const r = assembleSunoInput({
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MODEL_CATALOG } from "@nodaro/shared"
3
+ import { getPromptDoctrine } from "../provider-prompt-doctrine.js"
4
+
5
+ /**
6
+ * The badge's truthfulness guarantee (Cine build-brief §7 — never overclaim):
7
+ * every video GENERATION model is either doctrine-covered or DELIBERATELY
8
+ * generic. A new video model that is neither fails this test — forcing the
9
+ * author to write a sourced doctrine or consciously add it to the generic
10
+ * set with a reason.
11
+ */
12
+
13
+ /** Models deliberately left WITHOUT a doctrine, with the reason. */
14
+ const DELIBERATELY_GENERIC: ReadonlyMap<string, string> = new Map([
15
+ // Older ByteDance engine (pre-Seedance-2 surface: single image, no refs/audio
16
+ // levers) — mapping the Seedance family doctrine would overclaim.
17
+ ["bytedance-lite", "older engine, different surface"],
18
+ ["bytedance-pro", "older engine, different surface"],
19
+ ["bytedance-pro-fast", "older engine, different surface"],
20
+ // Hailuo pre-H3 tiers: single-image i2v without the multimodal reference
21
+ // surface the H3 doctrine teaches.
22
+ ["hailuo-2.3", "pre-H3 tier without the multimodal surface"],
23
+ ["hailuo-2.3-pro", "pre-H3 tier without the multimodal surface"],
24
+ ["hailuo-standard", "pre-H3 tier without the multimodal surface"],
25
+ // Legacy/simple tiers with no vendor guidance beyond the platform default.
26
+ ["minimax", "legacy 5s tier, no vendor guide"],
27
+ ["seedance", "legacy Seedance 1.x, superseded"],
28
+ ["ltx-2.3-fast", "no vendor prompt guide published"],
29
+ ["sora2", "roster utility — no first-party guide via KIE"],
30
+ ["sora2-pro", "roster utility — no first-party guide via KIE"],
31
+ ])
32
+
33
+ /** Utility modes that are driven by inputs, not prose prompting — doctrine
34
+ * coverage isn't meaningful for them (spec excludes them from the roster). */
35
+ const UTILITY_ONLY_MODES = new Set(["extend", "motion-transfer", "lip-sync", "video-upscale", "v2v", "video-analysis", "video-audit"])
36
+
37
+ describe("doctrine roster completeness", () => {
38
+ const videoGenerationIds = Object.values(MODEL_CATALOG)
39
+ .filter((m) => m.kind === "video")
40
+ .filter((m) => m.modes?.some((mode) => (mode === "t2v" || mode === "i2v") && !UTILITY_ONLY_MODES.has(mode)))
41
+ .map((m) => m.id)
42
+
43
+ it("covers every video generation model — or lists it as deliberately generic", () => {
44
+ const uncovered = videoGenerationIds.filter(
45
+ (id) => getPromptDoctrine(id) === undefined && !DELIBERATELY_GENERIC.has(id),
46
+ )
47
+ expect(
48
+ uncovered,
49
+ `New video model(s) with neither a doctrine nor a deliberate-generic entry: ${uncovered.join(", ")}. ` +
50
+ "Write a sourced doctrine or add to DELIBERATELY_GENERIC with a reason.",
51
+ ).toEqual([])
52
+ })
53
+
54
+ it("the deliberate-generic set stays honest (no entry that is actually covered)", () => {
55
+ for (const id of DELIBERATELY_GENERIC.keys()) {
56
+ expect(getPromptDoctrine(id), `${id} is covered — remove it from DELIBERATELY_GENERIC`).toBeUndefined()
57
+ }
58
+ })
59
+ })
@@ -10,12 +10,16 @@ const library: ConnectedReference = {
10
10
  }
11
11
 
12
12
  describe("location reference converges onto the image hybrid form", () => {
13
- it("wired location (canonical) → 'the background from reference image A', no legacy block", () => {
13
+ // The DEFAULT role for a wired location became "location" (2026-08-05) — a
14
+ // place, not a backdrop to paste. This assertion previously pinned "the
15
+ // background from …", the wording measured to produce cut-out composites.
16
+ // The explicit `:background` token below is unaffected and still renders it.
17
+ it("wired location (canonical) → 'the location from reference image A', no legacy block", () => {
14
18
  const out = buildImagePrompt({
15
19
  prompt: "a detective at her desk", connectedReferences: [library],
16
20
  provider: "nano-banana-pro", referenceFormat: "hybrid",
17
21
  })
18
- expect(out.prompt).toContain("the background from reference image A")
22
+ expect(out.prompt).toContain("the location from reference image A")
19
23
  expect(out.prompt).not.toContain("Use these locations:")
20
24
  expect(out.referenceImageUrls).toContain("https://cdn/library.png")
21
25
  })
@@ -77,7 +77,17 @@ describe("registry membership (Task 2: person only)", () => {
77
77
  describe("registry invariants (all analyzable pickers)", () => {
78
78
  it("registers the batch", () => {
79
79
  expect(new Set(PICKER_TYPES)).toEqual(
80
- new Set(["person", "styling", "framing", "lens", "camera-format"]),
80
+ new Set([
81
+ // multi-dim (discriminated)
82
+ "person", "styling", "framing", "lighting", "temporal", "exposure-settings",
83
+ "music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery",
84
+ // single-value (flat)
85
+ "lens", "camera-format", "setting", "atmosphere", "style", "mood", "color-look",
86
+ "photographer", "aesthetic", "era", "photo-genre", "backdrop", "render-quality",
87
+ "composition-effects", "post-process-effects", "action-fx", "loop-subject",
88
+ "transition", "character-fx", "pose", "material", "held-prop", "camera-motion",
89
+ "animal", "vehicle", "weapon", "furniture",
90
+ ]),
81
91
  )
82
92
  })
83
93
  it.each(PICKER_TYPES)("%s: every dimension enum equals its catalog ids and is non-empty", (t) => {
@@ -13,7 +13,7 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
13
13
  expect(d.providers.length).toBeGreaterThan(0)
14
14
  for (const p of d.providers) expect(MODEL_CATALOG[p]).toBeDefined()
15
15
  expect(d.tips.length).toBeGreaterThanOrEqual(3)
16
- expect(d.tips.length).toBeLessThanOrEqual(6)
16
+ expect(d.tips.length).toBeLessThanOrEqual(7)
17
17
  for (const t of d.tips) expect(t.length).toBeLessThanOrEqual(220)
18
18
  expect(d.doctrine.length).toBeGreaterThan(500)
19
19
  expect(d.heading.length).toBeGreaterThan(0)
@@ -23,9 +23,11 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
23
23
  it("resolves by provider id, returns undefined/[] for providers without doctrine", () => {
24
24
  expect(getPromptDoctrine("seedance-2")).toBeDefined()
25
25
  expect(getPromptDoctrine("seedance-2-fast")).toBeDefined()
26
- expect(getPromptDoctrine("veo3.1")).toBeUndefined()
26
+ // bytedance-lite/pro + hailuo-2.3 are DELIBERATELY uncovered (older engines
27
+ // whose surfaces differ — mapping the family doctrine would overclaim).
28
+ expect(getPromptDoctrine("bytedance-lite")).toBeUndefined()
27
29
  expect(getPromptTips("seedance-2").length).toBeGreaterThan(0)
28
- expect(getPromptTips("veo3.1")).toEqual([])
30
+ expect(getPromptTips("bytedance-lite")).toEqual([])
29
31
  })
30
32
 
31
33
  it("seedance doctrine encodes the official rules and bans the unstable patterns", () => {
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ REFERENCE_RULES,
4
+ REFERENCE_RULES_MULTI_PERSON,
5
+ SCENE_FRAME_RULE,
6
+ FILM_STILL_PREFIX,
7
+ CINEMATIC_LOOK_TAIL,
8
+ referenceRulesBlock,
9
+ } from "../reference-rules.js"
10
+ import { FACTORY_SNIPPETS } from "../factory-snippets/catalog.js"
11
+
12
+ /**
13
+ * These pin the OUTCOME of a measurement, not a preference — 36 draws on
14
+ * gpt-image-2 against a four-reference brief with wardrobe swapped between two
15
+ * people (see reference-rules.ts for the arms and the counts). Changing any of
16
+ * these strings without re-running that comparison is how the two wordings
17
+ * drifted apart in the first place.
18
+ */
19
+ describe("REFERENCE_RULES is the short block — the default follows the population", () => {
20
+ it("keeps the default-deny, the likeness rule and the compose clause", () => {
21
+ expect(REFERENCE_RULES).toContain("Do not use anything from reference images unless specified explicitly.")
22
+ expect(REFERENCE_RULES).toContain("All elements taken from reference images must preserve likeness.")
23
+ expect(REFERENCE_RULES).toContain("Compose them naturally into a single image.")
24
+ })
25
+
26
+ it("carries NO face clauses — they are dead weight on a product or a landscape", () => {
27
+ // The default lands on every brief. Tal's volume of real jobs says the
28
+ // short block wins in general; the controlled brief that favoured the face
29
+ // clauses had two faces swapping a garment, which is what MULTI_PERSON is
30
+ // for. Both results stand; this is the one that has to be safe everywhere.
31
+ expect(REFERENCE_RULES).not.toContain("face structure")
32
+ expect(REFERENCE_RULES).not.toContain("blend faces")
33
+ })
34
+
35
+ it("MULTI_PERSON adds exactly the two face clauses and nothing else", () => {
36
+ expect(REFERENCE_RULES_MULTI_PERSON).toContain("Do not alter face structure.")
37
+ expect(REFERENCE_RULES_MULTI_PERSON).toContain("Do not blend faces.")
38
+ expect(REFERENCE_RULES_MULTI_PERSON).toContain("Compose them naturally into a single image.")
39
+ })
40
+
41
+ it("neither block carries the performance clause — a composite has no scene to perform", () => {
42
+ // That clause belongs to gvp's scene lane, where a subject acts a beat.
43
+ // Here it replaced the compose clause and scored 1/4 against 4/4.
44
+ for (const block of [REFERENCE_RULES, REFERENCE_RULES_MULTI_PERSON]) {
45
+ expect(block).not.toContain("Expression, gaze and pose follow the scene")
46
+ }
47
+ })
48
+
49
+ it("does not claim a medium, a genre or a mood", () => {
50
+ // Every arm that described what the picture IS cost reference fidelity —
51
+ // "scene start frame of a video" lost the lead's identity in 3 of 3.
52
+ for (const banned of ["film", "video", "cinematic", "candid", "photo"]) {
53
+ expect(REFERENCE_RULES.toLowerCase()).not.toContain(banned)
54
+ expect(REFERENCE_RULES_MULTI_PERSON.toLowerCase()).not.toContain(banned)
55
+ }
56
+ })
57
+ })
58
+
59
+ describe("SCENE_FRAME_RULE is the short negative, and stays short", () => {
60
+ it("is exactly the sentence that measured free", () => {
61
+ expect(SCENE_FRAME_RULE).toBe("Nobody looks at the camera.")
62
+ })
63
+
64
+ it("constrains the eyeline and nothing else", () => {
65
+ // The longer phrasings ("a candid moment, unposed, nobody aware of the
66
+ // camera") fixed the gaze and lost a face. Length IS the failure mode.
67
+ expect(SCENE_FRAME_RULE.split(" ")).toHaveLength(5)
68
+ for (const banned of ["film", "cinematic", "candid", "unposed", "movie"]) {
69
+ expect(SCENE_FRAME_RULE.toLowerCase()).not.toContain(banned)
70
+ }
71
+ })
72
+ })
73
+
74
+ describe("referenceRulesBlock resolves the two toggles independently", () => {
75
+ it("defaults to the rules alone — the eyeline is a creative choice, not a rule", () => {
76
+ expect(referenceRulesBlock()).toBe(REFERENCE_RULES)
77
+ expect(referenceRulesBlock({})).toBe(REFERENCE_RULES)
78
+ expect(referenceRulesBlock()).not.toContain(SCENE_FRAME_RULE)
79
+ })
80
+
81
+ it("adds the eyeline rule only when asked", () => {
82
+ const both = referenceRulesBlock({ sceneFrame: true })
83
+ expect(both).toContain(REFERENCE_RULES)
84
+ expect(both).toContain(SCENE_FRAME_RULE)
85
+ expect(both.indexOf(REFERENCE_RULES)).toBeLessThan(both.indexOf(SCENE_FRAME_RULE))
86
+ })
87
+
88
+ it("returns EMPTY when everything is off, so a caller can prepend blind", () => {
89
+ expect(referenceRulesBlock({ referenceRules: false })).toBe("")
90
+ expect(referenceRulesBlock({ referenceRules: false, sceneFrame: false })).toBe("")
91
+ })
92
+
93
+ it("can emit the eyeline rule alone", () => {
94
+ expect(referenceRulesBlock({ referenceRules: false, sceneFrame: true })).toBe(SCENE_FRAME_RULE)
95
+ })
96
+ })
97
+
98
+ describe("the snippet catalog and the injected default cannot drift", () => {
99
+ const byId = (id: string) => FACTORY_SNIPPETS.find((s) => s.id === id)
100
+
101
+ it("Reference Lock IS the default constant, not a hand-kept twin", () => {
102
+ // The bug this closes: the catalog carried its own wording, written without
103
+ // knowledge of gvp's, and scored 0/4 where the merged block scored 4/4.
104
+ expect(byId("reference-lock")?.text).toBe(REFERENCE_RULES)
105
+ })
106
+
107
+ it("Scene Frame IS the constant, and is a SEPARATE entry", () => {
108
+ expect(byId("scene-frame")?.text).toBe(SCENE_FRAME_RULE)
109
+ expect(byId("reference-lock")?.text).not.toContain(SCENE_FRAME_RULE)
110
+ })
111
+
112
+ it("both sit in the Reference locks category, for images", () => {
113
+ for (const id of ["reference-lock", "scene-frame"]) {
114
+ expect(byId(id)?.category).toBe("Reference locks")
115
+ expect(byId(id)?.target).toBe("prompt")
116
+ }
117
+ })
118
+ })
119
+
120
+ /**
121
+ * END TO END through the assembler, because the block is only worth anything
122
+ * if it arrives AHEAD of the lettered scene it talks about. "Reference image A"
123
+ * is a hybrid phrase — rules prepended to a legacy assembly would be telling
124
+ * the model to obey bindings that were never lettered.
125
+ */
126
+ describe("the block reaches the prompt ahead of the scene it governs", () => {
127
+ const ref = (id: string, url: string) =>
128
+ ({ id, defaultName: id, source: "manual" as const, url })
129
+
130
+ it("prepends the measured wording, then the lettered scene", async () => {
131
+ const { buildImagePrompt } = await import("../prompt-builder.js")
132
+ const { prompt } = buildImagePrompt({
133
+ provider: "gpt-image-2",
134
+ prompt: "{image:1:person} wears {image:2:clothes}.",
135
+ connectedReferences: [ref("a", "https://r2/a.png"), ref("b", "https://r2/b.png")],
136
+ referenceFormat: "hybrid",
137
+ referenceLockSnippet: referenceRulesBlock({ sceneFrame: true }),
138
+ })
139
+ expect(prompt.startsWith(REFERENCE_RULES)).toBe(true)
140
+ expect(prompt).toContain(SCENE_FRAME_RULE)
141
+ // The rules come FIRST; the bindings they govern come after.
142
+ expect(prompt.indexOf(SCENE_FRAME_RULE)).toBeLessThan(prompt.indexOf("reference image A"))
143
+ // Line-initial is capitalized by the hybrid scene builder.
144
+ expect(prompt).toContain("The person from reference image A wears the clothes from reference image B.")
145
+ })
146
+
147
+ it("leaves the prompt untouched when the caller turned both off", async () => {
148
+ const { buildImagePrompt } = await import("../prompt-builder.js")
149
+ const snippet = referenceRulesBlock({ referenceRules: false })
150
+ const { prompt } = buildImagePrompt({
151
+ provider: "gpt-image-2",
152
+ prompt: "{image:1:person} sits.",
153
+ connectedReferences: [ref("a", "https://r2/a.png")],
154
+ referenceFormat: "hybrid",
155
+ ...(snippet ? { referenceLockSnippet: snippet } : {}),
156
+ })
157
+ expect(prompt).not.toContain("Do not take anything")
158
+ expect(prompt).not.toContain("Nobody looks at the camera")
159
+ })
160
+ })
161
+
162
+
163
+ /**
164
+ * THE ONE RULE ALL OF TONIGHT'S ARMS TURNED OUT TO BE: position decides whether
165
+ * an instruction helps or fights the reference bindings.
166
+ *
167
+ * rules FIRST · framing as a PREFIX · look LAST · never a claim in the middle
168
+ *
169
+ * "This image is a scene start frame of a video." is the middle-claim shape and
170
+ * it cost the lead's identity in 3 of 3 draws. "Film still of" is the prefix
171
+ * shape and costs nothing. The look tail is the last-position shape and costs
172
+ * nothing. gvp found the last one independently: "THE MEDIUM GOES LAST".
173
+ */
174
+ describe("the framing prefix and the look tail keep their shapes", () => {
175
+ it("the framing snippet is a PREFIX, not a sentence", () => {
176
+ // A fragment, so it swallows the scene that follows. A full stop here would
177
+ // make it a standalone claim — the shape that lost the references.
178
+ expect(FILM_STILL_PREFIX).toBe("Film still of")
179
+ expect(FILM_STILL_PREFIX.endsWith(".")).toBe(false)
180
+ })
181
+
182
+ it("the look tail names stock, lens, light and palette", () => {
183
+ for (const part of ["16mm", "lenses", "light", "palette"]) {
184
+ expect(CINEMATIC_LOOK_TAIL.toLowerCase()).toContain(part)
185
+ }
186
+ // It is a tail — no trailing full stop, nothing after it to argue with.
187
+ expect(CINEMATIC_LOOK_TAIL.endsWith(".")).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe("multiPerson swaps in the face clauses without touching anything else", () => {
192
+ it("is off by default", () => {
193
+ expect(referenceRulesBlock()).toBe(REFERENCE_RULES)
194
+ expect(referenceRulesBlock()).not.toContain("blend faces")
195
+ })
196
+
197
+ it("opts into the measured composition block", () => {
198
+ expect(referenceRulesBlock({ multiPerson: true })).toBe(REFERENCE_RULES_MULTI_PERSON)
199
+ })
200
+
201
+ it("still composes with the eyeline rule", () => {
202
+ const both = referenceRulesBlock({ multiPerson: true, sceneFrame: true })
203
+ expect(both).toContain(REFERENCE_RULES_MULTI_PERSON)
204
+ expect(both).toContain(SCENE_FRAME_RULE)
205
+ })
206
+
207
+ it("stays silent when the rules are off, whatever multiPerson says", () => {
208
+ expect(referenceRulesBlock({ referenceRules: false, multiPerson: true })).toBe("")
209
+ })
210
+ })
211
+
212
+ describe("filmStillPrefix leads with the shot size", () => {
213
+ it("puts the framing first, then the fixed tail", async () => {
214
+ const { filmStillPrefix } = await import("../reference-rules.js")
215
+ expect(filmStillPrefix("Medium wide")).toBe("Medium wide film still of")
216
+ expect(filmStillPrefix("Extreme wide")).toBe("Extreme wide film still of")
217
+ })
218
+
219
+ it("falls back to the bare prefix when no shot is given", async () => {
220
+ const { filmStillPrefix } = await import("../reference-rules.js")
221
+ expect(filmStillPrefix()).toBe(FILM_STILL_PREFIX)
222
+ expect(filmStillPrefix(" ")).toBe(FILM_STILL_PREFIX)
223
+ })
224
+
225
+ it("claims no genre — a UGC clip and a documentary are film stills too", async () => {
226
+ const { filmStillPrefix } = await import("../reference-rules.js")
227
+ // "Cinematic" imposes a register. It is also the exact category of word
228
+ // every measured arm punished, so it does not belong in a default.
229
+ for (const shot of [undefined, "Medium wide"]) {
230
+ expect(filmStillPrefix(shot).toLowerCase()).not.toContain("cinematic")
231
+ }
232
+ })
233
+
234
+ it("never ends in a full stop — it must swallow the scene, not stand alone", async () => {
235
+ const { filmStillPrefix } = await import("../reference-rules.js")
236
+ for (const shot of [undefined, "Extreme wide", "Close-up"]) {
237
+ expect(filmStillPrefix(shot).endsWith(".")).toBe(false)
238
+ }
239
+ })
240
+ })
@@ -34,6 +34,7 @@ describe("video reference-image capability (catalog)", () => {
34
34
  "seedance-2",
35
35
  "seedance-2-fast",
36
36
  "seedance-2-mini",
37
+ "minimax-h3",
37
38
  ]) {
38
39
  expect(refModels).toContain(id)
39
40
  }
@@ -84,6 +84,13 @@ export interface AssembleSunoResult {
84
84
  customMode: boolean
85
85
  instrumental: boolean
86
86
  model?: string
87
+ /**
88
+ * Requested song length in seconds (KIE: 10–360). Passed through from
89
+ * `data.duration` unconditionally — the provider client is the single
90
+ * gate that only sends it when customMode && model V5_5 (KIE ignores it
91
+ * elsewhere), so the assembler stays a faithful field carrier.
92
+ */
93
+ duration?: number
87
94
  personaId?: string
88
95
  personaModel?: "voice_persona" | "style_persona"
89
96
  }
@@ -140,6 +147,7 @@ export function assembleSunoInput(input: AssembleSunoInput): AssembleSunoResult
140
147
  styleWeight: data.styleWeight as number | undefined,
141
148
  weirdnessConstraint: data.weirdnessConstraint as number | undefined,
142
149
  audioWeight: data.audioWeight as number | undefined,
150
+ duration: data.duration as number | undefined,
143
151
  customMode,
144
152
  instrumental: (data.instrumental as boolean | undefined) ?? false,
145
153
  ...(input.persona ?? {}),
@@ -1,6 +1,7 @@
1
1
  /** Factory snippet catalog (v1: image + video; audio/text follow later).
2
2
  * Order within a category = menu order = pill quick-cycle order. */
3
3
  import type { FactorySnippet } from "./types.js"
4
+ import { REFERENCE_RULES, SCENE_FRAME_RULE, FILM_STILL_PREFIX, CINEMATIC_LOOK_TAIL } from "../reference-rules.js"
4
5
 
5
6
  const B = ["image", "video"] as const
6
7
  const I = ["image"] as const
@@ -15,7 +16,24 @@ export const FACTORY_SNIPPETS: readonly FactorySnippet[] = [
15
16
  { id: "no-beautify", name: "No Beautify", description: "Stop the model 'improving' a face", text: "preserve natural skin texture, age lines, and asymmetries; do not beautify, smooth, slim, or rejuvenate the face", target: "prompt", media: I, category: "Identity & Consistency" },
16
17
 
17
18
  // ── Reference locks (prompt) — insert at the START of a reference prompt ──
18
- { id: "reference-lock", name: "Reference Lock", description: "Default-deny + preserve likeness + compose into one image (full scenes)", text: "Do not use anything from reference images unless specified explicitly. All elements taken from reference images must preserve likeness. Compose them naturally into a single image.", target: "prompt", media: I, category: "Reference locks" },
19
+ // Text comes from the shared constant, not a hand-written twin. This entry used
20
+ // to carry its own wording — no face rules, likeness phrased as a passive —
21
+ // which scored 0/4 on moving a garment between references where the merged
22
+ // block scored 4/4 (see reference-rules.ts). A snippet is copied into the
23
+ // prompt at INSERT time, so changing it here only affects future insertions.
24
+ { id: "reference-lock", name: "Reference Lock", description: "Default-deny + preserve likeness + compose into one image (full scenes)", text: REFERENCE_RULES, target: "prompt", media: I, category: "Reference locks" },
25
+ // The eyeline suppressor, kept OUT of Reference Lock on purpose: a portrait
26
+ // or a piece to camera wants the eyeline. 4/4 on a brief where the lock alone
27
+ // was 0/4, at no measured cost to identity or wardrobe.
28
+ { id: "scene-frame", name: "Scene Frame", description: "Reads as a frame from a film, not a posed photo — nobody faces the lens", text: SCENE_FRAME_RULE, target: "prompt", media: I, category: "Reference locks" },
29
+ // A PREFIX, not a sentence — it swallows the scene that follows it, which is
30
+ // exactly why it costs nothing. The same idea written as a standalone claim
31
+ // ("This image is a scene start frame of a video.") lost the lead's identity
32
+ // in 3 of 3 draws. Goes at the very TOP, above the scene.
33
+ { id: "film-still-of", name: "Film Still Of", description: "Put at the very top, before the scene — lead with the shot size, e.g. \"Medium wide…\"", text: `Medium wide ${FILM_STILL_PREFIX.charAt(0).toLowerCase()}${FILM_STILL_PREFIX.slice(1)}`, target: "prompt", media: I, category: "Reference locks" },
34
+ // Goes at the very END. An example to edit — a different film wants a
35
+ // different stock; what generalises is that the look comes LAST.
36
+ { id: "cinematic-look", name: "Cinematic Look (16mm)", description: "Append at the END — film stock, lens, light and palette", text: CINEMATIC_LOOK_TAIL, target: "prompt", media: I, category: "Reference locks" },
19
37
  { id: "reference-extract", name: "Reference Extract", description: "Isolate only the specified elements — no scene, no figure", text: "Take only what is specified from the reference images. Do not take anything else.", target: "prompt", media: I, category: "Reference locks" },
20
38
  { id: "ghost-mannequin", name: "Ghost Mannequin", description: "Show worn garments without a wearer (product shot)", text: "Ghost-mannequin product shot: the garment in its natural worn shape, with no person, body, face, or mannequin visible.", target: "prompt", media: I, category: "Reference locks" },
21
39
 
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * only what the public API contract requires.
10
10
  */
11
11
  export * from "./identity-lock.js"
12
+ export * from "./reference-rules.js"
12
13
  export * from "./parameter-prompt-hint.js"
13
14
  export * from "./entity-prompts.js"
14
15
  export * from "./brand-tokens.js"
@@ -64,3 +65,4 @@ export * from "./factory-presets.js"
64
65
  export * from "./style-presets.js"
65
66
  export * from "./object-asset-presets.js"
66
67
  export * from "./factory-snippets/index.js"
68
+ export * from "./picker-wiring.js"