@nodaro/prompts 1.0.1

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 (150) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +27 -0
  3. package/dist/index.cjs +21240 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +3599 -0
  6. package/dist/index.d.ts +3599 -0
  7. package/dist/index.js +20854 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
  11. package/src/__tests__/action-fx.test.ts +155 -0
  12. package/src/__tests__/apply-picker-json.test.ts +50 -0
  13. package/src/__tests__/assemble-image-input.test.ts +248 -0
  14. package/src/__tests__/assemble-suno-input.test.ts +283 -0
  15. package/src/__tests__/brand-tokens.test.ts +81 -0
  16. package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
  17. package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
  18. package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
  19. package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
  20. package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
  21. package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
  22. package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
  23. package/src/__tests__/catalog-gapfill.test.ts +53 -0
  24. package/src/__tests__/character-convergence-image.test.ts +217 -0
  25. package/src/__tests__/character-default-role-image.test.ts +167 -0
  26. package/src/__tests__/character-default-role-video.test.ts +168 -0
  27. package/src/__tests__/character-default-role.test.ts +70 -0
  28. package/src/__tests__/character-fx.test.ts +200 -0
  29. package/src/__tests__/entity-prompts-location.test.ts +97 -0
  30. package/src/__tests__/entity-prompts.test.ts +240 -0
  31. package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
  32. package/src/__tests__/factory-presets.test.ts +1045 -0
  33. package/src/__tests__/factory-snippets.test.ts +68 -0
  34. package/src/__tests__/framing-multi.test.ts +71 -0
  35. package/src/__tests__/framing-vantage.test.ts +39 -0
  36. package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
  37. package/src/__tests__/identity-lock.test.ts +46 -0
  38. package/src/__tests__/instrumentation.test.ts +68 -0
  39. package/src/__tests__/lighting-multi.test.ts +68 -0
  40. package/src/__tests__/location-convergence-image.test.ts +124 -0
  41. package/src/__tests__/mention-lock-flag.test.ts +499 -0
  42. package/src/__tests__/multi-picker-spec.test.ts +71 -0
  43. package/src/__tests__/music-genre.test.ts +103 -0
  44. package/src/__tests__/music-mood.test.ts +87 -0
  45. package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
  46. package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
  47. package/src/__tests__/parameter-registry-sync.test.ts +243 -0
  48. package/src/__tests__/person-age.test.ts +80 -0
  49. package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
  50. package/src/__tests__/person-body-axes.test.ts +67 -0
  51. package/src/__tests__/person-facial-geometry.test.ts +156 -0
  52. package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
  53. package/src/__tests__/person-sections.test.ts +18 -0
  54. package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
  55. package/src/__tests__/picker-catalogs-project.test.ts +85 -0
  56. package/src/__tests__/picker-catalogs.test.ts +53 -0
  57. package/src/__tests__/picker-limits.test.ts +20 -0
  58. package/src/__tests__/prompt-builder-segments.test.ts +183 -0
  59. package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
  60. package/src/__tests__/prompt-builder.test.ts +1773 -0
  61. package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
  62. package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
  63. package/src/__tests__/resolve-prompt-append.test.ts +58 -0
  64. package/src/__tests__/resolve-prompt.test.ts +44 -0
  65. package/src/__tests__/role-picker-shared.test.ts +208 -0
  66. package/src/__tests__/seedance-2-inputs.test.ts +173 -0
  67. package/src/__tests__/seedance-extend.test.ts +44 -0
  68. package/src/__tests__/sound-aggregator.test.ts +350 -0
  69. package/src/__tests__/style-presets.test.ts +34 -0
  70. package/src/__tests__/temporal-multi.test.ts +74 -0
  71. package/src/__tests__/transitions.test.ts +213 -0
  72. package/src/__tests__/video-reference-features.test.ts +41 -0
  73. package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
  74. package/src/__tests__/video-reference-resolver.test.ts +233 -0
  75. package/src/__tests__/video-reference-roles.test.ts +96 -0
  76. package/src/__tests__/voice-character.test.ts +48 -0
  77. package/src/__tests__/voice-delivery.test.ts +41 -0
  78. package/src/__tests__/wardrobe.test.ts +25 -0
  79. package/src/action-fx.ts +255 -0
  80. package/src/aesthetic.ts +435 -0
  81. package/src/assemble-image-input.ts +236 -0
  82. package/src/assemble-suno-input.ts +147 -0
  83. package/src/atmosphere.ts +104 -0
  84. package/src/backdrop.ts +131 -0
  85. package/src/brand-tokens.ts +154 -0
  86. package/src/camera-format.ts +76 -0
  87. package/src/camera-motions.ts +615 -0
  88. package/src/character-fx.ts +274 -0
  89. package/src/color-look.ts +103 -0
  90. package/src/composition-effects.ts +67 -0
  91. package/src/entity-prompts.ts +231 -0
  92. package/src/era.ts +292 -0
  93. package/src/exposure-settings.ts +142 -0
  94. package/src/factory-presets/generate-image.ts +1644 -0
  95. package/src/factory-presets/generate-video.ts +1116 -0
  96. package/src/factory-presets/index.ts +46 -0
  97. package/src/factory-presets/lottie-overlay.ts +166 -0
  98. package/src/factory-presets/motion-graphics.ts +350 -0
  99. package/src/factory-presets/music.ts +734 -0
  100. package/src/factory-presets/sfx.ts +136 -0
  101. package/src/factory-presets/shared-image.ts +207 -0
  102. package/src/factory-presets/switchx.ts +65 -0
  103. package/src/factory-presets/text.ts +292 -0
  104. package/src/factory-presets/types.ts +51 -0
  105. package/src/factory-presets/video-edit.ts +136 -0
  106. package/src/factory-presets/voice.ts +172 -0
  107. package/src/factory-presets.ts +2 -0
  108. package/src/factory-snippets/catalog.ts +105 -0
  109. package/src/factory-snippets/index.ts +16 -0
  110. package/src/factory-snippets/types.ts +33 -0
  111. package/src/framing.ts +634 -0
  112. package/src/held-prop.ts +187 -0
  113. package/src/identity-lock.ts +213 -0
  114. package/src/index.ts +66 -0
  115. package/src/instrumentation.ts +337 -0
  116. package/src/lens.ts +59 -0
  117. package/src/lighting.ts +229 -0
  118. package/src/loop-subject.ts +276 -0
  119. package/src/materials.ts +184 -0
  120. package/src/mood.ts +186 -0
  121. package/src/music-genre.ts +662 -0
  122. package/src/music-mood.ts +137 -0
  123. package/src/object-asset-presets.ts +81 -0
  124. package/src/parameter-prompt-hint.ts +281 -0
  125. package/src/person.ts +1368 -0
  126. package/src/photo-genre.ts +151 -0
  127. package/src/photographer.ts +612 -0
  128. package/src/picker-analyzer-registry.ts +374 -0
  129. package/src/picker-catalogs.ts +858 -0
  130. package/src/pose.ts +246 -0
  131. package/src/post-process-effects.ts +94 -0
  132. package/src/prompt-builder-structured-fields.ts +116 -0
  133. package/src/prompt-builder.ts +2964 -0
  134. package/src/prompt-templates.ts +50 -0
  135. package/src/prompt-wizard-categories.ts +334 -0
  136. package/src/provider-prompt-doctrine.ts +85 -0
  137. package/src/render-quality.ts +89 -0
  138. package/src/resolve-prompt.ts +120 -0
  139. package/src/seedance-2-inputs.ts +100 -0
  140. package/src/setting.ts +130 -0
  141. package/src/sound-aggregator.ts +241 -0
  142. package/src/style-presets.ts +162 -0
  143. package/src/style.ts +99 -0
  144. package/src/styling.ts +585 -0
  145. package/src/temporal.ts +151 -0
  146. package/src/transitions.ts +333 -0
  147. package/src/video-reference-resolver.ts +823 -0
  148. package/src/voice-character.ts +237 -0
  149. package/src/voice-delivery.ts +142 -0
  150. package/src/wardrobe.ts +179 -0
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ FACTORY_SNIPPETS,
4
+ getFactorySnippets,
5
+ SNIPPET_MEDIA_VALUES,
6
+ } from "../factory-snippets/index.js"
7
+
8
+ describe("FACTORY_SNIPPETS catalog invariants", () => {
9
+ it("has unique ids and unique names", () => {
10
+ const ids = FACTORY_SNIPPETS.map((s) => s.id)
11
+ expect(new Set(ids).size).toBe(ids.length)
12
+ const names = FACTORY_SNIPPETS.map((s) => s.name.toLowerCase())
13
+ expect(new Set(names).size).toBe(names.length)
14
+ })
15
+
16
+ it("every snippet has valid shape", () => {
17
+ for (const s of FACTORY_SNIPPETS) {
18
+ expect(s.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
19
+ expect(s.name.trim().length).toBeGreaterThan(0)
20
+ expect(s.name.length).toBeLessThanOrEqual(80)
21
+ expect(s.text.trim()).toBe(s.text) // no leading/trailing whitespace
22
+ expect(s.text.length).toBeGreaterThan(0)
23
+ expect(s.text.length).toBeLessThanOrEqual(600)
24
+ expect(["prompt", "negative"]).toContain(s.target)
25
+ expect(s.media.length).toBeGreaterThan(0)
26
+ for (const m of s.media) expect(SNIPPET_MEDIA_VALUES).toContain(m)
27
+ expect(s.category.trim().length).toBeGreaterThan(0)
28
+ }
29
+ })
30
+
31
+ it("snippet text never collides with mention/variable token parsing", () => {
32
+ // `{`/`}` would form {Label} variable tokens; `@` would form @slug mentions;
33
+ // newlines would break the per-line pill matcher (collectTokens is per-line).
34
+ for (const s of FACTORY_SNIPPETS) {
35
+ expect(s.text).not.toMatch(/[{}@\n]/)
36
+ }
37
+ })
38
+
39
+ it("negative-target snippets are bare comma lists (never 'no X' phrasing)", () => {
40
+ for (const s of FACTORY_SNIPPETS.filter((x) => x.target === "negative")) {
41
+ expect(s.text.toLowerCase().startsWith("no ")).toBe(false)
42
+ expect(s.text.toLowerCase()).not.toContain(" don't ")
43
+ }
44
+ })
45
+
46
+ it("getFactorySnippets filters by target + media", () => {
47
+ const imgPrompt = getFactorySnippets("prompt", "image")
48
+ expect(imgPrompt.length).toBeGreaterThan(20)
49
+ expect(imgPrompt.every((s) => s.target === "prompt" && s.media.includes("image"))).toBe(true)
50
+ const vidNeg = getFactorySnippets("negative", "video")
51
+ expect(vidNeg.length).toBeGreaterThanOrEqual(4)
52
+ expect(vidNeg.every((s) => s.target === "negative" && s.media.includes("video"))).toBe(true)
53
+ // Audio/text catalogs are deliberately empty in v1 — menu shows user snippets only.
54
+ expect(getFactorySnippets("prompt", "audio")).toEqual([])
55
+ })
56
+
57
+ it("no snippet text is a substring of another (keeps the pill matcher unambiguous)", () => {
58
+ for (const a of FACTORY_SNIPPETS) {
59
+ for (const b of FACTORY_SNIPPETS) {
60
+ if (a.id === b.id) continue
61
+ expect(
62
+ b.text.includes(a.text),
63
+ `"${a.id}" text is contained in "${b.id}" text`,
64
+ ).toBe(false)
65
+ }
66
+ }
67
+ })
68
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildFramingHints, FRAMING_FIELD_BY_CATEGORY, FRAMING_CATEGORY_ORDER } from "../framing.js"
3
+
4
+ describe("buildFramingHints", () => {
5
+ it("returns empty when nothing is set", () => {
6
+ const hints = buildFramingHints({})
7
+ expect(hints).toEqual([])
8
+ })
9
+
10
+ it("emits one hint per set per-category field, in canonical category order", () => {
11
+ const hints = buildFramingHints({
12
+ shotSize: "wide-shot",
13
+ angle: "low-angle",
14
+ coverage: "single",
15
+ composition: "rule-of-thirds",
16
+ vantage: "front-on",
17
+ })
18
+ expect(hints.length).toBe(5)
19
+ // Canonical order: shot-size, angle, coverage, composition, vantage
20
+ expect(hints[0]).toMatch(/wide shot/i)
21
+ expect(hints[1]).toMatch(/low angle/i)
22
+ expect(hints[2]).toMatch(/single shot/i)
23
+ expect(hints[3]).toMatch(/rule of thirds/i)
24
+ expect(hints[4]).toMatch(/front-on/i)
25
+ })
26
+
27
+ it("ignores the legacy framing field — multi-category is the sole shape", () => {
28
+ // Legacy compat layer was removed: the legacy `framing` field is no
29
+ // longer read; only per-category fields are considered.
30
+ const hints = buildFramingHints({ framing: "medium-shot" } as Record<string, unknown>)
31
+ expect(hints).toEqual([])
32
+ })
33
+
34
+ it("skipVantage suppresses ONLY the vantage hint, keeps other categories", () => {
35
+ const hints = buildFramingHints(
36
+ {
37
+ shotSize: "close-up",
38
+ angle: "high-angle",
39
+ vantage: "profile-left",
40
+ },
41
+ true,
42
+ )
43
+ expect(hints.length).toBe(2)
44
+ expect(hints[0]).toMatch(/close-up/i)
45
+ expect(hints[1]).toMatch(/high angle/i)
46
+ expect(hints.some((h) => /profile/i.test(h))).toBe(false)
47
+ })
48
+
49
+ it("ignores empty-string and non-string values", () => {
50
+ const hints = buildFramingHints({
51
+ shotSize: "",
52
+ angle: undefined,
53
+ coverage: null as unknown as string,
54
+ composition: 42 as unknown as string,
55
+ vantage: "front-on",
56
+ })
57
+ expect(hints.length).toBe(1)
58
+ expect(hints[0]).toMatch(/front-on/i)
59
+ })
60
+
61
+ it("FRAMING_FIELD_BY_CATEGORY covers all categories in order", () => {
62
+ for (const cat of FRAMING_CATEGORY_ORDER) {
63
+ expect(FRAMING_FIELD_BY_CATEGORY[cat]).toBeDefined()
64
+ }
65
+ })
66
+
67
+ it("returns empty when an unknown framing id is given (no hint string)", () => {
68
+ const hints = buildFramingHints({ shotSize: "this-is-not-a-real-id" })
69
+ expect(hints).toEqual([])
70
+ })
71
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ FRAMINGS,
4
+ FRAMING_CATEGORY_ORDER,
5
+ FRAMING_CATEGORY_LABELS,
6
+ getFraming,
7
+ isVantageFraming,
8
+ } from "../framing.js"
9
+
10
+ describe("Framing vantage category", () => {
11
+ it("includes vantage in the category order", () => {
12
+ expect(FRAMING_CATEGORY_ORDER).toContain("vantage")
13
+ })
14
+ it("labels vantage correctly", () => {
15
+ expect(FRAMING_CATEGORY_LABELS.vantage).toBe("Vantage")
16
+ })
17
+ it("contains all 6 vantage entries", () => {
18
+ const ids = ["front-on", "three-quarter-front", "profile-left", "profile-right", "three-quarter-back", "behind"]
19
+ for (const id of ids) {
20
+ const entry = getFraming(id)
21
+ expect(entry).toBeDefined()
22
+ expect(entry?.category).toBe("vantage")
23
+ expect(entry?.promptHint.length).toBeGreaterThan(10)
24
+ }
25
+ })
26
+ it("isVantageFraming correctly identifies vantage entries", () => {
27
+ expect(isVantageFraming("front-on")).toBe(true)
28
+ expect(isVantageFraming("profile-left")).toBe(true)
29
+ expect(isVantageFraming("medium-shot")).toBe(false) // shot-size
30
+ expect(isVantageFraming("high-angle")).toBe(false) // angle
31
+ expect(isVantageFraming("rule-of-thirds")).toBe(false) // composition
32
+ expect(isVantageFraming(undefined)).toBe(false)
33
+ })
34
+ it("has at least the core framing entries (vantage + foundational categories)", () => {
35
+ // Initial set was 35 (24 original + 6 vantage + 4 reference-parity + 1 later);
36
+ // catalog has since grown. Use a lower-bound check so this doesn't break on every catalog expansion.
37
+ expect(FRAMINGS.length).toBeGreaterThanOrEqual(35)
38
+ })
39
+ })
@@ -0,0 +1,269 @@
1
+ /**
2
+ * L1#6b — i18n per-ENTRY × locale completeness.
3
+ *
4
+ * The sibling file `i18n-locale-completeness.test.ts` only asserts that the
5
+ * sidecar FILES exist (`<catalog>.<locale>.ts`). It does NOT look inside them.
6
+ * That is how ~660 person / camera-format / render-quality translation strings
7
+ * silently shipped missing: the files were all present, but a batch of catalog
8
+ * entries (e.g. the facial-geometry person dimensions, the ARRI Alexa 65 /
9
+ * Sony Venice camera bodies, the Arnold / V-Ray render engines) had no entry at
10
+ * all in any non-English sidecar — so those pickers fell back to English in 11
11
+ * locales with zero signal.
12
+ *
13
+ * This guard walks every catalog ENTRY (not just every file) and asserts each
14
+ * id is actually translated in every non-English locale. It enumerates from
15
+ * `PICKER_CATALOGS` (`picker-catalogs.ts`) — the pure-data, single-source-of-
16
+ * truth mirror of the frontend picker registry — so the moment a new catalog
17
+ * entry is added (anywhere, by anyone) it is automatically in-scope here; there
18
+ * is no second list to remember to update.
19
+ *
20
+ * ---------------------------------------------------------------------------
21
+ * What "translated" means (derived from the established sidecar convention,
22
+ * documented in `i18n/types.ts`):
23
+ *
24
+ * - `description` is translated FREELY for every entry. Empirically every
25
+ * description-carrying catalog translates the description of EVERY one of
26
+ * its ids in EVERY locale. So:
27
+ * → DESCRIPTION IS REQUIRED for an entry when (a) the canonical English
28
+ * entry has a non-empty description AND (b) the catalog conventionally
29
+ * translates descriptions in that locale (detected per (catalog,locale)
30
+ * as: at least one id in that sidecar carries a description). Condition
31
+ * (b) cleanly exempts the handful of label-only catalogs (loop-subject,
32
+ * seasons) whose sidecars translate the short subject label only and
33
+ * never carry a description — translating their description is simply
34
+ * not part of that catalog's contract.
35
+ *
36
+ * - `label` is translated when it is a common noun/adjective, but LEFT in its
37
+ * canonical English form when it is a brand name, model number, proper noun,
38
+ * technical unit, microtrend tag, loanword, or Latin/Italian/Japanese
39
+ * cinematography jargon (ARRI Alexa 65, V-Ray, Sony A7III, 8K UHD,
40
+ * anamorphic, djembe, goblincore, katana, …). Translators apply this
41
+ * per-ENTRY and per-LOCALE: empirically the label-skip is pervasive and
42
+ * irreducible across the WHOLE corpus — e.g. `djembe`, `chiffon`,
43
+ * `baroque`, `super-8`, `femme-fatale`, the `*-core` aesthetics are each
44
+ * translated in some locales and intentionally left English in others.
45
+ * There is therefore NO clean dataset-wide "label required" invariant; any
46
+ * such rule (even "required where a majority translate it" or "required
47
+ * where all-but-one translate it") false-fails on 1500+ cells of shipped,
48
+ * deliberate data. So this guard does NOT impose a blanket label
49
+ * requirement. The real, universal failure mode the original gap exhibited
50
+ * was a MISSING ENTRY (the id key absent in every sidecar) — Test 1 below
51
+ * catches that for every id in every locale, and a present entry always
52
+ * carries the canonical-English label fallback at runtime. The specific
53
+ * common-noun label work this guard was added for (the 26 facial-geometry
54
+ * person dimensions) is pinned explicitly in Test 3, which also documents
55
+ * the deliberate brand-label-English decision for the camera / render
56
+ * bodies & engines.
57
+ *
58
+ * Net: Test 1 (entry-exists) + Test 2 (description) are TRUE on the entire
59
+ * current dataset (so the suite is green) yet FAIL the instant a new catalog
60
+ * entry ships without an entry or without the description its catalog
61
+ * conventionally carries — exactly the regression that let the original gap
62
+ * through. Test 3 additionally pins the in-scope label coverage.
63
+ * ---------------------------------------------------------------------------
64
+ */
65
+
66
+ import { describe, it, expect } from "vitest"
67
+ import { PICKER_CATALOGS, type PickerOption } from "../picker-catalogs.js"
68
+ import { NON_EN_LOCALE_IDS, type LocaleId, type LocaleCatalogMap } from "@nodaro/shared"
69
+
70
+ const LOCALES = NON_EN_LOCALE_IDS
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Flatten PICKER_CATALOGS → { catalogId → PickerOption[] }.
74
+ // single: top-level `options`. multi: every `dimensions[].options`.
75
+ // De-dupe by id (a few multi catalogs can repeat an id across dimensions; the
76
+ // person catalog does not, but be defensive).
77
+ // ---------------------------------------------------------------------------
78
+
79
+ interface CatalogEntries {
80
+ readonly catalogId: string
81
+ readonly options: readonly PickerOption[]
82
+ }
83
+
84
+ const CATALOGS: readonly CatalogEntries[] = PICKER_CATALOGS.map((c) => {
85
+ const collected: PickerOption[] = []
86
+ const seen = new Set<string>()
87
+ const push = (opts: readonly PickerOption[] | undefined) => {
88
+ for (const o of opts ?? []) {
89
+ if (seen.has(o.id)) continue
90
+ seen.add(o.id)
91
+ collected.push(o)
92
+ }
93
+ }
94
+ if (c.kind === "single") {
95
+ push(c.options)
96
+ } else {
97
+ for (const d of c.dimensions ?? []) push(d.options)
98
+ }
99
+ return { catalogId: c.catalogId, options: collected }
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Eagerly load every sidecar map for every (catalog, locale) so the per-entry
104
+ // `it.each` cases are synchronous. Done once at module init.
105
+ //
106
+ // Loader mirrors `catalog-gapfill.test.ts` (dynamic import of the source .ts).
107
+ // A missing sidecar file is the sibling test's job; here we treat it as an
108
+ // empty map so this test reports per-entry coverage rather than a load crash.
109
+ // ---------------------------------------------------------------------------
110
+
111
+ type LoadedMaps = Map<string, LocaleCatalogMap> // key = `${catalogId}:${locale}`
112
+
113
+ async function loadAllMaps(): Promise<LoadedMaps> {
114
+ const maps: LoadedMaps = new Map()
115
+ await Promise.all(
116
+ CATALOGS.flatMap((c) =>
117
+ LOCALES.map(async (locale) => {
118
+ try {
119
+ const mod = (// i18n sidecars live in @nodaro/shared source (test-only cross-package path)
120
+ await import(`../../../shared/src/i18n/${c.catalogId}.${locale}.ts`)) as {
121
+ default?: LocaleCatalogMap
122
+ }
123
+ maps.set(`${c.catalogId}:${locale}`, mod.default ?? {})
124
+ } catch {
125
+ maps.set(`${c.catalogId}:${locale}`, {})
126
+ }
127
+ }),
128
+ ),
129
+ )
130
+ return maps
131
+ }
132
+
133
+ const MAPS = await loadAllMaps()
134
+
135
+ function mapFor(catalogId: string, locale: LocaleId): LocaleCatalogMap {
136
+ return MAPS.get(`${catalogId}:${locale}`) ?? {}
137
+ }
138
+
139
+ const has = (s: string | undefined): boolean => typeof s === "string" && s.trim().length > 0
140
+
141
+ /**
142
+ * Per (catalog, locale): does this sidecar conventionally translate
143
+ * descriptions at all? True if any entry carries a non-empty description.
144
+ * Label-only catalogs (loop-subject, seasons) return false → their entries are
145
+ * not required to carry a translated description.
146
+ */
147
+ function localeTranslatesDescriptions(catalogId: string, locale: LocaleId): boolean {
148
+ const map = mapFor(catalogId, locale)
149
+ for (const v of Object.values(map)) if (has(v?.description)) return true
150
+ return false
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Sanity: the enumeration source is non-trivial and the locale list is the 11
155
+ // non-English locales.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("i18n per-entry guard — setup sanity", () => {
159
+ it("enumerates a non-trivial number of catalogs and entries", () => {
160
+ expect(CATALOGS.length).toBeGreaterThanOrEqual(30)
161
+ const totalEntries = CATALOGS.reduce((n, c) => n + c.options.length, 0)
162
+ expect(totalEntries).toBeGreaterThan(2000)
163
+ })
164
+
165
+ it("covers exactly the 11 non-English locales", () => {
166
+ expect([...LOCALES].sort()).toEqual(
167
+ ["ar", "de", "es", "fr", "he", "hi", "ja", "ko", "pt-BR", "ru", "zh-CN"].sort(),
168
+ )
169
+ })
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Test 1 — every catalog entry EXISTS in every non-English sidecar.
174
+ // (The worst failure mode: the id key is entirely absent → picker silently
175
+ // English in that locale.)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ describe("every catalog entry has a sidecar translation entry", () => {
179
+ const cases: Array<[string, LocaleId, string]> = []
180
+ for (const c of CATALOGS) {
181
+ for (const locale of LOCALES) {
182
+ for (const o of c.options) cases.push([c.catalogId, locale, o.id])
183
+ }
184
+ }
185
+
186
+ it.each(cases)("%s [%s] entry exists: %s", (catalogId, locale, id) => {
187
+ const entry = mapFor(catalogId, locale)[id]
188
+ expect(
189
+ entry,
190
+ `Missing i18n entry "${id}" in packages/shared/src/i18n/${catalogId}.${locale}.ts. ` +
191
+ `The "${catalogId}" picker silently falls back to English for this option in the "${locale}" locale. ` +
192
+ `Add an entry mirroring its siblings (e.g. ${catalogId}.fr.ts).`,
193
+ ).toBeTruthy()
194
+ })
195
+ })
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Test 2 — DESCRIPTION is translated wherever the catalog convention carries it.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe("description is translated for every entry (where the catalog carries descriptions)", () => {
202
+ const cases: Array<[string, LocaleId, string, boolean]> = []
203
+ for (const c of CATALOGS) {
204
+ for (const locale of LOCALES) {
205
+ // Gate per (catalog, locale): only catalogs whose convention translates
206
+ // descriptions are subject to the requirement.
207
+ const required = localeTranslatesDescriptions(c.catalogId, locale)
208
+ for (const o of c.options) {
209
+ // English entry must itself have a description to require a translation.
210
+ if (!has(o.description)) continue
211
+ cases.push([c.catalogId, locale, o.id, required])
212
+ }
213
+ }
214
+ }
215
+
216
+ it.each(cases)("%s [%s] description: %s", (catalogId, locale, id, required) => {
217
+ if (!required) return // label-only catalog (e.g. loop-subject, seasons)
218
+ const entry = mapFor(catalogId, locale)[id]
219
+ expect(
220
+ has(entry?.description),
221
+ `Missing translated description for "${id}" in packages/shared/src/i18n/${catalogId}.${locale}.ts. ` +
222
+ `This catalog translates descriptions for its other entries in "${locale}", so this one must too ` +
223
+ `(otherwise the picker tooltip falls back to English).`,
224
+ ).toBe(true)
225
+ })
226
+ })
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Test 3 — focused, explicit coverage of the exact gap this guard was added
230
+ // for. Belt-and-suspenders: even if the data-driven gates above ever loosen,
231
+ // these specific entries must carry the specific fields. (Doubles as live
232
+ // documentation of the brand-label decision for camera/render.)
233
+ // ---------------------------------------------------------------------------
234
+
235
+ const PERSON_FACIAL_GEOMETRY_IDS = [
236
+ "cheekbones-average", "cheekbones-low", "cheekbones-high-defined", "cheekbones-sculpted", "cheekbones-wide",
237
+ "facial-fullness-average", "facial-fullness-gaunt", "facial-fullness-lean", "facial-fullness-full", "facial-fullness-round",
238
+ "eye-wide", "eye-narrow", "eyelid-standard", "canthal-neutral", "eye-spacing-average",
239
+ "eyeset-average", "eyeset-low", "eyeset-high",
240
+ "nose-tip-natural", "nose-tip-refined", "nose-tip-upturned", "nose-tip-rounded", "nose-tip-drooping",
241
+ "lips-full-lower", "lips-natural", "lips-heart",
242
+ ] as const
243
+
244
+ // Brand-named camera bodies / render engines: label stays English (matches the
245
+ // sibling convention for arri-alexa, canon-r5, octane-render, redshift, …).
246
+ // Only the description is translated.
247
+ const CAMERA_FORMAT_BRAND_IDS = ["alexa-65", "sony-venice", "blackmagic-pocket-6k", "red-komodo"] as const
248
+ const RENDER_QUALITY_BRAND_IDS = ["arnold-render", "corona-renderer", "vray", "aces"] as const
249
+
250
+ describe("targeted gap coverage: facial-geometry + brand camera/render entries", () => {
251
+ it.each(LOCALES)("person facial-geometry ids carry label + description [%s]", (locale) => {
252
+ const map = mapFor("person", locale)
253
+ for (const id of PERSON_FACIAL_GEOMETRY_IDS) {
254
+ expect(has(map[id]?.label), `person.${locale}: ${id} label`).toBe(true)
255
+ expect(has(map[id]?.description), `person.${locale}: ${id} description`).toBe(true)
256
+ }
257
+ })
258
+
259
+ it.each(LOCALES)("brand camera/render ids carry description, label left English [%s]", (locale) => {
260
+ const cam = mapFor("camera-format", locale)
261
+ for (const id of CAMERA_FORMAT_BRAND_IDS) {
262
+ expect(has(cam[id]?.description), `camera-format.${locale}: ${id} description`).toBe(true)
263
+ }
264
+ const ren = mapFor("render-quality", locale)
265
+ for (const id of RENDER_QUALITY_BRAND_IDS) {
266
+ expect(has(ren[id]?.description), `render-quality.${locale}: ${id} description`).toBe(true)
267
+ }
268
+ })
269
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildIdentityLockLine } from "../identity-lock.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
6
+ id: "c", defaultName: "Victoria Hayes", source: "wired-character", url: "u", ...over,
7
+ })
8
+
9
+ describe("buildIdentityLockLine", () => {
10
+ it("default-OFF for a character with no explicit flag → null", () => {
11
+ expect(buildIdentityLockLine(charRef(), "reference image A")).toBeNull()
12
+ })
13
+
14
+ it("OFF when identityLock.enabled === false → null", () => {
15
+ expect(buildIdentityLockLine(charRef({ identityLock: { enabled: false } }), "reference image A")).toBeNull()
16
+ })
17
+
18
+ it("custom text overrides the built-in, with the binding interpolated", () => {
19
+ const ref = charRef({ identityLock: { enabled: true, text: "Keep {ref} pixel-perfect." } })
20
+ expect(buildIdentityLockLine(ref, "reference image A")).toBe("Keep reference image A pixel-perfect.")
21
+ })
22
+
23
+ it("enabled:true with no text → built-in wording per source (character / face / creature)", () => {
24
+ expect(buildIdentityLockLine(charRef({ identityLock: { enabled: true } }), "reference image A"))
25
+ .toBe("Lock the exact identity of the person in reference image A — face, bone structure, skin tone, and all unique features.")
26
+ expect(buildIdentityLockLine(
27
+ { id: "f", defaultName: "Face", source: "wired-face", url: "u", identityLock: { enabled: true } },
28
+ "reference image A",
29
+ )).toBe("Lock the exact facial identity in reference image A — bone structure, features, and skin texture.")
30
+ expect(buildIdentityLockLine(
31
+ { id: "r", defaultName: "Rex", source: "wired-creature", url: "u", identityLock: { enabled: true } },
32
+ "@image_2",
33
+ )).toBe("Lock the exact identity of the creature in @image_2 — anatomy, markings, and all unique features.")
34
+ })
35
+
36
+ it("default-OFF for object/location/image → null", () => {
37
+ expect(buildIdentityLockLine({ id: "o", defaultName: "Lamp", source: "wired-object", url: "u" }, "reference image A")).toBeNull()
38
+ expect(buildIdentityLockLine({ id: "l", defaultName: "Library", source: "wired-location", url: "u" }, "reference image A")).toBeNull()
39
+ })
40
+
41
+ it("creature with enabled:true uses the creature wording", () => {
42
+ const ref: ConnectedReference = { id: "r", defaultName: "Rex", source: "wired-creature", url: "u", identityLock: { enabled: true } }
43
+ expect(buildIdentityLockLine(ref, "@image_2"))
44
+ .toBe("Lock the exact identity of the creature in @image_2 — anatomy, markings, and all unique features.")
45
+ })
46
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ INSTRUMENTS,
4
+ PRODUCTION_STYLES,
5
+ VOCAL_PRESENCE,
6
+ getInstrument,
7
+ getProductionStyle,
8
+ getVocalPresence,
9
+ buildInstrumentationHints,
10
+ INSTRUMENTATION_DEFAULT_DATA,
11
+ } from "../instrumentation.js"
12
+
13
+ describe("instrumentation catalogs", () => {
14
+ it("INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE are non-empty with unique ids", () => {
15
+ for (const list of [INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE]) {
16
+ expect(list.length).toBeGreaterThan(0)
17
+ const ids = new Set(list.map((x) => x.id))
18
+ expect(ids.size).toBe(list.length)
19
+ }
20
+ })
21
+
22
+ it("VOCAL_PRESENCE includes 'instrumental' so it can flip the boolean for MiniMax", () => {
23
+ expect(VOCAL_PRESENCE.find((x) => x.id === "instrumental")).toBeDefined()
24
+ })
25
+
26
+ it("INSTRUMENTS includes darbuka in percussion", () => {
27
+ const entry = INSTRUMENTS.find((x) => x.id === "darbuka")
28
+ expect(entry).toBeDefined()
29
+ expect(entry?.category).toBe("percussion")
30
+ expect(entry?.promptHint).toBe("darbuka")
31
+ })
32
+ })
33
+
34
+ describe("buildInstrumentationHints", () => {
35
+ it("returns empty for empty data", () => {
36
+ expect(buildInstrumentationHints({ instruments: [] })).toBe("")
37
+ expect(buildInstrumentationHints({})).toBe("")
38
+ })
39
+
40
+ it("composes single instrument", () => {
41
+ const i = INSTRUMENTS[0]
42
+ expect(buildInstrumentationHints({ instruments: [i.id] })).toBe(i.promptHint)
43
+ })
44
+
45
+ it("joins multiple instruments with commas", () => {
46
+ const a = INSTRUMENTS[0], b = INSTRUMENTS[1]
47
+ const out = buildInstrumentationHints({ instruments: [a.id, b.id] })
48
+ expect(out).toContain(a.promptHint)
49
+ expect(out).toContain(b.promptHint)
50
+ expect(out).toContain(",")
51
+ })
52
+
53
+ it("composes [production] [instruments] with [vocalPresence]", () => {
54
+ const p = PRODUCTION_STYLES[0]
55
+ const i = INSTRUMENTS[0]
56
+ const v = VOCAL_PRESENCE.find((x) => x.id === "female-lead")!
57
+ const out = buildInstrumentationHints({
58
+ production: p.id, instruments: [i.id], vocalPresence: v.id,
59
+ })
60
+ expect(out.indexOf(p.promptHint)).toBeLessThan(out.indexOf(i.promptHint))
61
+ expect(out).toContain(v.promptHint)
62
+ })
63
+
64
+ it("filters unknown instrument ids silently", () => {
65
+ const i = INSTRUMENTS[0]
66
+ expect(buildInstrumentationHints({ instruments: [i.id, "not-real"] })).toBe(i.promptHint)
67
+ })
68
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildLightingHints, LIGHTING_FIELD_BY_CATEGORY, LIGHTING_CATEGORY_ORDER } from "../lighting.js"
3
+
4
+ describe("buildLightingHints", () => {
5
+ it("returns empty when nothing is set", () => {
6
+ const hints = buildLightingHints({})
7
+ expect(hints).toEqual([])
8
+ })
9
+
10
+ it("emits one hint per set per-category field, in canonical category order", () => {
11
+ const hints = buildLightingHints({
12
+ timeOfDay: "golden-hour",
13
+ lightingStyle: "rembrandt",
14
+ lightingDirection: "side",
15
+ })
16
+ expect(hints.length).toBe(3)
17
+ // Canonical order: time-of-day, style, direction
18
+ expect(hints[0]).toMatch(/golden hour/i)
19
+ expect(hints[1]).toMatch(/rembrandt/i)
20
+ expect(hints[2]).toMatch(/side lighting/i)
21
+ })
22
+
23
+ it("ignores the legacy lighting field — multi-category is the sole shape", () => {
24
+ // Legacy compat layer was removed: the legacy `lighting` field is no
25
+ // longer read; only per-category fields are considered.
26
+ const hints = buildLightingHints({ lighting: "noon" } as Record<string, unknown>)
27
+ expect(hints).toEqual([])
28
+ })
29
+
30
+ it("ignores empty-string and non-string values", () => {
31
+ const hints = buildLightingHints({
32
+ timeOfDay: "",
33
+ lightingStyle: undefined,
34
+ lightingDirection: "front",
35
+ })
36
+ expect(hints.length).toBe(1)
37
+ expect(hints[0]).toMatch(/front lighting/i)
38
+ })
39
+
40
+ it("LIGHTING_FIELD_BY_CATEGORY covers all categories in order", () => {
41
+ for (const cat of LIGHTING_CATEGORY_ORDER) {
42
+ expect(LIGHTING_FIELD_BY_CATEGORY[cat]).toBeDefined()
43
+ }
44
+ })
45
+
46
+ it("LIGHTING_FIELD_BY_CATEGORY maps each category to the expected field name", () => {
47
+ expect(LIGHTING_FIELD_BY_CATEGORY["time-of-day"]).toBe("timeOfDay")
48
+ expect(LIGHTING_FIELD_BY_CATEGORY.style).toBe("lightingStyle")
49
+ expect(LIGHTING_FIELD_BY_CATEGORY.direction).toBe("lightingDirection")
50
+ })
51
+
52
+ it("returns empty when an unknown lighting id is given (no hint string)", () => {
53
+ const hints = buildLightingHints({ timeOfDay: "this-is-not-a-real-id" })
54
+ expect(hints).toEqual([])
55
+ })
56
+
57
+ it("aggregates all 3 categories with the correct prompt-hint substrings", () => {
58
+ const hints = buildLightingHints({
59
+ timeOfDay: "neon-night",
60
+ lightingStyle: "low-key",
61
+ lightingDirection: "back-rim",
62
+ })
63
+ expect(hints.length).toBe(3)
64
+ expect(hints[0]).toMatch(/neon night/i)
65
+ expect(hints[1]).toMatch(/low-key/i)
66
+ expect(hints[2]).toMatch(/back-light|rim-light/i)
67
+ })
68
+ })