@nodaro/shared 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/index.cjs +329 -53
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +631 -94
  4. package/dist/index.d.ts +631 -94
  5. package/dist/index.js +297 -53
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/catalog-projection.test.ts +14 -0
  9. package/src/__tests__/default-video-provider.test.ts +1 -1
  10. package/src/__tests__/llm-models.test.ts +3 -2
  11. package/src/__tests__/organizations-types.test.ts +61 -0
  12. package/src/__tests__/pack-sidecar-localization.test.ts +21 -0
  13. package/src/__tests__/parameter-node-value.test.ts +18 -1
  14. package/src/__tests__/producer-types.test.ts +11 -0
  15. package/src/__tests__/prompt-length-limits.test.ts +7 -3
  16. package/src/__tests__/resolve-pipeline-model.test.ts +4 -4
  17. package/src/__tests__/seedance2-continuation-ref.test.ts +2 -3
  18. package/src/__tests__/video-analysis-catalog-sync.test.ts +1 -1
  19. package/src/__tests__/video-analysis-pricing.test.ts +2 -2
  20. package/src/__tests__/video-mode-for-inputs.test.ts +74 -0
  21. package/src/animals.ts +10 -0
  22. package/src/catalog-projection.ts +48 -0
  23. package/src/combine-transitions.ts +38 -0
  24. package/src/credit-estimators/video-utils.ts +1 -1
  25. package/src/entity-node-fields.ts +147 -0
  26. package/src/featured-entities.ts +1 -1
  27. package/src/furniture.ts +10 -0
  28. package/src/i18n/index.ts +33 -3
  29. package/src/i18n/transitions.ar.ts +6 -0
  30. package/src/i18n/transitions.de.ts +6 -0
  31. package/src/i18n/transitions.es.ts +6 -0
  32. package/src/i18n/transitions.fr.ts +6 -0
  33. package/src/i18n/transitions.he.ts +6 -0
  34. package/src/i18n/transitions.hi.ts +6 -0
  35. package/src/i18n/transitions.ja.ts +6 -0
  36. package/src/i18n/transitions.ko.ts +6 -0
  37. package/src/i18n/transitions.pt-BR.ts +6 -0
  38. package/src/i18n/transitions.ru.ts +6 -0
  39. package/src/i18n/transitions.zh-CN.ts +6 -0
  40. package/src/i18n/types.ts +12 -14
  41. package/src/index.ts +30 -1
  42. package/src/llm-models.ts +4 -0
  43. package/src/model-catalog.ts +19 -0
  44. package/src/model-constants.ts +52 -7
  45. package/src/organizations/index.ts +2 -0
  46. package/src/organizations/types.ts +152 -0
  47. package/src/organizations/views.ts +220 -0
  48. package/src/parameter-node-value.ts +23 -3
  49. package/src/producer-types.ts +10 -0
  50. package/src/smart-cut-windows.ts +8 -17
  51. package/src/suno-track-sources.ts +23 -0
  52. package/src/surround.ts +10 -90
  53. package/src/vehicles.ts +11 -1
  54. package/src/video-analysis-pricing.ts +25 -36
  55. package/src/weapons.ts +11 -1
  56. package/src/workflow-export.ts +41 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.8.0",
3
+ "version": "2.11.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,14 @@
1
+ import { describe, it, expectTypeOf } from "vitest"
2
+ import type { ProjectedCatalog } from "../catalog-projection.js"
3
+
4
+ describe("ProjectedCatalog wire shape (Apache boundary)", () => {
5
+ it("is tag-free and policy-free (the deferred CatalogPolicy never crosses the wire)", () => {
6
+ // @ts-expect-error — no `tags` field may exist on the projection shape
7
+ const _t: ProjectedCatalog["tags"] = undefined
8
+ // @ts-expect-error — no policy field may exist either
9
+ const _p: ProjectedCatalog["policy"] = undefined
10
+ void _t
11
+ void _p
12
+ expectTypeOf<ProjectedCatalog>().toHaveProperty("catalogId")
13
+ })
14
+ })
@@ -23,7 +23,7 @@ describe("DEFAULT_VIDEO_PROVIDER", () => {
23
23
  })
24
24
 
25
25
  it("nothing-specified request resolves to the cheapest seeded composite", () => {
26
- const sel = applyDefaultVideoSelection({})
26
+ const sel = applyDefaultVideoSelection<number>({})
27
27
  const id = buildVideoCreditModelIdentifier(
28
28
  sel.provider, sel.duration, undefined, "image-to-video", undefined, undefined, false,
29
29
  )
@@ -392,10 +392,11 @@ describe("LLM_FEATURE_DEFAULTS", () => {
392
392
  "translate",
393
393
  "image-critic",
394
394
  "pick-best-llm",
395
+ "workflow-copilot",
395
396
  ]
396
397
 
397
- it("has entries for all 16 features", () => {
398
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(16)
398
+ it("has entries for all 17 features", () => {
399
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(17)
399
400
  for (const feature of ALL_FEATURES) {
400
401
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
401
402
  }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ ACCESS_LEVELS,
4
+ COLLABORATOR_ROLES,
5
+ MEMBER_STATUSES,
6
+ ORG_ERROR_CODES,
7
+ ORG_KINDS,
8
+ ORG_ROLES,
9
+ OrgSettingsSchema,
10
+ PRESET_SETTING_KEYS,
11
+ PresetSettingsSchema,
12
+ WORKSPACE_ROLES,
13
+ WorkspaceSettingsSchema,
14
+ } from "../organizations/index.js"
15
+
16
+ describe("organizations wire contract — enums", () => {
17
+ it("every enum is non-empty and duplicate-free", () => {
18
+ for (const list of [ORG_KINDS, ORG_ROLES, WORKSPACE_ROLES, MEMBER_STATUSES, COLLABORATOR_ROLES, ACCESS_LEVELS, ORG_ERROR_CODES]) {
19
+ expect(list.length).toBeGreaterThan(0)
20
+ expect(new Set(list).size).toBe(list.length)
21
+ }
22
+ })
23
+
24
+ it("error codes are snake_case identifiers", () => {
25
+ for (const code of ORG_ERROR_CODES) expect(code).toMatch(/^[a-z][a-z_]*[a-z]$/)
26
+ })
27
+ })
28
+
29
+ describe("organizations wire contract — settings request schemas", () => {
30
+ it("the preset shape has exactly the nine resolvable keys", () => {
31
+ expect([...PRESET_SETTING_KEYS].sort()).toEqual(
32
+ [
33
+ "admin_access",
34
+ "collaborators_can_invite",
35
+ "default_workflow_visibility",
36
+ "member_access_to_shared",
37
+ "member_caps_enabled",
38
+ "members_can_create_projects",
39
+ "personal_space_enabled",
40
+ "policy_survives_suspension",
41
+ "workspace_admins_can_invite",
42
+ ].sort(),
43
+ )
44
+ expect(PresetSettingsSchema.safeParse({}).success).toBe(false)
45
+ })
46
+
47
+ it("workspace settings are a partial of the preset; unknown keys are stripped; bad values rejected", () => {
48
+ expect(WorkspaceSettingsSchema.parse({ admin_access: "edit", rogue: 1 })).toEqual({ admin_access: "edit" })
49
+ expect(WorkspaceSettingsSchema.safeParse({}).success).toBe(true)
50
+ expect(WorkspaceSettingsSchema.safeParse({ admin_access: "owner" }).success).toBe(false)
51
+ expect(WorkspaceSettingsSchema.safeParse({ member_caps_enabled: "yes" }).success).toBe(false)
52
+ })
53
+
54
+ it("org settings accept lower-case domains only and string vocabulary overrides", () => {
55
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["school.edu", "sub.school.ac.il"] }).success).toBe(true)
56
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["School.edu"] }).success).toBe(false)
57
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["not a domain"] }).success).toBe(false)
58
+ expect(OrgSettingsSchema.safeParse({ vocabulary_overrides: { workspace: "Cohort" } }).success).toBe(true)
59
+ expect(OrgSettingsSchema.safeParse({ vocabulary_overrides: { workspace: 3 } }).success).toBe(false)
60
+ })
61
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect, afterEach } from "vitest"
2
+ import { registerCatalogSidecars, resetCatalogSidecars, resolveLabel, entryMatchesQuery } from "../index.js"
3
+
4
+ afterEach(() => resetCatalogSidecars())
5
+
6
+ describe("pack sidecars resolve through the shared localizer (G10)", () => {
7
+ it("resolveLabel returns a pack-registered localized label", () => {
8
+ // English fallback before registration
9
+ expect(resolveLabel("person", "attire-x", "Modest Suit", "he")).toBe("Modest Suit")
10
+ registerCatalogSidecars("person", { he: { "attire-x": { label: "חליפה צנועה" } } })
11
+ expect(resolveLabel("person", "attire-x", "Modest Suit", "he")).toBe("חליפה צנועה")
12
+ })
13
+ it("english locale ignores sidecars", () => {
14
+ registerCatalogSidecars("person", { he: { "attire-x": { label: "חליפה צנועה" } } })
15
+ expect(resolveLabel("person", "attire-x", "Modest Suit", "en")).toBe("Modest Suit")
16
+ })
17
+ it("search matches the localized pack label", () => {
18
+ registerCatalogSidecars("person", { he: { "attire-x": { label: "חליפה צנועה" } } })
19
+ expect(entryMatchesQuery("person", "attire-x", "Modest Suit", "d", "he", "חליפה")).toBe(true)
20
+ })
21
+ })
@@ -1,8 +1,9 @@
1
- import { describe, it, expect } from "vitest"
1
+ import { describe, it, expect, afterEach } from "vitest"
2
2
  import {
3
3
  PARAMETER_NODE_TYPES,
4
4
  getParameterValue,
5
5
  } from "../parameter-node-value.js"
6
+ import { setRegisteredPersonPackFields } from "../index.js"
6
7
 
7
8
  describe("PARAMETER_NODE_TYPES", () => {
8
9
  it("includes the existing parameter node types", () => {
@@ -135,3 +136,19 @@ describe("parameter-node-value — furniture", () => {
135
136
  expect(getParameterValue({}, "furniture")).toBeUndefined()
136
137
  })
137
138
  })
139
+
140
+ describe("getParameterValue person pack-dimension fallback (G4)", () => {
141
+ afterEach(() => setRegisteredPersonPackFields([]))
142
+
143
+ it("returns undefined for an unregistered pack field", () => {
144
+ expect(getParameterValue({ sectorAttire: "attire-modest-suit" }, "person")).toBeUndefined()
145
+ })
146
+ it("resolves a registered pack field when no base dimension is set", () => {
147
+ setRegisteredPersonPackFields(["sectorAttire"])
148
+ expect(getParameterValue({ sectorAttire: "attire-modest-suit" }, "person")).toBe("attire-modest-suit")
149
+ })
150
+ it("base dimensions still win over pack fields", () => {
151
+ setRegisteredPersonPackFields(["sectorAttire"])
152
+ expect(getParameterValue({ type: "man", sectorAttire: "attire-modest-suit" }, "person")).toBe("man")
153
+ })
154
+ })
@@ -23,6 +23,17 @@ describe("producer-types", () => {
23
23
  DYNAMIC_PRODUCER_TYPES,
24
24
  } as const
25
25
 
26
+ // still-to-video (still image + audio → MP4 via local FFmpeg) has a video
27
+ // OUTPUT handle — absent from this set, every downstream video input would
28
+ // hard-reject its edges (the "cannot connect the outputs" bug class).
29
+ it("registers still-to-video as a video producer", () => {
30
+ expect(VIDEO_PRODUCER_TYPES.has("still-to-video")).toBe(true)
31
+ })
32
+
33
+ it("registers slideshow as a video producer (still-to-video's N-image companion)", () => {
34
+ expect(VIDEO_PRODUCER_TYPES.has("slideshow")).toBe(true)
35
+ })
36
+
26
37
  it("registers voice-changer-pro as an audio producer (its default output)", () => {
27
38
  expect(AUDIO_PRODUCER_TYPES.has("voice-changer-pro")).toBe(true)
28
39
  })
@@ -102,9 +102,13 @@ describe("per-model prompt length limits", () => {
102
102
  })
103
103
 
104
104
  describe("Suno per-version caps", () => {
105
- it("prompt: 500 non-custom; 3000 V4 custom; 5000 V4.5+/V5 custom", () => {
106
- expect(getMaxSunoPromptChars("V4", false)).toBe(500)
107
- expect(getMaxSunoPromptChars("V5", false)).toBe(500)
105
+ // Non-custom was 500 until 2026-08-19 six times under the provider's
106
+ // documented 3000, and the route truncates rather than rejects, so a
107
+ // 950-char recast score brief reached Suno cut to exactly 500 mid-word.
108
+ it("prompt: 3000 non-custom (all models); 3000 V4 custom; 5000 V4.5+/V5 custom", () => {
109
+ expect(getMaxSunoPromptChars("V4", false)).toBe(3000)
110
+ expect(getMaxSunoPromptChars("V5", false)).toBe(3000)
111
+ expect(getMaxSunoPromptChars("V5_5", false)).toBe(3000)
108
112
  expect(getMaxSunoPromptChars("V4", true)).toBe(3000)
109
113
  expect(getMaxSunoPromptChars("V4_5", true)).toBe(5000)
110
114
  expect(getMaxSunoPromptChars("V5", true)).toBe(5000)
@@ -28,8 +28,8 @@ describe("resolvePipelineModel", () => {
28
28
  })
29
29
 
30
30
  it("returns the global script_llm for the script_llm stage", () => {
31
- const config: Partial<PipelineConfig> = { script_llm: "claude-opus-4-6" }
32
- expect(resolvePipelineModel(config, "script_llm")).toBe("claude-opus-4-6")
31
+ const config: Partial<PipelineConfig> = { script_llm: "claude-opus-4-7" }
32
+ expect(resolvePipelineModel(config, "script_llm")).toBe("claude-opus-4-7")
33
33
  })
34
34
 
35
35
  it("per-stage override beats the matching global field", () => {
@@ -52,12 +52,12 @@ describe("resolvePipelineModel", () => {
52
52
  stage_models: {
53
53
  scene_keyframes_image: "gpt-image",
54
54
  shots_video: "veo3",
55
- script_llm: "claude-opus-4-6",
55
+ script_llm: "claude-opus-4-7",
56
56
  },
57
57
  }
58
58
  expect(resolvePipelineModel(config, "scene_keyframes_image")).toBe("gpt-image")
59
59
  expect(resolvePipelineModel(config, "shots_video")).toBe("veo3")
60
- expect(resolvePipelineModel(config, "script_llm")).toBe("claude-opus-4-6")
60
+ expect(resolvePipelineModel(config, "script_llm")).toBe("claude-opus-4-7")
61
61
  // Entity image stages keep the global pick.
62
62
  expect(resolvePipelineModel(config, "characters_image")).toBe("flux")
63
63
  })
@@ -11,9 +11,8 @@ import {
11
11
  * job dbf95612 — the original 1.0s tails made every generate-video-pro
12
12
  * continuation segment fail deterministically). Everything this platform
13
13
  * sends as a Seedance-2 video reference cuts SEEDANCE_2_CONTINUATION_REF_SEC
14
- * seconds; do NOT "optimize" it back below the floor. The private-plugin
15
- * twin constants (nodaro-cloud-plugins chain.ts TAIL_SEC / bridge-math.ts
16
- * MIN_REF) are guarded by that repo's r2v-ref-floor.test.ts — keep in sync. */
14
+ * seconds; do NOT "optimize" it back below the floor. The plugin repo
15
+ * carries twin constants guarded by its own tests — keep the two in sync. */
17
16
  describe("seedance-2 continuation-reference floor (do not regress)", () => {
18
17
  it("the platform's continuation-ref length clears KIE's r2v minimum", () => {
19
18
  expect(SEEDANCE_2_CONTINUATION_REF_SEC).toBeGreaterThanOrEqual(SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC)
@@ -157,7 +157,7 @@ describe("bare video-analysis node-type credit id", () => {
157
157
  // 24 and the schedule regenerated ~47% lower at the ceiling bucket, and 300
158
158
  // wrote 2064: the V1 hybrid-smart reprice (task A3) moved `smart` to a
159
159
  // multi-roll plan (native skeleton + donor rolls, always refined) and
160
- // trued up every tier's judge/refine terms from staging measurement).
160
+ // trued up every tier's judge/refine terms).
161
161
  expect(ceiling).toBe(2064)
162
162
  })
163
163
 
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  // The measured-rate constants and the $-derived `videoAnalysisBucketCredits`
17
17
  // formula are PRIVATE, in @nodaroai/cloud-plugins
18
- // (src/plugins/video-analysis/cost.ts) — its tests (the worked-example
18
+ // (in the plugin repo) — its tests (the worked-example
19
19
  // bucket-credit values and the cross-check against VIDEO_ANALYSIS_BUCKET_CREDITS
20
20
  // below) live in that private package's __tests__/cost.test.ts. This file
21
21
  // covers only the NON-monetary duration-bucketing, window-batching, and
@@ -121,7 +121,7 @@ describe("video-analysis-pricing", () => {
121
121
  })
122
122
 
123
123
  // Full drift-detection against the live $-formula lives in the PRIVATE
124
- // plugin repo (src/plugins/video-analysis/__tests__/cost.test.ts) — the
124
+ // plugin repo — the
125
125
  // formula moved there in 2026-07 and the app-side test was deleted with it,
126
126
  // so nothing in THIS repo can recompute these numbers. This is a lightweight
127
127
  // shape check that the precomputed table covers every legal id.
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ VIDEO_MODE_ALIASES,
4
+ VIDEO_REF_LIMITS_BY_PROVIDER,
5
+ resolveVideoModeForInputs,
6
+ resolveVideoProviderForMode,
7
+ } from "../model-constants.js"
8
+
9
+ /**
10
+ * #861 — reference images wired to Grok Imagine 1 without a start frame were
11
+ * silently dropped: the run resolved to text-to-video, the t2v twin (`grok`)
12
+ * has no image parameter, and nothing said so. The mode must follow what the
13
+ * model can CARRY, derived from the catalog's reference caps — not a name.
14
+ */
15
+ const I2V = "image-to-video"
16
+ const T2V = "text-to-video"
17
+
18
+ describe("resolveVideoModeForInputs", () => {
19
+ it("a start frame is image-to-video for every provider", () => {
20
+ for (const id of ["grok-i2v", "grok", "seedance-2", "kling", "veo3", "wan-i2v", undefined]) {
21
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: true, hasImageRefs: false })).toBe(I2V)
22
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: true, hasImageRefs: true })).toBe(I2V)
23
+ }
24
+ })
25
+
26
+ it("nothing wired is text-to-video", () => {
27
+ expect(resolveVideoModeForInputs("grok-i2v", { hasStartFrame: false, hasImageRefs: false })).toBe(T2V)
28
+ expect(resolveVideoModeForInputs(undefined, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
29
+ })
30
+
31
+ it("refs alone route Grok Imagine 1 to its i2v twin — the only one that can carry them (#861)", () => {
32
+ // Whichever member id the node stored (base/i2v/t2v all appear in old workflows).
33
+ for (const id of ["grok-i2v", "grok"]) {
34
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(I2V)
35
+ }
36
+ // The premise, pinned: i2v carries refs, t2v does not.
37
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok-i2v"]?.images).toBeGreaterThan(0)
38
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok"]).toBeUndefined()
39
+ })
40
+
41
+ it("single-id models are untouched: the same id serves both modes, so there is nothing to re-route", () => {
42
+ // seedance-2 / gemini-omni-video / veo forward refs on their t2v path
43
+ // (generate-video.md mode table). kling-3-omni is i2v-only and keeps its
44
+ // documented orchestrator behavior (image_required on the t2v path) —
45
+ // whether refs alone should satisfy it is a separate question from #861.
46
+ for (const id of ["seedance-2", "gemini-omni-video", "veo3.1", "kling-3-omni"]) {
47
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
48
+ }
49
+ })
50
+
51
+ it("a split-id model with no ref support on either twin is unaffected (wan, happyhorse)", () => {
52
+ for (const id of ["wan-i2v", "wan-2.7-i2v", "happyhorse-i2v"]) {
53
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
54
+ }
55
+ })
56
+
57
+ it("every alias group is classified — a new split-id model lands in one of the two pinned outcomes", () => {
58
+ // Explicit per-group expectations (not a re-derivation of the formula):
59
+ // grok is the one group whose twins differ on ref support today.
60
+ const expectedByBase: Record<string, "image-to-video" | "text-to-video"> = {
61
+ "grok-i2v": I2V,
62
+ "wan-i2v": T2V,
63
+ "wan-2.7-i2v": T2V,
64
+ "happyhorse-i2v": T2V,
65
+ }
66
+ expect(VIDEO_MODE_ALIASES.map((g) => g.base).sort()).toEqual(Object.keys(expectedByBase).sort())
67
+ for (const g of VIDEO_MODE_ALIASES) {
68
+ const expected = expectedByBase[g.base]!
69
+ expect(resolveVideoModeForInputs(g.base, { hasStartFrame: false, hasImageRefs: true }), g.base).toBe(expected)
70
+ // The mode it picks resolves to the twin that can actually run it.
71
+ if (expected === I2V) expect(resolveVideoProviderForMode(g.base, expected)).toBe(g.i2v)
72
+ }
73
+ })
74
+ })
package/src/animals.ts CHANGED
@@ -30,6 +30,16 @@ export interface Animal {
30
30
  readonly label: string
31
31
  readonly subcategory: AnimalSubcategory
32
32
  readonly description: string
33
+ /**
34
+ * Optional authored compact term — the short phrase a professional would
35
+ * write in a prompt, as opposed to the user-facing `label` (see the `term`
36
+ * convention in `@nodaro/prompts`'s `term.ts`). Authored ONLY where the
37
+ * lowercased label is not that phrase — a UI compound naming two things at
38
+ * once ("Airship / Dirigible" -> "airship", "Plasma Sword / Lightsaber" ->
39
+ * "plasma sword"). Everywhere else the lowercased label IS the term for a
40
+ * concrete object ("golden retriever", "katana"), so nothing is authored.
41
+ */
42
+ readonly term?: string
33
43
  }
34
44
 
35
45
  export const ANIMALS: ReadonlyArray<Animal> = [
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tag-free, policy-free wire shape for the `GET /v1/catalogs` projection — the
3
+ * server-driven, pack-composed catalog view thin clients render their own
4
+ * pickers from. This is the ONLY catalog-related type in `@nodaro/shared`
5
+ * (Apache): catalog DATA stays in `@nodaro/prompts` (FSL), and the deferred
6
+ * `CatalogPolicy` (tags / deny-by-tag / per-read-kind filter) is deliberately
7
+ * NOT represented here — nothing tag- or policy-shaped may cross this boundary.
8
+ */
9
+ export interface ProjectedCatalogOption {
10
+ id: string
11
+ label: string
12
+ description?: string
13
+ category?: string
14
+ /** The prompt fragment this id injects downstream. Present only when detail="full". */
15
+ promptHint?: string
16
+ /**
17
+ * Short professional term injected by compact hint mode; `label` is for
18
+ * display. Present at BOTH detail levels — a thin client renders `label`
19
+ * and injects `term`. Empty for a no-op ("auto"/"none") entry that injects
20
+ * nothing.
21
+ */
22
+ term?: string
23
+ icon?: string
24
+ }
25
+
26
+ export interface ProjectedCatalogDimension {
27
+ field: string
28
+ label: string
29
+ options: ProjectedCatalogOption[]
30
+ }
31
+
32
+ export interface ProjectedCatalog {
33
+ nodeType: string
34
+ label: string
35
+ catalogId: string
36
+ kind: "single" | "multi"
37
+ /** single only — the node-data field the chosen id writes to. */
38
+ valueField?: string
39
+ defaultValue?: string
40
+ categoryOrder?: readonly string[]
41
+ categoryLabels?: Readonly<Record<string, string>>
42
+ detail: "compact" | "full"
43
+ /** single-dim catalogs. */
44
+ options?: ProjectedCatalogOption[]
45
+ /** multi-dim catalogs. */
46
+ fields?: readonly string[]
47
+ dimensions?: ProjectedCatalogDimension[]
48
+ }
@@ -480,3 +480,41 @@ export function resolveXfadeName(id: string): string | null {
480
480
  }
481
481
  return entry.xfade
482
482
  }
483
+
484
+ /**
485
+ * Map a `transition` PARAMETER-NODE pick (the @nodaro/prompts creative
486
+ * catalog — prose-hint transitions for AI video prompts) onto the ffmpeg
487
+ * xfade vocabulary above. The two vocabularies are deliberately different
488
+ * (the picker describes shots, this table describes blends); only the
489
+ * visually-faithful subset maps — everything else falls back to `cut`.
490
+ *
491
+ * Consumed by the slideshow node (its transition TYPE comes from a wired
492
+ * transition parameter node; unwired defaults to cut). Kept here — not in
493
+ * @nodaro/prompts — because the dependency points prompts → shared, and the
494
+ * ids are plain strings on both sides.
495
+ */
496
+ export const PICKER_TO_COMBINE_TRANSITION: Readonly<Record<string, string>> = {
497
+ "none": "cut",
498
+ "auto": "dissolve", // an explicitly wired "auto" wants A blend — the classic slideshow default
499
+ "cross-dissolve": "dissolve",
500
+ "fade-to-black": "dip-to-black",
501
+ "fade-to-white": "dip-to-white",
502
+ "iris": "circle-open",
503
+ "wipe": "wipe-left",
504
+ "pixelate-reform": "pixelize",
505
+ "dissolve-to-mist": "hblur",
506
+ "match-cut": "cut",
507
+ "smash-cut": "cut",
508
+ "freeze-frame-jump": "cut",
509
+ }
510
+
511
+ /**
512
+ * Resolve ANY transition string a slideshow may receive — a combine id
513
+ * (already valid), a picker id (mapped), or anything else (→ cut).
514
+ */
515
+ export function resolveSlideshowTransition(value: string | undefined | null): string {
516
+ if (!value) return "cut"
517
+ if (COMBINE_TRANSITION_IDS.includes(value)) return value
518
+ return PICKER_TO_COMBINE_TRANSITION[value] ?? "cut"
519
+ }
520
+
@@ -155,7 +155,7 @@ export function estimateLoopTrimAddonCredits(
155
155
  Math.ceil(frames / VIDEO_UTIL_PRICING.FRAMES_PER_CREDIT)
156
156
  }
157
157
 
158
- /** BASE credits (at-cost) for assemble-narrated-video: 3 flat + 1 per 6
158
+ /** BASE credits for assemble-narrated-video: 3 flat + 1 per 6
159
159
  * blocks. 6→4, 24→7, 60→13. Single source of truth shared by the backend
160
160
  * route/creditGuard (`backend/src/providers/video/narrated-block-fit.ts`
161
161
  * re-exports this) and the frontend pre-run estimate
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Which DB columns of a saved entity land on its canvas node, per kind.
3
+ *
4
+ * Four surfaces copy an entity row onto a node: the browser's load-time
5
+ * hydrator, the browser's library picker, the backend's run-time hydration, and
6
+ * (indirectly) anything that reads a node expecting those fields to be there.
7
+ * They used to be four hand-written lists, and they drifted exactly as you would
8
+ * expect — the load-time hydrator covers `character` and nothing else, so an
9
+ * object node bound by an agent stays media-less until someone rebinds it by
10
+ * hand.
11
+ *
12
+ * This is the field NAMES only — the structural vocabulary. Merge behaviour
13
+ * (defaults, `prev` fallbacks, type narrowing) stays with each caller, because
14
+ * a browser node and a server row disagree about nulls and it is not worth
15
+ * pretending otherwise.
16
+ */
17
+
18
+ /**
19
+ * Every entity kind, in the order surfaces present them.
20
+ *
21
+ * The list is the invariant: `Record<EntityNodeKind, …>` makes the compiler
22
+ * find every table below, every per-kind UI row in the `@` picker, and the
23
+ * picker’s kind→query map. What the compiler cannot see — that the MCP read
24
+ * tools for a kind exist and are reachable — is pinned by a test instead.
25
+ *
26
+ * Tool names are derivable from it too: `list_<kind>s` / `get_<kind>`.
27
+ */
28
+ export const ENTITY_NODE_KINDS = ["character", "object", "creature", "location"] as const
29
+
30
+ export type EntityNodeKind = (typeof ENTITY_NODE_KINDS)[number]
31
+
32
+ /** The `data.*DbId` field that binds each entity node to its row. */
33
+ export const ENTITY_DB_ID_FIELD: Record<EntityNodeKind, string> = {
34
+ character: "characterDbId",
35
+ object: "objectDbId",
36
+ creature: "creatureDbId",
37
+ location: "locationDbId",
38
+ }
39
+
40
+ /** The `data.*Name` field each kind stores its display name under. */
41
+ export const ENTITY_NAME_FIELD: Record<EntityNodeKind, string> = {
42
+ character: "characterName",
43
+ object: "objectName",
44
+ creature: "creatureName",
45
+ location: "locationName",
46
+ }
47
+
48
+ /** Postgres table per kind. */
49
+ export const ENTITY_TABLE: Record<EntityNodeKind, string> = {
50
+ character: "characters",
51
+ object: "objects",
52
+ creature: "creatures",
53
+ location: "locations",
54
+ }
55
+
56
+ /**
57
+ * `{name,url}[]` buckets per kind, as `[db_column, nodeField]`.
58
+ *
59
+ * These are what a generation actually consumes — the variant a prompt
60
+ * `@mentions`, the extra references a user attaches. A node missing them is not
61
+ * visibly broken; it just quietly generates the wrong picture.
62
+ */
63
+ export const ENTITY_BUCKET_FIELDS: Record<EntityNodeKind, ReadonlyArray<readonly [string, string]>> = {
64
+ character: [
65
+ ["expressions", "expressions"],
66
+ ["poses", "poses"],
67
+ ["motions", "motions"],
68
+ ["angles", "angles"],
69
+ ["body_angles", "bodyAngles"],
70
+ ["lighting_variations", "lightingVariations"],
71
+ ["outfit_variations", "outfitVariations"],
72
+ ["detail_closeups", "detailCloseups"],
73
+ ["sheets", "sheets"],
74
+ ],
75
+ object: [
76
+ ["angles", "angles"],
77
+ ["materials", "materials"],
78
+ ["variations", "variations"],
79
+ ["motion_clips", "motionClips"],
80
+ ["detail_closeups", "detailCloseups"],
81
+ ["sheets", "sheets"],
82
+ ],
83
+ creature: [
84
+ ["angles", "angles"],
85
+ ["poses", "poses"],
86
+ ["variations", "variations"],
87
+ ["motion_clips", "motionClips"],
88
+ ["detail_closeups", "detailCloseups"],
89
+ ["sheets", "sheets"],
90
+ ],
91
+ location: [
92
+ ["time_of_day", "timeOfDay"],
93
+ ["weather", "weather"],
94
+ ["angles", "angles"],
95
+ ["lighting", "lighting"],
96
+ ["seasons", "seasons"],
97
+ ["atmosphere_motions", "atmosphereMotions"],
98
+ ["detail_closeups", "detailCloseups"],
99
+ ["sheets", "sheets"],
100
+ ],
101
+ }
102
+
103
+ /**
104
+ * Scalars every kind shares, as `[db_column, nodeField]`.
105
+ *
106
+ * `source_image_url` is the load-bearing one: the run engine reads
107
+ * `defaultAssetUrl || sourceImageUrl` and SKIPS the reference entirely when
108
+ * both are empty — no error, no warning, just a generation of the wrong
109
+ * person. A node with an id but no image is the shape an agent produces.
110
+ */
111
+ export const ENTITY_SCALAR_FIELDS: ReadonlyArray<readonly [string, string]> = [
112
+ ["name", "__name"], // routed to the kind's own name field by the caller
113
+ ["description", "description"],
114
+ ["canonical_description", "canonicalDescription"],
115
+ ["source_image_url", "sourceImageUrl"],
116
+ ]
117
+
118
+ /**
119
+ * Scalars only SOME kinds have.
120
+ *
121
+ * `style_lock` looks shared and is not: characters never grew the column,
122
+ * because a character's likeness is the lock. Selecting it from `characters`
123
+ * anyway is a PostgREST error, which the run-time hydrator swallows by design
124
+ * — so the whole kind would quietly stop hydrating and every test that mocks
125
+ * the database would still pass. That is why the column lists are checked
126
+ * against the migrations by `entity-hydration-columns.test.ts`.
127
+ */
128
+ export const ENTITY_KIND_SCALAR_FIELDS: Record<EntityNodeKind, ReadonlyArray<readonly [string, string]>> = {
129
+ character: [],
130
+ object: [["style_lock", "styleLock"]],
131
+ creature: [["style_lock", "styleLock"]],
132
+ location: [["style_lock", "styleLock"]],
133
+ }
134
+
135
+ /** Every DB column a full hydration of `kind` reads. */
136
+ export function entityHydrationColumns(kind: EntityNodeKind): string[] {
137
+ return [
138
+ "id",
139
+ ...entityScalarFields(kind).map(([column]) => column),
140
+ ...ENTITY_BUCKET_FIELDS[kind].map(([column]) => column),
141
+ ]
142
+ }
143
+
144
+ /** Every scalar `kind` actually has, shared plus its own. */
145
+ export function entityScalarFields(kind: EntityNodeKind): ReadonlyArray<readonly [string, string]> {
146
+ return [...ENTITY_SCALAR_FIELDS, ...ENTITY_KIND_SCALAR_FIELDS[kind]]
147
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Featured entity starter catalog (north-star §6 ③ — the "Featured" tab of each
2
+ * Featured entity starter catalog (the "Featured" tab of each
3
3
  * entity Library).
4
4
  *
5
5
  * These are app-provided starter presets: a curated visual description per
package/src/furniture.ts CHANGED
@@ -26,6 +26,16 @@ export interface Furniture {
26
26
  readonly label: string
27
27
  readonly subcategory: FurnitureSubcategory
28
28
  readonly description: string
29
+ /**
30
+ * Optional authored compact term — the short phrase a professional would
31
+ * write in a prompt, as opposed to the user-facing `label` (see the `term`
32
+ * convention in `@nodaro/prompts`'s `term.ts`). Authored ONLY where the
33
+ * lowercased label is not that phrase — a UI compound naming two things at
34
+ * once ("Airship / Dirigible" -> "airship", "Plasma Sword / Lightsaber" ->
35
+ * "plasma sword"). Everywhere else the lowercased label IS the term for a
36
+ * concrete object ("golden retriever", "katana"), so nothing is authored.
37
+ */
38
+ readonly term?: string
29
39
  }
30
40
 
31
41
  export const FURNITURE: ReadonlyArray<Furniture> = [