@nodaro/shared 2.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.10.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
  )
@@ -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
  })
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> = [
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ß" },
@@ -7,12 +7,15 @@ const map: LocaleCatalogMap = {
7
7
  "cross-dissolve": { label: "Disolvencia Cruzada", description: "Mezcla gradual entre planos" },
8
8
  "fade-to-black": { label: "Fundido a Negro", description: "Oscurece a negro, emerge el segundo plano" },
9
9
  "fade-to-white": { label: "Fundido a Blanco", description: "Estalla en blanco, emerge el segundo plano" },
10
+ "snap-to-black": { label: "Corte a Negro", description: "Corte instantáneo a negro por un instante, luego el siguiente plano" },
10
11
  "match-cut": { label: "Corte de Raccord", description: "Coincidencia de forma o movimiento entre planos" },
11
12
  "smash-cut": { label: "Corte Brusco", description: "Corte abrupto entre planos contrastados" },
12
13
  "iris": { label: "Iris", description: "Iris circular cierra y abre en el segundo plano" },
13
14
  "wipe": { label: "Barrido", description: "Barrido lineal reemplaza el primer plano" },
14
15
  "roll-transition": { label: "Rotación", description: "Cuadro gira 90-180°, segundo plano al aterrizar" },
15
16
  "seamless-match": { label: "Corte Invisible", description: "Corte oculto por movimiento y color sincronizados" },
17
+ "whip-pan": { label: "Barrido Rápido", description: "La cámara barre de lado con desenfoque, el siguiente plano sigue esa dirección" },
18
+ "jump-cut": { label: "Corte de Salto", description: "Mismo encuadre, el tiempo salta hacia adelante" },
16
19
 
17
20
  // ── Time ──
18
21
  "fast-forward-day-night": { label: "Time-lapse Día → Noche", description: "Time-lapse de día a noche en la misma escena" },
@@ -62,6 +65,8 @@ const map: LocaleCatalogMap = {
62
65
  "zoom-into-mouth": { label: "Zoom a la Boca", description: "La cámara entra en la boca abierta hacia el nuevo mundo" },
63
66
  "push-through-glass": { label: "Atravesar el Cristal", description: "La cámara atraviesa el cristal hacia el nuevo mundo" },
64
67
  "soul-jump": { label: "Salto del Alma", description: "Un alma translúcida sale del cuerpo y entra en el nuevo" },
68
+ "mask-transition": { label: "Transición de Máscara", description: "Un objeto en primer plano tapa el cuadro y la cámara lo atraviesa" },
69
+ "zoom-through": { label: "Zoom Atravesado", description: "La cámara amplía un detalle hasta que la nueva escena surge dentro" },
65
70
 
66
71
  // ── Physics ──
67
72
  "explosion-blast": { label: "Explosión", description: "Explosión barre el cuadro, emerge la nueva escena" },
@@ -73,6 +78,7 @@ const map: LocaleCatalogMap = {
73
78
  "vehicle-explosion": { label: "Explosión de Vehículo", description: "Vehículo explota en primer plano, la escena cambia" },
74
79
  "jump-match": { label: "Salto Raccord", description: "El sujeto salta, el aterrizaje enlaza con la nueva escena" },
75
80
  "hand-swipe": { label: "Barrido de Mano", description: "Una mano barre la lente, la escena cambia al descubrirse" },
81
+ "action-relay": { label: "Corte por Acción", description: "El sujeto sale de cuadro en una acción y la continúa en la nueva escena" },
76
82
 
77
83
  // ── Light ──
78
84
  "white-flash": { label: "Destello Blanco", description: "El cuadro estalla en blanco puro" },
@@ -7,12 +7,15 @@ const map: LocaleCatalogMap = {
7
7
  "cross-dissolve": { label: "Fondu Enchaîné", description: "Mélange progressif entre les plans" },
8
8
  "fade-to-black": { label: "Fondu au Noir", description: "S'assombrit, le second plan émerge du noir" },
9
9
  "fade-to-white": { label: "Fondu au Blanc", description: "Sature de blanc, le second plan en émerge" },
10
+ "snap-to-black": { label: "Coupe au Noir", description: "Coupe instantanée au noir pendant un temps, puis le plan suivant" },
10
11
  "match-cut": { label: "Coupe sur Raccord", description: "Correspondance de forme ou mouvement entre plans" },
11
12
  "smash-cut": { label: "Coupe Violente", description: "Coupe abrupte entre plans contrastés" },
12
13
  "iris": { label: "Cache Iris", description: "L'iris ferme puis ouvre sur le second plan" },
13
14
  "wipe": { label: "Balayage", description: "Balayage linéaire remplace le premier plan" },
14
15
  "roll-transition": { label: "Rotation", description: "Le cadre pivote 90-180°, second plan à l'arrivée" },
15
16
  "seamless-match": { label: "Coupe Invisible", description: "Coupe masquée par mouvement et couleur synchronisés" },
17
+ "whip-pan": { label: "Filé Panoramique", description: "La caméra file latéralement en flou, le plan suivant garde la direction" },
18
+ "jump-cut": { label: "Jump Cut", description: "Même cadrage, le temps saute en avant" },
16
19
 
17
20
  // ── Time ──
18
21
  "fast-forward-day-night": { label: "Accéléré Jour → Nuit", description: "Accéléré de jour à nuit sur la même scène" },
@@ -62,6 +65,8 @@ const map: LocaleCatalogMap = {
62
65
  "zoom-into-mouth": { label: "Zoom dans la Bouche", description: "La caméra pénètre la bouche ouverte vers le nouveau monde" },
63
66
  "push-through-glass": { label: "Traversée du Verre", description: "La caméra traverse le verre vers le nouveau monde" },
64
67
  "soul-jump": { label: "Saut d'Âme", description: "Une âme translucide quitte le corps et entre dans le nouveau" },
68
+ "mask-transition": { label: "Transition Masquée", description: "Un objet au premier plan masque le cadre, la caméra le traverse" },
69
+ "zoom-through": { label: "Zoom Traversant", description: "La caméra grossit un détail jusqu'à ce que la nouvelle scène s'y déploie" },
65
70
 
66
71
  // ── Physics ──
67
72
  "explosion-blast": { label: "Souffle d'Explosion", description: "L'explosion balaie le cadre, la nouvelle scène émerge" },
@@ -73,6 +78,7 @@ const map: LocaleCatalogMap = {
73
78
  "vehicle-explosion": { label: "Explosion de Véhicule", description: "Le véhicule explose au premier plan, la scène change" },
74
79
  "jump-match": { label: "Raccord de Saut", description: "Le sujet saute, l'atterrissage enchaîne sur la nouvelle scène" },
75
80
  "hand-swipe": { label: "Balayage de Main", description: "Une main balaie l'objectif, la scène change en se dégageant" },
81
+ "action-relay": { label: "Raccord dans le Mouvement", description: "Le sujet sort du cadre sur une action et la poursuit dans la nouvelle scène" },
76
82
 
77
83
  // ── Light ──
78
84
  "white-flash": { label: "Flash Blanc", description: "Le cadre sature en blanc pur" },
@@ -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: "क्रॉस-डिसॉल्व", 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: "फ्रेम सफ़ेद होता है, नया दृश्य उभरता है" },