@nodaro/shared 2.10.0 → 2.12.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 (46) hide show
  1. package/dist/index.cjs +142 -8
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +209 -1
  4. package/dist/index.d.ts +209 -1
  5. package/dist/index.js +129 -9
  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__/pack-sidecar-localization.test.ts +21 -0
  11. package/src/__tests__/parameter-node-value.test.ts +18 -1
  12. package/src/__tests__/resolve-pipeline-model.test.ts +4 -4
  13. package/src/__tests__/video-analysis-shot-craft.test.ts +38 -0
  14. package/src/animals.ts +10 -0
  15. package/src/catalog-projection.ts +48 -0
  16. package/src/entity-node-fields.ts +147 -0
  17. package/src/furniture.ts +10 -0
  18. package/src/i18n/framing.ar.ts +0 -1
  19. package/src/i18n/framing.de.ts +0 -1
  20. package/src/i18n/framing.es.ts +0 -1
  21. package/src/i18n/framing.fr.ts +0 -1
  22. package/src/i18n/framing.he.ts +0 -1
  23. package/src/i18n/framing.hi.ts +0 -1
  24. package/src/i18n/framing.ja.ts +0 -1
  25. package/src/i18n/framing.ko.ts +0 -1
  26. package/src/i18n/framing.pt-BR.ts +0 -1
  27. package/src/i18n/framing.ru.ts +0 -1
  28. package/src/i18n/framing.zh-CN.ts +0 -1
  29. package/src/i18n/index.ts +33 -3
  30. package/src/i18n/transitions.ar.ts +6 -0
  31. package/src/i18n/transitions.de.ts +6 -0
  32. package/src/i18n/transitions.es.ts +6 -0
  33. package/src/i18n/transitions.fr.ts +6 -0
  34. package/src/i18n/transitions.he.ts +6 -0
  35. package/src/i18n/transitions.hi.ts +6 -0
  36. package/src/i18n/transitions.ja.ts +6 -0
  37. package/src/i18n/transitions.ko.ts +6 -0
  38. package/src/i18n/transitions.pt-BR.ts +6 -0
  39. package/src/i18n/transitions.ru.ts +6 -0
  40. package/src/i18n/transitions.zh-CN.ts +6 -0
  41. package/src/index.ts +19 -0
  42. package/src/parameter-node-value.ts +23 -3
  43. package/src/producer-types.ts +4 -0
  44. package/src/vehicles.ts +11 -1
  45. package/src/video-analysis.ts +21 -0
  46. package/src/weapons.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.10.0",
3
+ "version": "2.12.1",
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
  )
@@ -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
+ })
@@ -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
  })
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ VIDEO_ANALYSIS_TEXT_KINDS, VIDEO_ANALYSIS_CLIP_TRANSITIONS_IN, VIDEO_ANALYSIS_TRANSITIONS,
4
+ windowAnalysisSchema, videoAnalysisResultSchema,
5
+ } from "../video-analysis.js"
6
+
7
+ const scene = { startSec: 0, endSec: 4, label: "hook", shotType: "Wide", camera: "static", visual: "{slot:a} waits", audio: [] }
8
+ const slot = { slotId: "a", label: "A", source: "wired-character", role: "person", description: "d" }
9
+
10
+ describe("shot-craft fields (keys only)", () => {
11
+ it("adds a text-kind vocabulary and a clip transition-in vocabulary without widening the transition enum", () => {
12
+ expect(VIDEO_ANALYSIS_TEXT_KINDS).toEqual(["title", "caption", "lower-third", "subtitle", "logo", "other"])
13
+ expect(VIDEO_ANALYSIS_CLIP_TRANSITIONS_IN).toEqual(["fade"])
14
+ expect(VIDEO_ANALYSIS_TRANSITIONS).toEqual(["cut", "fade", "dissolve", "wipe", "whip"])
15
+ })
16
+ it("window and result accept transitionIn at the CLIP level and onScreenTextKind on a scene", () => {
17
+ const w = windowAnalysisSchema.parse({ transitionIn: "fade", slots: [slot], scenes: [{ ...scene, onScreenText: "HELLO", onScreenTextKind: "title", transitionOut: "cut" }] })
18
+ expect(w.transitionIn).toBe("fade")
19
+ expect(w.scenes[0]!.onScreenTextKind).toBe("title")
20
+ const r = videoAnalysisResultSchema.parse({
21
+ meta: { durationSec: 4, width: 16, height: 9, aspectRatio: "16:9" }, transitionIn: "fade", slots: [slot],
22
+ scenes: [{ ...scene, sceneNumber: 1, visualResolved: "A waits", slotRefs: ["a"], onScreenTextKind: "subtitle", onScreenText: "hi" }],
23
+ })
24
+ expect(r.transitionIn).toBe("fade")
25
+ })
26
+ it("a pre-change result still parses, and an unknown key is stripped (readers never break)", () => {
27
+ const r = videoAnalysisResultSchema.parse({
28
+ meta: { durationSec: 4, width: 16, height: 9, aspectRatio: "16:9" }, slots: [slot],
29
+ scenes: [{ ...scene, sceneNumber: 1, visualResolved: "A waits", slotRefs: ["a"], futureKey: 1 }],
30
+ })
31
+ expect(r.transitionIn).toBeUndefined()
32
+ expect((r.scenes[0] as Record<string, unknown>).futureKey).toBeUndefined()
33
+ })
34
+ it("rejects a value outside the vocabularies", () => {
35
+ expect(() => windowAnalysisSchema.parse({ transitionIn: "wipe", slots: [], scenes: [] })).toThrow()
36
+ expect(() => windowAnalysisSchema.parse({ slots: [], scenes: [{ ...scene, onScreenTextKind: "banner" }] })).toThrow()
37
+ })
38
+ })
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
+ }
@@ -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
+ }
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> = [
@@ -18,7 +18,6 @@ const map: LocaleCatalogMap = {
18
18
  "macro": { label: "ماكرو", description: "تفصيل قريب جدا لموضوع صغير" },
19
19
  "full-shot": { label: "لقطة كاملة", description: "الجسم بالكامل من الرأس إلى القدم في الإطار" },
20
20
  "cowboy-shot": { label: "لقطة Cowboy", description: "من منتصف الفخذ فأعلى، تأطير غربي كلاسيكي" },
21
- "head-to-knees": { label: "من الرأس للركبتين", description: "من الرأس نزولا للركبتين" },
22
21
  "head-to-hip": { label: "من الرأس للخصر", description: "من الرأس نزولا للوركين" },
23
22
  "half-body": { label: "نصف جسم", description: "بورتريه نظيف من الخصر فأعلى" },
24
23
 
@@ -18,7 +18,6 @@ const map: LocaleCatalogMap = {
18
18
  "macro": { label: "Makro", description: "Extremes Nahdetail eines kleinen Motivs" },
19
19
  "full-shot": { label: "Ganzkörper-Aufnahme", description: "Ganzer Körper von Kopf bis Fuß im Bild" },
20
20
  "cowboy-shot": { label: "Cowboy Shot", description: "Ab Mitte Oberschenkel, klassische Western-Einstellung" },
21
- "head-to-knees": { label: "Kopf bis Knie", description: "Vom Kopf bis zu den Knien" },
22
21
  "head-to-hip": { label: "Kopf bis Hüfte", description: "Vom Kopf bis zur Hüftlinie" },
23
22
  "half-body": { label: "Halbporträt", description: "Sauberes Porträt ab der Hüfte aufwärts" },
24
23
  "eye-level": { label: "Augenhöhe", description: "Kamera auf Augenhöhe des Motivs" },
@@ -18,7 +18,6 @@ const map: LocaleCatalogMap = {
18
18
  "macro": { label: "Macro", description: "Detalle extremamente cercano de un sujeto pequeño" },
19
19
  "full-shot": { label: "Plano Entero", description: "Cuerpo entero de cabeza a pies en el cuadro" },
20
20
  "cowboy-shot": { label: "Plano Vaquero", description: "Desde mitad del muslo hacia arriba, encuadre clásico del Oeste" },
21
- "head-to-knees": { label: "De Cabeza a Rodillas", description: "Desde la cabeza hasta las rodillas" },
22
21
  "head-to-hip": { label: "De Cabeza a Cadera", description: "Desde la cabeza hasta las caderas" },
23
22
  "half-body": { label: "Medio Cuerpo", description: "Retrato limpio de cintura para arriba" },
24
23
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { description: "Détail extrêmement rapproché d'un petit sujet" },
20
20
  "full-shot": { label: "Plan en pied", description: "Corps entier de la tête aux pieds dans le cadre" },
21
21
  "cowboy-shot": { description: "Du milieu des cuisses, cadrage western classique" },
22
- "head-to-knees": { label: "De la tête aux genoux", description: "De la tête jusqu'aux genoux" },
23
22
  "head-to-hip": { label: "De la tête à la hanche", description: "De la tête jusqu'aux hanches" },
24
23
  "half-body": { label: "Demi-corps", description: "Portrait propre à partir de la taille" },
25
24
  // Angle
@@ -18,7 +18,6 @@ const map: LocaleCatalogMap = {
18
18
  "macro": { label: "Macro", description: "פרט קרוב קיצוני של סובייקט קטן" },
19
19
  "full-shot": { label: "Full Shot", description: "כל הגוף מראש עד רגליים בפריים" },
20
20
  "cowboy-shot": { label: "Cowboy Shot", description: "מאמצע הירך ומעלה, מסגור Western קלאסי" },
21
- "head-to-knees": { label: "ראש עד ברכיים", description: "מהראש למטה עד הברכיים" },
22
21
  "head-to-hip": { label: "ראש עד מותן", description: "מהראש למטה עד המותניים" },
23
22
  "half-body": { label: "חצי גוף", description: "פורטרט נקי מהמותניים ומעלה" },
24
23
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { label: "Macro", description: "छोटे subject का अत्यधिक नज़दीकी detail" },
20
20
  "full-shot": { label: "Full Shot", description: "Frame में सिर-से-पैर तक पूरा बदन" },
21
21
  "cowboy-shot": { label: "Cowboy Shot", description: "जाँघ के बीच से ऊपर, क्लासिक Western framing" },
22
- "head-to-knees": { label: "सिर से घुटनों तक", description: "सिर से लेकर घुटनों तक" },
23
22
  "head-to-hip": { label: "सिर से कूल्हे तक", description: "सिर से लेकर कूल्हे तक" },
24
23
  "half-body": { label: "आधा बदन", description: "साफ़ कमर-से-ऊपर portrait" },
25
24
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { label: "マクロ", description: "小さな被写体の極端な拡大" },
20
20
  "full-shot": { label: "フルショット", description: "頭からつま先まで全身がフレーム内に収まる" },
21
21
  "cowboy-shot": { label: "カウボーイショット", description: "太もも中央から上、クラシックな西部劇のフレーミング" },
22
- "head-to-knees": { label: "頭から膝まで", description: "頭から膝までを写す" },
23
22
  "head-to-hip": { label: "頭から腰まで", description: "頭から腰までを写す" },
24
23
  "half-body": { label: "ハーフボディ", description: "クリーンな腰上のポートレート" },
25
24
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { label: "매크로", description: "작은 피사체의 극단적인 클로즈 디테일입니다" },
20
20
  "full-shot": { label: "풀 샷", description: "머리부터 발끝까지 전신이 프레임에 들어갑니다" },
21
21
  "cowboy-shot": { label: "카우보이 샷", description: "허벅지 중간부터 위로, 클래식한 서부극 프레이밍입니다" },
22
- "head-to-knees": { label: "머리부터 무릎까지", description: "머리부터 무릎까지의 프레이밍입니다" },
23
22
  "head-to-hip": { label: "머리부터 엉덩이까지", description: "머리부터 엉덩이까지의 프레이밍입니다" },
24
23
  "half-body": { label: "반신", description: "허리 위의 깔끔한 인물 사진입니다" },
25
24
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { description: "Detalhe extremo de um pequeno sujeito" },
20
20
  "full-shot": { label: "Plano Inteiro", description: "Corpo inteiro da cabeça aos pés no quadro" },
21
21
  "cowboy-shot": { description: "Da metade da coxa para cima, enquadramento clássico de Western" },
22
- "head-to-knees": { label: "Da Cabeça aos Joelhos", description: "Da cabeça até os joelhos" },
23
22
  "head-to-hip": { label: "Da Cabeça ao Quadril", description: "Da cabeça até o quadril" },
24
23
  "half-body": { label: "Meio Corpo", description: "Retrato limpo da cintura para cima" },
25
24
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { label: "Макро", description: "Сверхблизкая деталь маленького объекта" },
20
20
  "full-shot": { label: "Полный план", description: "Всё тело с головы до пят в кадре" },
21
21
  "cowboy-shot": { label: "Ковбойский план", description: "От середины бедра, классическое вестерн-кадрирование" },
22
- "head-to-knees": { label: "Голова до колен", description: "От головы до колен" },
23
22
  "head-to-hip": { label: "Голова до бёдер", description: "От головы до бёдер" },
24
23
  "half-body": { label: "Поясной портрет", description: "Чистый поясной портрет" },
25
24
 
@@ -19,7 +19,6 @@ const map: LocaleCatalogMap = {
19
19
  "macro": { label: "微距", description: "对小型主体的极近距细节" },
20
20
  "full-shot": { label: "全身镜头", description: "全身入画" },
21
21
  "cowboy-shot": { label: "牛仔镜头", description: "大腿中部以上,经典西部片取景" },
22
- "head-to-knees": { label: "头到膝盖", description: "从头部到膝盖" },
23
22
  "head-to-hip": { label: "头到髋", description: "从头部到髋部" },
24
23
  "half-body": { label: "半身像", description: "干净的腰部以上人像" },
25
24
 
package/src/i18n/index.ts CHANGED
@@ -30,6 +30,33 @@ function cacheKey(catalog: I18nCatalogId, locale: LocaleId): string {
30
30
  return `${catalog}:${locale}`
31
31
  }
32
32
 
33
+ /**
34
+ * Pack-sidecar overlay: localized strings contributed by registered catalog
35
+ * packs (e.g. an extension pack's `sidecars.he`), keyed `<catalog>:<locale>`.
36
+ * Consulted by `getLocalizedEntry` when the file sidecar lacks an id. Empty on
37
+ * mainline. Populated by `@nodaro/prompts` at pack registration — the content
38
+ * (the translated strings) lives in the pack (the deployment overlay), never
39
+ * in this Apache package.
40
+ */
41
+ const packSidecars = new Map<string, LocaleCatalogMap>()
42
+
43
+ export function registerCatalogSidecars(
44
+ catalog: string,
45
+ sidecars: Partial<Record<LocaleId, LocaleCatalogMap>> | undefined,
46
+ ): void {
47
+ if (!sidecars) return
48
+ for (const locale of Object.keys(sidecars) as LocaleId[]) {
49
+ const map = sidecars[locale]
50
+ if (!map) continue
51
+ const key = `${catalog}:${locale}`
52
+ packSidecars.set(key, { ...(packSidecars.get(key) ?? {}), ...map })
53
+ }
54
+ }
55
+
56
+ export function resetCatalogSidecars(): void {
57
+ packSidecars.clear()
58
+ }
59
+
33
60
  /**
34
61
  * Lazy-load a sidecar catalog. Returns `null` if the locale is `en` (no
35
62
  * sidecar — English lives in the canonical catalog file) or if no sidecar
@@ -82,9 +109,12 @@ export function getLocalizedEntry(
82
109
  locale: LocaleId,
83
110
  ): LocalizedEntry | undefined {
84
111
  if (locale === "en") return undefined
85
- const map = cache.get(cacheKey(catalog, locale))
86
- if (!map) return undefined
87
- return map[id]
112
+ const key = cacheKey(catalog, locale)
113
+ const fromFile = cache.get(key)?.[id]
114
+ if (fromFile) return fromFile
115
+ // Fall back to a pack-registered sidecar overlay (G10). File sidecars win;
116
+ // pack sidecars cover ids the deployment overlay added. Empty on mainline.
117
+ return packSidecars.get(key)?.[id]
88
118
  }
89
119
 
90
120
  /**
@@ -7,12 +7,15 @@ const map: LocaleCatalogMap = {
7
7
  "cross-dissolve": { label: "تلاشٍ متقاطع", description: "مزج تدريجي بين اللقطتين" },
8
8
  "fade-to-black": { label: "تلاشٍ إلى الأسود", description: "تظلم تدريجي ثم تظهر اللقطة التالية" },
9
9
  "fade-to-white": { label: "تلاشٍ إلى الأبيض", description: "توهج حتى الأبيض ثم تظهر اللقطة" },
10
+ "snap-to-black": { label: "قطع فوري إلى السواد", description: "قطع فوري إلى سواد كامل للحظة، ثم تدخل اللقطة التالية" },
10
11
  "match-cut": { label: "قطع متطابق", description: "تطابق الشكل أو الحركة بين اللقطتين" },
11
12
  "smash-cut": { label: "قطع مفاجئ", description: "قطع مفاجئ صارخ بين لقطتين متباينتين" },
12
13
  "iris": { label: "انتقال القزحية", description: "دائرة تنغلق ثم تنفتح على اللقطة الجديدة" },
13
14
  "wipe": { label: "مسح خطي", description: "خط يجتاح الإطار ليكشف اللقطة الجديدة" },
14
15
  "roll-transition": { label: "دوران", description: "الإطار يدور 90-180 درجة للانتقال" },
15
16
  "seamless-match": { label: "تطابق سلس", description: "قطع خفي يُموَّه بتطابق الحركة واللون" },
17
+ "whip-pan": { label: "بان سريع", description: "الكاميرا تنعطف جانبًا بسرعة مع ضبابية، واللقطة التالية تتابع الاتجاه نفسه" },
18
+ "jump-cut": { label: "قطع قافز", description: "الإطار نفسه، والزمن يقفز إلى الأمام" },
16
19
 
17
20
  // ── Time ──
18
21
  "fast-forward-day-night": { label: "تسريع (نهار ← ليل)", description: "مرور الزمن من النهار إلى الليل في نفس المشهد" },
@@ -62,6 +65,8 @@ const map: LocaleCatalogMap = {
62
65
  "zoom-into-mouth": { label: "تكبير في الفم", description: "الكاميرا تدخل الفم لتظهر في عالم جديد" },
63
66
  "push-through-glass": { label: "اختراق الزجاج", description: "الكاميرا تخترق لوح زجاج لعالم آخر" },
64
67
  "soul-jump": { label: "قفزة الروح", description: "روح شفافة تخرج وتدخل جسداً جديداً" },
68
+ "mask-transition": { label: "انتقال بالقناع", description: "جسم في المقدمة يحجب الإطار، والكاميرا تعبر العتمة إلى المشهد الجديد" },
69
+ "zoom-through": { label: "تقريب عبر التفصيل", description: "الكاميرا تكبّر تفصيلاً واحدًا حتى ينكشف المشهد الجديد داخله" },
65
70
 
66
71
  // ── Physics ──
67
72
  "explosion-blast": { label: "موجة انفجار", description: "انفجار يجتاح الإطار ويكشف المشهد الجديد" },
@@ -73,6 +78,7 @@ const map: LocaleCatalogMap = {
73
78
  "vehicle-explosion": { label: "انفجار مركبة", description: "مركبة تنفجر واللهب يغطي الإطار ثم يُكشف المشهد" },
74
79
  "jump-match": { label: "قفزة متطابقة", description: "الشخص يقفز وعند الهبوط يكون في مشهد جديد" },
75
80
  "hand-swipe": { label: "مسح اليد", description: "يد تمر أمام العدسة ويتغير المشهد" },
81
+ "action-relay": { label: "قطع على الحركة", description: "الشخصية تغادر الإطار وسط حركة وتكملها في المشهد الجديد" },
76
82
 
77
83
  // ── Light ──
78
84
  "white-flash": { label: "وميض أبيض", description: "الإطار يتوهج أبيض ثم يظهر المشهد الجديد" },
@@ -7,12 +7,15 @@ const map: LocaleCatalogMap = {
7
7
  "cross-dissolve": { label: "Überblendung", description: "Allmähliche Mischung zwischen den Einstellungen" },
8
8
  "fade-to-black": { label: "Abblende auf Schwarz", description: "Dunkelt auf Schwarz ab, zweite Einstellung erscheint" },
9
9
  "fade-to-white": { label: "Abblende auf Weiß", description: "Erstrahlt in Weiß, zweite Einstellung erscheint" },
10
+ "snap-to-black": { label: "Harter Schnitt auf Schwarz", description: "Sofortiger Schnitt auf volles Schwarz für einen Moment, dann die nächste Einstellung" },
10
11
  "match-cut": { label: "Match Cut", description: "Form- oder Bewegungsübereinstimmung zwischen Einstellungen" },
11
12
  "smash-cut": { label: "Smash Cut", description: "Abrupter Schnitt zwischen kontrastierenden Einstellungen" },
12
13
  "iris": { label: "Irisblende", description: "Iris schließt und öffnet sich zur zweiten Einstellung" },
13
14
  "wipe": { label: "Wischblende", description: "Linearer Wisch ersetzt die erste Einstellung" },
14
15
  "roll-transition": { label: "Drehübergang", description: "Bild dreht 90-180°, zweite Einstellung am Ende" },
15
16
  "seamless-match": { label: "Nahtloser Schnitt", description: "Versteckter Schnitt durch angeglichene Bewegung und Farbe" },
17
+ "whip-pan": { label: "Whip Pan", description: "Die Kamera reißt seitwärts in Bewegungsunschärfe, die zweite Einstellung folgt derselben Richtung" },
18
+ "jump-cut": { label: "Jump Cut", description: "Gleiche Einstellung, die Zeit springt vorwärts" },
16
19
 
17
20
  // ── Time ──
18
21
  "fast-forward-day-night": { label: "Zeitraffer Tag → Nacht", description: "Zeitraffer von Tag zu Nacht in derselben Szene" },
@@ -62,6 +65,8 @@ const map: LocaleCatalogMap = {
62
65
  "zoom-into-mouth": { label: "Zoom in den Mund", description: "Die Kamera tritt durch den geöffneten Mund in die neue Welt" },
63
66
  "push-through-glass": { label: "Durch das Glas", description: "Die Kamera durchquert das Glas in die neue Welt" },
64
67
  "soul-jump": { label: "Seelensprung", description: "Eine transluzente Seele verlässt den Körper und betritt den neuen" },
68
+ "mask-transition": { label: "Maskenübergang", description: "Ein Objekt im Vordergrund verdunkelt das Bild, die Kamera fährt hindurch" },
69
+ "zoom-through": { label: "Zoom Hindurch", description: "Die Kamera vergrößert ein Detail, bis die neue Szene darin entsteht" },
65
70
 
66
71
  // ── Physics ──
67
72
  "explosion-blast": { label: "Explosionsdruckwelle", description: "Die Explosion fegt den Rahmen frei, neue Szene erscheint" },
@@ -73,6 +78,7 @@ const map: LocaleCatalogMap = {
73
78
  "vehicle-explosion": { label: "Fahrzeugexplosion", description: "Das Fahrzeug explodiert im Vordergrund, die Szene wechselt" },
74
79
  "jump-match": { label: "Sprung-Schnitt", description: "Das Sujet springt, die Landung verbindet mit der neuen Szene" },
75
80
  "hand-swipe": { label: "Hand-Wisch", description: "Eine Hand wischt über das Objektiv, die Szene wechselt dahinter" },
81
+ "action-relay": { label: "Schnitt auf Bewegung", description: "Das Sujet verlässt das Bild in einer Bewegung und setzt sie in der neuen Szene fort" },
76
82
 
77
83
  // ── Light ──
78
84
  "white-flash": { label: "Weißblitz", description: "Der Rahmen erstrahlt in reinem Weiß" },