@nodaro/shared 2.12.2 → 2.14.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.12.2",
3
+ "version": "2.14.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",
@@ -4,6 +4,7 @@ import {
4
4
  extractPresetData,
5
5
  PRESET_EXCLUDED_KEYS,
6
6
  PRESET_APPLY_CLEAR_KEYS,
7
+ presetApplyClearKeys,
7
8
  presetDataMatches,
8
9
  } from "../node-preset-extract.js"
9
10
 
@@ -164,3 +165,61 @@ describe("presetDataMatches", () => {
164
165
  expect(presetDataMatches({ prompt: "a" }, {})).toBe(true)
165
166
  })
166
167
  })
168
+
169
+ describe("presetApplyClearKeys", () => {
170
+ const staticKeys = [...PRESET_APPLY_CLEAR_KEYS]
171
+ const hasAffixes = (keys: readonly string[]) =>
172
+ keys.includes("promptPrefix") && keys.includes("promptSuffix")
173
+
174
+ it("always includes every static clear key, whatever the preset ships", () => {
175
+ for (const data of [
176
+ {},
177
+ { prompt: "a cat" },
178
+ { promptPrefix: "Cinematic 35mm still of" },
179
+ { promptSuffix: ", golden hour" },
180
+ { prompt: "a cat", promptPrefix: "PRE", promptSuffix: "POST" },
181
+ { provider: "flux", aspectRatio: "16:9" },
182
+ ]) {
183
+ const keys = presetApplyClearKeys(data)
184
+ for (const k of staticKeys) expect(keys, `${k} missing for ${JSON.stringify(data)}`).toContain(k)
185
+ }
186
+ })
187
+
188
+ it("owns no prompt content -> does NOT clear the affixes (settings-only preset)", () => {
189
+ expect(hasAffixes(presetApplyClearKeys({}))).toBe(false)
190
+ expect(hasAffixes(presetApplyClearKeys({ provider: "flux", seed: 3 }))).toBe(false)
191
+ // exactly the static set, nothing added
192
+ expect([...presetApplyClearKeys({ provider: "flux" })].sort()).toEqual([...staticKeys].sort())
193
+ })
194
+
195
+ it("ships `prompt` -> clears BOTH affixes (the stale-prefix hazard)", () => {
196
+ expect(hasAffixes(presetApplyClearKeys({ prompt: "a cat" }))).toBe(true)
197
+ })
198
+
199
+ it("ships an affix -> clears both affixes (the other one is then re-set by the spread)", () => {
200
+ expect(hasAffixes(presetApplyClearKeys({ promptPrefix: "PRE" }))).toBe(true)
201
+ expect(hasAffixes(presetApplyClearKeys({ promptSuffix: "POST" }))).toBe(true)
202
+ })
203
+
204
+ it("ships prompt AND affixes -> clears both affixes", () => {
205
+ expect(hasAffixes(presetApplyClearKeys({ prompt: "a cat", promptPrefix: "PRE", promptSuffix: "POST" }))).toBe(true)
206
+ })
207
+
208
+ it("never lists `prompt` itself — it is overwritten only when the preset ships it", () => {
209
+ for (const data of [{}, { prompt: "a cat" }, { promptPrefix: "PRE" }]) {
210
+ expect(presetApplyClearKeys(data)).not.toContain("prompt")
211
+ }
212
+ })
213
+
214
+ it("treats an explicit `undefined` value as NOT owning prompt content", () => {
215
+ expect(hasAffixes(presetApplyClearKeys({ prompt: undefined }))).toBe(false)
216
+ expect(hasAffixes(presetApplyClearKeys({ promptPrefix: undefined }))).toBe(false)
217
+ })
218
+
219
+ it("does not mutate PRESET_APPLY_CLEAR_KEYS", () => {
220
+ presetApplyClearKeys({ prompt: "a cat" })
221
+ expect([...PRESET_APPLY_CLEAR_KEYS].sort()).toEqual([...staticKeys].sort())
222
+ expect(PRESET_APPLY_CLEAR_KEYS).not.toContain("promptPrefix")
223
+ expect(PRESET_APPLY_CLEAR_KEYS).not.toContain("promptSuffix")
224
+ })
225
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY, readPromptAffixes } from "../prompt-affixes.js"
3
+
4
+ describe("prompt-affixes keys", () => {
5
+ it("names the node-data keys", () => {
6
+ expect(PROMPT_PREFIX_KEY).toBe("promptPrefix")
7
+ expect(PROMPT_SUFFIX_KEY).toBe("promptSuffix")
8
+ })
9
+ })
10
+
11
+ describe("readPromptAffixes", () => {
12
+ it("returns both affixes when set", () =>
13
+ expect(readPromptAffixes({ promptPrefix: "PRE", promptSuffix: "POST" })).toEqual({ prefix: "PRE", suffix: "POST" }))
14
+ it("omits blank / non-string values", () =>
15
+ expect(readPromptAffixes({ promptPrefix: " ", promptSuffix: 42 })).toEqual({}))
16
+ it("omits absent keys", () => expect(readPromptAffixes({ prompt: "x" })).toEqual({}))
17
+ it("keeps internal/trailing whitespace of a non-blank affix verbatim", () =>
18
+ expect(readPromptAffixes({ promptPrefix: "RULES:\n\n" })).toEqual({ prefix: "RULES:\n\n" }))
19
+ })
20
+
21
+ /**
22
+ * Presets capture the affixes for free: `extractPresetData` strips only
23
+ * PRESET_EXCLUDED_KEYS + EXECUTION_DATA_KEYS, and the affix keys are in
24
+ * neither. This test pins that contract so a future addition to either
25
+ * exclusion set can't silently drop pre/post text from saved presets.
26
+ */
27
+ import { extractPresetData } from "../node-preset-extract.js"
28
+ describe("presets capture prompt affixes", () => {
29
+ it("extractPresetData keeps promptPrefix/promptSuffix", () =>
30
+ expect(extractPresetData({ prompt: "p", promptPrefix: "PRE", promptSuffix: "POST", label: "x", executionStatus: "running" }))
31
+ .toEqual({ prompt: "p", promptPrefix: "PRE", promptSuffix: "POST" }))
32
+ })
@@ -160,9 +160,11 @@ describe("bare video-analysis node-type credit id", () => {
160
160
  // trued up every tier's judge/refine terms), 355 wrote 2068: the
161
161
  // recast shot-craft Stage-1 doctrine (transitions + on-screen text, three
162
162
  // required wire fields) grew the analyser's system prompt, moving the
163
- // plugin's token pin 8_203 -> 8_482, and 356 wrote 2071: Stage 1.3 grew
164
- // that prompt again, moving the pin 8_482 -> 8_706.
165
- expect(ceiling).toBe(2071)
163
+ // plugin's token pin 8_203 -> 8_482, 356 wrote 2071: Stage 1.3 grew
164
+ // that prompt again, moving the pin 8_482 -> 8_706, and 357 wrote 2076:
165
+ // the transition vocabulary v2 doctrine (twelve named values; absent =
166
+ // nothing asserted) grew it once more, moving the pin 8_706 -> 9_082.
167
+ expect(ceiling).toBe(2076)
166
168
  })
167
169
 
168
170
  it("the bare id still bounds the default tier at the ceiling bucket", () => {
@@ -178,12 +178,12 @@ describe("video-audit-pricing", () => {
178
178
  })
179
179
 
180
180
  it("videoAuditCreditsForBucket: table lookup by resolved bucket + family, snapping a raw duration onto the ladder", () => {
181
- expect(videoAuditCreditsForBucket(60, false)).toBe(214)
182
- expect(videoAuditCreditsForBucket(60, true)).toBe(394)
181
+ expect(videoAuditCreditsForBucket(60, false)).toBe(215)
182
+ expect(videoAuditCreditsForBucket(60, true)).toBe(396)
183
183
  expect(videoAuditCreditsForBucket(180, false)).toBe(290)
184
- expect(videoAuditCreditsForBucket(360, false)).toBe(661)
185
- expect(videoAuditCreditsForBucket(600, false)).toBe(1070)
186
- expect(videoAuditCreditsForBucket(600, true)).toBe(1917)
184
+ expect(videoAuditCreditsForBucket(360, false)).toBe(663)
185
+ expect(videoAuditCreditsForBucket(600, false)).toBe(1073)
186
+ expect(videoAuditCreditsForBucket(600, true)).toBe(1921)
187
187
  // Not just exact ladder values — a raw duration snaps up to its bucket.
188
188
  expect(videoAuditCreditsForBucket(70, false)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:180s"])
189
189
  expect(videoAuditCreditsForBucket(9999, true)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"])
@@ -224,8 +224,8 @@ describe("video-audit-pricing", () => {
224
224
  // 600s bucket here, same convention as VIDEO_ANALYSIS_BUCKET_CREDITS).
225
225
  expect(Object.keys(VIDEO_AUDIT_BUCKET_CREDITS)).toHaveLength(8)
226
226
  // Bare-id values quoted in the task/catalog must equal each family's 600s ceiling.
227
- expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:600s"]).toBe(1070)
228
- expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(1917)
227
+ expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:600s"]).toBe(1073)
228
+ expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(1921)
229
229
  })
230
230
 
231
231
  it("auto family = base family + the gemini-3-flash (legacy fast tier) row at the same bucket, exactly — single-source, never hand-added", () => {
@@ -8,10 +8,16 @@ const scene = { startSec: 0, endSec: 4, label: "hook", shotType: "Wide", camera:
8
8
  const slot = { slotId: "a", label: "A", source: "wired-character", role: "person", description: "d" }
9
9
 
10
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", () => {
11
+ it("adds a text-kind vocabulary and a clip transition-in vocabulary; the transition enum is v2 (rev 1.5)", () => {
12
12
  expect(VIDEO_ANALYSIS_TEXT_KINDS).toEqual(["title", "caption", "lower-third", "subtitle", "logo", "other"])
13
13
  expect(VIDEO_ANALYSIS_CLIP_TRANSITIONS_IN).toEqual(["fade"])
14
- expect(VIDEO_ANALYSIS_TRANSITIONS).toEqual(["cut", "fade", "dissolve", "wipe", "whip"])
14
+ // Stage 1 shipped "keys only, no enum widened" (a lagging client rejects an
15
+ // unknown VALUE). Rev 1.5 (2026-08-30) widens it on purpose — sequenced
16
+ // behind the client bump — so this pin now tracks the v2 vocabulary.
17
+ expect(VIDEO_ANALYSIS_TRANSITIONS).toEqual([
18
+ "cut", "fade", "dissolve", "wipe", "whip",
19
+ "zoom", "slide", "white-flash", "digital-glitch", "morph", "match", "jump",
20
+ ])
15
21
  })
16
22
  it("window and result accept transitionIn at the CLIP level and onScreenTextKind on a scene", () => {
17
23
  const w = windowAnalysisSchema.parse({ transitionIn: "fade", slots: [slot], scenes: [{ ...scene, onScreenText: "HELLO", onScreenTextKind: "title", transitionOut: "cut" }] })
@@ -275,6 +275,18 @@ describe("cinematography fields (angle / speed / onScreenText / look)", () => {
275
275
  }
276
276
  })
277
277
 
278
+ it("vocabulary v2 carries the twelve common edits, and the wire neutral is not a member", () => {
279
+ // rev 1.5 Appendix F: absent = nothing asserted; `none` is wire-local to the
280
+ // analyser's roll schema and must never reach the canonical enum.
281
+ expect([...VIDEO_ANALYSIS_TRANSITIONS]).toEqual([
282
+ "cut", "fade", "dissolve", "wipe", "whip",
283
+ "zoom", "slide", "white-flash", "digital-glitch", "morph", "match", "jump",
284
+ ])
285
+ expect(VIDEO_ANALYSIS_TRANSITIONS).not.toContain("none" as never)
286
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transitionOut: "none" }] }).success).toBe(false)
287
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene }] }).success).toBe(true)
288
+ })
289
+
278
290
  it("marks the viewpoints where no face is visible — auto-cast reads this", () => {
279
291
  // A reference frame shot from behind cannot cast a face, however well framed.
280
292
  expect([...VIDEO_ANALYSIS_FACELESS_ANGLES].sort()).toEqual(["from-behind", "over-the-shoulder"])
@@ -44,5 +44,8 @@ export interface ProjectedCatalog {
44
44
  options?: ProjectedCatalogOption[]
45
45
  /** multi-dim catalogs. */
46
46
  fields?: readonly string[]
47
+ /** Per-field option lists. Multi-dim catalogs always carry these; a
48
+ * single-dim catalog carries them when it has secondary parameter
49
+ * fields beside its main picker (transition position/duration/intensity). */
47
50
  dimensions?: ProjectedCatalogDimension[]
48
51
  }
package/src/extra-refs.ts CHANGED
@@ -38,6 +38,13 @@ import type { ConnectedReference } from "./types.js"
38
38
  * the backend (which sees node data as `Record<string, unknown>`) can pass
39
39
  * raw JSON through `expandExtraRefsToConnectedReferences`. */
40
40
  export interface ExtraRefInput {
41
+ /**
42
+ * Stable id of this extra reference, when the row has one. What a
43
+ * `{ref:<id>}` token in the prompt names; passed through to the shared video
44
+ * resolver's slot map by both the canvas and the orchestrator. Absent → the
45
+ * row still numbers normally but cannot be addressed by id.
46
+ */
47
+ readonly id?: string
41
48
  readonly url?: string
42
49
  readonly description?: string
43
50
  readonly characterSlug?: string
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "بدون", description: "بدون تأثير تركيبي" },
4
5
  "bursting-through-frame": { label: "اختراق الإطار", description: "تمزق ورقي ثلاثي الأبعاد يكسر الإطار" },
5
6
  "breaking-out-of-frame": { label: "الخروج من الإطار", description: "طرف يمتد خارج حدود اللوحة" },
6
7
  "pixel-disintegration": { label: "تفكك بكسلي", description: "الموضوع يتحلل إلى جسيمات" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "شظايا عائمة", description: "أجزاء من الجسم تنجرف بعيدا" },
15
16
  "silhouette-outline": { label: "ظل خارجي", description: "ظل أسود نقي على خلفية مسطحة" },
16
17
  "exploding-particles": { label: "جسيمات منفجرة", description: "الخط الخارجي يتطاير إلى جسيمات" },
17
- "3x3-grid-collage": { label: "كولاج شبكة 3×3", description: "مونتاج تسع وضعيات على ورقة اتصال" },
18
18
 
19
19
  "matte-painting": { label: "رسم Matte", description: "خلفية مرسومة بـ Matte مدمجة مع Live Action" },
20
20
  "double-exposure": { label: "تعريض مزدوج", description: "تعريضان فوتوغرافيان متراكبان مدمجان" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "Keiner", description: "Kein Kompositionseffekt" },
4
5
  "bursting-through-frame": { label: "Durch den Rahmen brechen", description: "3D-Papierriss durchbricht den Rahmen" },
5
6
  "breaking-out-of-frame": { label: "Aus dem Rahmen ausbrechen", description: "Glied erstreckt sich über den Bildrand" },
6
7
  "pixel-disintegration": { label: "Pixel-Zerfall", description: "Motiv löst sich in Partikel auf" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "Schwebende Fragmente", description: "Körper schwebt teilweise davon" },
15
16
  "silhouette-outline": { label: "Silhouetten-Umriss", description: "Saubere schwarze Silhouette auf flachem Hintergrund" },
16
17
  "exploding-particles": { label: "Explodierende Partikel", description: "Umriss zerstreut sich in Partikel" },
17
- "3x3-grid-collage": { label: "3x3-Raster-Collage", description: "Kontaktbogen mit 9 Posen-Montage" },
18
18
  "matte-painting": { label: "Matte Painting", description: "Kompositiertes Matte-Painting-Hintergrund mit Live-Action verbunden" },
19
19
  "double-exposure": { label: "Doppelbelichtung", description: "Zwei geschichtete fotografische Belichtungen verschmolzen" },
20
20
  "multiple-exposure": { label: "Mehrfachbelichtung", description: "Drei oder mehr übereinander gestapelte Belichtungen" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "Ninguno", description: "Sin efecto de composición" },
4
5
  "bursting-through-frame": { label: "Atravesando el Marco", description: "Rasgado de papel 3D rompiendo el marco" },
5
6
  "breaking-out-of-frame": { label: "Rompiendo el Marco", description: "Extremidad se extiende más allá del borde del lienzo" },
6
7
  "pixel-disintegration": { label: "Desintegración de Píxeles", description: "Sujeto disolviéndose en partículas" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "Fragmentos Flotantes", description: "Cuerpo derivando parcialmente" },
15
16
  "silhouette-outline": { label: "Silueta Contorno", description: "Silueta negra limpia sobre fondo plano" },
16
17
  "exploding-particles": { label: "Partículas Explotando", description: "Contorno disipándose en partículas" },
17
- "3x3-grid-collage": { label: "Collage en Cuadrícula 3x3", description: "Montaje tipo hoja de contactos de 9 poses" },
18
18
 
19
19
  // -------------------- Round 2 --------------------
20
20
  "matte-painting": { label: "Matte Painting", description: "Fondo de matte painting compuesto y mezclado con acción real" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "Aucun", description: "Aucun effet de composition" },
4
5
  "bursting-through-frame": { label: "Surgissant à travers le cadre", description: "Déchirure de papier 3D brisant le cadre" },
5
6
  "breaking-out-of-frame": { label: "Sortant du cadre", description: "Membre s'étendant au-delà de la bordure du tableau" },
6
7
  "pixel-disintegration": { label: "Désintégration en pixels", description: "Sujet se dissolvant en particules" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "Fragments flottants", description: "Corps se dispersant partiellement" },
15
16
  "silhouette-outline": { label: "Silhouette en contour", description: "Silhouette noire nette sur fond uni" },
16
17
  "exploding-particles": { label: "Particules explosives", description: "Contour se dispersant en particules" },
17
- "3x3-grid-collage": { label: "Collage en grille 3x3", description: "Montage planche-contact de 9 poses" },
18
18
  "matte-painting": { label: "Matte painting", description: "Fond peint en matte painting fondu avec prise de vue réelle" },
19
19
  "double-exposure": { label: "Double exposition", description: "Deux expositions photographiques superposées et fondues" },
20
20
  "multiple-exposure": { label: "Exposition multiple", description: "Trois expositions ou plus empilées" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "ללא", description: "ללא אפקט קומפוזיציה" },
4
5
  "bursting-through-frame": { label: "פורץ דרך הפריים", description: "קריעת נייר תלת-ממדית הפורצת את הפריים" },
5
6
  "breaking-out-of-frame": { label: "פורץ מחוץ לפריים", description: "גף נמתח מעבר לגבול הקנבס" },
6
7
  "pixel-disintegration": { label: "פירוק לפיקסלים", description: "סובייקט מתפרק לחלקיקים" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "שברים מרחפים", description: "גוף נסחף חלקית" },
15
16
  "silhouette-outline": { label: "מתאר צללית", description: "צללית שחורה נקייה על רקע שטוח" },
16
17
  "exploding-particles": { label: "חלקיקים מתפוצצים", description: "מתאר מתפזר לחלקיקים" },
17
- "3x3-grid-collage": { label: "קולאז' רשת 3x3", description: "מונטז' של 9 פוזות בסגנון contact sheet" },
18
18
  "matte-painting": { label: "Matte Painting", description: "רקע matte-painting מורכב מעורב עם צילום חי" },
19
19
  "double-exposure": { label: "חשיפה כפולה", description: "שתי חשיפות צילום שכבתיות מוזגות" },
20
20
  "multiple-exposure": { label: "חשיפה מרובה", description: "שלוש חשיפות או יותר מוערמות" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "कोई नहीं", description: "कोई composition effect नहीं" },
4
5
  "bursting-through-frame": { label: "Frame से बाहर निकलता हुआ", description: "Frame को तोड़ता 3D paper-tear" },
5
6
  "breaking-out-of-frame": { label: "Frame से बाहर निकलना", description: "अंग canvas की सीमा से बाहर निकलता है" },
6
7
  "pixel-disintegration": { label: "Pixel Disintegration", description: "Subject कणों में घुलता हुआ" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "तैरते टुकड़े", description: "बदन आंशिक रूप से दूर बहता हुआ" },
15
16
  "silhouette-outline": { label: "Silhouette की रेखा", description: "Flat BG पर साफ़ काला silhouette" },
16
17
  "exploding-particles": { label: "विस्फोटक कण", description: "रेखा कणों में बिखर रही है" },
17
- "3x3-grid-collage": { label: "3x3 Grid Collage", description: "Contact-sheet 9-pose montage" },
18
18
 
19
19
  // -------------------- Round 2 --------------------
20
20
  "matte-painting": { label: "Matte Painting", description: "Live action के साथ blended composite matte-painted background" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "なし", description: "構図エフェクトなし" },
4
5
  "bursting-through-frame": { label: "フレームを突き破る", description: "フレームを破る3D紙破り効果" },
5
6
  "breaking-out-of-frame": { label: "フレームから飛び出す", description: "手足がキャンバスの境界を越える" },
6
7
  "pixel-disintegration": { label: "ピクセル崩壊", description: "粒子に溶け込む被写体" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "浮かぶ断片", description: "体が部分的に漂い去る" },
15
16
  "silhouette-outline": { label: "シルエットの輪郭", description: "フラットな背景上の真っ黒なシルエット" },
16
17
  "exploding-particles": { label: "爆発する粒子", description: "粒子に飛散する輪郭" },
17
- "3x3-grid-collage": { label: "3×3グリッド・コラージュ", description: "コンタクトシート風の9ポーズモンタージュ" },
18
18
 
19
19
  // Additional composition effects
20
20
  "matte-painting": { label: "マットペインティング", description: "実写と合成されたマットペイントの背景" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "없음", description: "구도 효과 없음" },
4
5
  "bursting-through-frame": { label: "프레임을 뚫고 나오기", description: "프레임을 찢고 나오는 3D 페이퍼-티어 효과입니다" },
5
6
  "breaking-out-of-frame": { label: "프레임 밖으로 나오기", description: "사지가 캔버스 경계 너머로 뻗어 나옵니다" },
6
7
  "pixel-disintegration": { label: "픽셀 디스인테그레이션", description: "피사체가 입자로 분해되어 흩어집니다" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "플로팅 프래그먼츠", description: "몸이 부분적으로 떠다니며 흩어집니다" },
15
16
  "silhouette-outline": { label: "실루엣 아웃라인", description: "평면 배경 위의 깔끔한 검은 실루엣입니다" },
16
17
  "exploding-particles": { label: "익스플로딩 파티클", description: "윤곽이 입자로 흩뿌려집니다" },
17
- "3x3-grid-collage": { label: "3x3 그리드 콜라주", description: "9 포즈 콘택트 시트 몽타주입니다" },
18
18
 
19
19
  // Additional composition effects
20
20
  "matte-painting": { label: "매트 페인팅", description: "라이브 액션과 합성된 매트 페인팅 배경입니다" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "Nenhum", description: "Sem efeito de composição" },
4
5
  "bursting-through-frame": { label: "Rompendo o Quadro", description: "Rasgo de papel 3D quebrando o quadro" },
5
6
  "breaking-out-of-frame": { label: "Saindo do Quadro", description: "Membro se estende além da borda do quadro" },
6
7
  "pixel-disintegration": { label: "Desintegração em Pixels", description: "Sujeito se dissolvendo em partículas" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "Fragmentos Flutuando", description: "Corpo se afastando parcialmente em pedaços" },
15
16
  "silhouette-outline": { label: "Contorno em Silhueta", description: "Silhueta preta limpa sobre fundo plano" },
16
17
  "exploding-particles": { label: "Partículas Explodindo", description: "Contorno se desfazendo em partículas" },
17
- "3x3-grid-collage": { label: "Colagem em Grade 3x3", description: "Folha-contato com 9 poses" },
18
18
  "matte-painting": { label: "Matte Painting", description: "Fundo composto em matte painting integrado a captação real" },
19
19
  "double-exposure": { label: "Dupla Exposição", description: "Duas exposições fotográficas sobrepostas e fundidas" },
20
20
  "multiple-exposure": { label: "Múltipla Exposição", description: "Três ou mais exposições empilhadas" },
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "Нет", description: "Без композиционного эффекта" },
4
5
  "bursting-through-frame": {
5
6
  label: "Прорыв сквозь рамку",
6
7
  description: "3D-эффект разорванной бумаги, ломающий рамку",
@@ -53,10 +54,6 @@ const map: LocaleCatalogMap = {
53
54
  label: "Взрыв частиц",
54
55
  description: "Контур разлетается на частицы",
55
56
  },
56
- "3x3-grid-collage": {
57
- label: "Коллаж 3×3",
58
- description: "Контактный лист из 9 поз",
59
- },
60
57
  "matte-painting": {
61
58
  label: "Маттовая живопись",
62
59
  description: "Композитный нарисованный матовый фон, смешанный с живой съёмкой",
@@ -1,6 +1,7 @@
1
1
  import type { LocaleCatalogMap } from "./types.js"
2
2
 
3
3
  const map: LocaleCatalogMap = {
4
+ "none": { label: "无", description: "无构图效果" },
4
5
  "bursting-through-frame": { label: "撕纸破框", description: "3D 撕纸效果冲破画框" },
5
6
  "breaking-out-of-frame": { label: "破出画框", description: "肢体伸出画布边缘" },
6
7
  "pixel-disintegration": { label: "像素崩解", description: "主体崩解为颗粒" },
@@ -14,7 +15,6 @@ const map: LocaleCatalogMap = {
14
15
  "floating-fragments": { label: "漂浮碎片", description: "身体部分飘散远去" },
15
16
  "silhouette-outline": { label: "剪影轮廓", description: "纯色背景上的干净黑剪影" },
16
17
  "exploding-particles": { label: "粒子爆裂", description: "轮廓向外炸成颗粒" },
17
- "3x3-grid-collage": { label: "3x3 网格拼贴", description: "9 格姿势接触印样拼贴" },
18
18
  "matte-painting": { label: "接景绘画", description: "接景背景与实拍画面合成" },
19
19
  "double-exposure": { label: "二次曝光", description: "两层摄影曝光叠合" },
20
20
  "multiple-exposure": { label: "多重曝光", description: "三次或更多次曝光叠合" },
package/src/index.ts CHANGED
@@ -912,7 +912,9 @@ export {
912
912
  describeNodeAdjustments,
913
913
  } from "./normalize-node-params.js"
914
914
  export type { NodeParamAdjustment, NormalizedNodes } from "./normalize-node-params.js"
915
- export { extractPresetData, PRESET_EXCLUDED_KEYS, PRESET_APPLY_CLEAR_KEYS, presetDataMatches } from "./node-preset-extract.js"
915
+ export { extractPresetData, PRESET_EXCLUDED_KEYS, PRESET_APPLY_CLEAR_KEYS, presetApplyClearKeys, presetDataMatches } from "./node-preset-extract.js"
916
+ export { PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY, readPromptAffixes } from "./prompt-affixes.js"
917
+ export type { PromptAffixFields, PromptAffixes } from "./prompt-affixes.js"
916
918
 
917
919
  // --- Factory prompt-snippets (reusable inline prompt fragments) ---
918
920
 
@@ -1971,11 +1971,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1971
1971
  description: "Legacy fast-tier analysis model (pre-2026-07). Kept so stored raw-model configs keep running and keep pricing under their own identifier; new fast-tier runs use the current fast model.",
1972
1972
  useCases: ["video-analysis", "shot-list", "fast"],
1973
1973
  pricing: [
1974
- { identifier: "video-analysis:gemini-3-flash", credits: 847, note: "10-min ceiling (no duration given)" },
1975
- { identifier: "video-analysis:gemini-3-flash:60s", credits: 180 },
1974
+ { identifier: "video-analysis:gemini-3-flash", credits: 848, note: "10-min ceiling (no duration given)" },
1975
+ { identifier: "video-analysis:gemini-3-flash:60s", credits: 181 },
1976
1976
  { identifier: "video-analysis:gemini-3-flash:180s", credits: 185 },
1977
1977
  { identifier: "video-analysis:gemini-3-flash:360s", credits: 515 },
1978
- { identifier: "video-analysis:gemini-3-flash:600s", credits: 847, note: "10-min ceiling" },
1978
+ { identifier: "video-analysis:gemini-3-flash:600s", credits: 848, note: "10-min ceiling" },
1979
1979
  ],
1980
1980
  },
1981
1981
  "gemini-3.6-flash-video-analysis": {
@@ -1988,11 +1988,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1988
1988
  description: "Analyze a video into a structured shot list (scenes, camera, audio) — fast, economy tier. Billed per duration bucket.",
1989
1989
  useCases: ["video-analysis", "shot-list", "fast"],
1990
1990
  pricing: [
1991
- { identifier: "video-analysis:gemini-3.6-flash", credits: 990, note: "10-min ceiling (no duration given)" },
1991
+ { identifier: "video-analysis:gemini-3.6-flash", credits: 992, note: "10-min ceiling (no duration given)" },
1992
1992
  { identifier: "video-analysis:gemini-3.6-flash:60s", credits: 204 },
1993
- { identifier: "video-analysis:gemini-3.6-flash:180s", credits: 218 },
1994
- { identifier: "video-analysis:gemini-3.6-flash:360s", credits: 600 },
1995
- { identifier: "video-analysis:gemini-3.6-flash:600s", credits: 990, note: "10-min ceiling" },
1993
+ { identifier: "video-analysis:gemini-3.6-flash:180s", credits: 219 },
1994
+ { identifier: "video-analysis:gemini-3.6-flash:360s", credits: 602 },
1995
+ { identifier: "video-analysis:gemini-3.6-flash:600s", credits: 992, note: "10-min ceiling" },
1996
1996
  ],
1997
1997
  },
1998
1998
  "gemini-3.1-pro-video-analysis": {
@@ -2005,11 +2005,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
2005
2005
  description: "Analyze a video into a structured shot list (scenes, camera, audio) — higher-fidelity, default tier. Billed per duration bucket.",
2006
2006
  useCases: ["video-analysis", "shot-list", "cinematic"],
2007
2007
  pricing: [
2008
- { identifier: "video-analysis:gemini-3.1-pro", credits: 1053, note: "10-min ceiling (no duration given)" },
2008
+ { identifier: "video-analysis:gemini-3.1-pro", credits: 1056, note: "10-min ceiling (no duration given)" },
2009
2009
  { identifier: "video-analysis:gemini-3.1-pro:60s", credits: 216 },
2010
2010
  { identifier: "video-analysis:gemini-3.1-pro:180s", credits: 232 },
2011
- { identifier: "video-analysis:gemini-3.1-pro:360s", credits: 639 },
2012
- { identifier: "video-analysis:gemini-3.1-pro:600s", credits: 1053, note: "10-min ceiling" },
2011
+ { identifier: "video-analysis:gemini-3.1-pro:360s", credits: 640 },
2012
+ { identifier: "video-analysis:gemini-3.1-pro:600s", credits: 1056, note: "10-min ceiling" },
2013
2013
  ],
2014
2014
  },
2015
2015
  // Both mixed tiers are variants of the same advanced multi-engine analysis
@@ -2025,11 +2025,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
2025
2025
  description: "Our most advanced analysis tier — multiple analysis engines combined into one result for maximum completeness and accuracy. Billed per duration bucket.",
2026
2026
  useCases: ["video-analysis", "shot-list", "premium", "most-complete"],
2027
2027
  pricing: [
2028
- { identifier: "video-analysis:mixed", credits: 1174, note: "10-min ceiling (no duration given)" },
2029
- { identifier: "video-analysis:mixed:60s", credits: 269 },
2030
- { identifier: "video-analysis:mixed:180s", credits: 290 },
2031
- { identifier: "video-analysis:mixed:360s", credits: 727 },
2032
- { identifier: "video-analysis:mixed:600s", credits: 1174, note: "10-min ceiling" },
2028
+ { identifier: "video-analysis:mixed", credits: 1177, note: "10-min ceiling (no duration given)" },
2029
+ { identifier: "video-analysis:mixed:60s", credits: 270 },
2030
+ { identifier: "video-analysis:mixed:180s", credits: 291 },
2031
+ { identifier: "video-analysis:mixed:360s", credits: 729 },
2032
+ { identifier: "video-analysis:mixed:600s", credits: 1177, note: "10-min ceiling" },
2033
2033
  ],
2034
2034
  },
2035
2035
  // SMART — the accuracy tier. Since the 2026-08-03 hybrid re-plan it is a
@@ -2047,11 +2047,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
2047
2047
  description: "Highest-accuracy analysis — a hybrid pass that blends a native skeleton read with several donor analysis rolls, then always refines the merged result to find shot boundaries and identify the cast by appearance. Best choice when the shot list will drive regeneration. Billed per duration bucket.",
2048
2048
  useCases: ["video-analysis", "shot-list", "premium", "most-accurate"],
2049
2049
  pricing: [
2050
- { identifier: "video-analysis:smart", credits: 2071, note: "10-min ceiling (no duration given)" },
2051
- { identifier: "video-analysis:smart:60s", credits: 412 },
2052
- { identifier: "video-analysis:smart:180s", credits: 502 },
2053
- { identifier: "video-analysis:smart:360s", credits: 1264 },
2054
- { identifier: "video-analysis:smart:600s", credits: 2071, note: "10-min ceiling" },
2050
+ { identifier: "video-analysis:smart", credits: 2076, note: "10-min ceiling (no duration given)" },
2051
+ { identifier: "video-analysis:smart:60s", credits: 413 },
2052
+ { identifier: "video-analysis:smart:180s", credits: 503 },
2053
+ { identifier: "video-analysis:smart:360s", credits: 1267 },
2054
+ { identifier: "video-analysis:smart:600s", credits: 2076, note: "10-min ceiling" },
2055
2055
  ],
2056
2056
  },
2057
2057
  // ── AI Audit (`video-audit`) ── a SEPARATE node from video-analysis (own
@@ -2075,11 +2075,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
2075
2075
  description: "Re-watches a clip against a wired analysis, applies video-verified corrections under guards, and returns a disclosed report of what changed. Billed per duration bucket.",
2076
2076
  useCases: ["video-audit", "qa", "shot-list", "verification"],
2077
2077
  pricing: [
2078
- { identifier: "video-audit", credits: 1070, note: "10-min ceiling (no duration given)" },
2079
- { identifier: "video-audit:60s", credits: 214 },
2078
+ { identifier: "video-audit", credits: 1073, note: "10-min ceiling (no duration given)" },
2079
+ { identifier: "video-audit:60s", credits: 215 },
2080
2080
  { identifier: "video-audit:180s", credits: 290 },
2081
- { identifier: "video-audit:360s", credits: 661 },
2082
- { identifier: "video-audit:600s", credits: 1070, note: "10-min ceiling" },
2081
+ { identifier: "video-audit:360s", credits: 663 },
2082
+ { identifier: "video-audit:600s", credits: 1073, note: "10-min ceiling" },
2083
2083
  ],
2084
2084
  },
2085
2085
  "video-audit-auto": {
@@ -2091,11 +2091,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
2091
2091
  description: "Same video-verified audit as AI Audit, but auto-runs a fast analysis first when none is wired in, then applies corrections under guards and returns a disclosed report. Billed per duration bucket.",
2092
2092
  useCases: ["video-audit", "qa", "shot-list", "verification", "auto-analysis"],
2093
2093
  pricing: [
2094
- { identifier: "video-audit:auto", credits: 1917, note: "10-min ceiling (no duration given)" },
2095
- { identifier: "video-audit:auto:60s", credits: 394 },
2094
+ { identifier: "video-audit:auto", credits: 1921, note: "10-min ceiling (no duration given)" },
2095
+ { identifier: "video-audit:auto:60s", credits: 396 },
2096
2096
  { identifier: "video-audit:auto:180s", credits: 475 },
2097
- { identifier: "video-audit:auto:360s", credits: 1176 },
2098
- { identifier: "video-audit:auto:600s", credits: 1917, note: "10-min ceiling" },
2097
+ { identifier: "video-audit:auto:360s", credits: 1178 },
2098
+ { identifier: "video-audit:auto:600s", credits: 1921, note: "10-min ceiling" },
2099
2099
  ],
2100
2100
  },
2101
2101
  }
@@ -1,5 +1,6 @@
1
1
  import { EXECUTION_DATA_KEYS } from "./node-runtime-keys.js"
2
2
  import { COMPOSER_PLAN_FIELDS } from "./model-constants.js"
3
+ import { PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY } from "./prompt-affixes.js"
3
4
 
4
5
  /**
5
6
  * Generated-plan state on composer nodes that a preset must NEITHER capture NOR
@@ -26,6 +27,43 @@ import { COMPOSER_PLAN_FIELDS } from "./model-constants.js"
26
27
  */
27
28
  export const PRESET_APPLY_CLEAR_KEYS: readonly string[] = [...COMPOSER_PLAN_FIELDS, "lottieUrl"]
28
29
 
30
+ /**
31
+ * The three keys that carry a preset's PROMPT CONTENT: the prompt itself plus the pre/post text
32
+ * wrapped around it at run time. A preset "owns prompt content" iff its data defines any of them.
33
+ */
34
+ const PROMPT_CONTENT_KEYS: readonly string[] = ["prompt", PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
35
+
36
+ /**
37
+ * The keys applying THIS preset must clear on the node: always `PRESET_APPLY_CLEAR_KEYS`, plus
38
+ * `promptPrefix` / `promptSuffix` when the preset owns prompt content. The dropdown spreads
39
+ * `{ ...clearPatch, ...preset.data }`, so the preset's own affixes (if any) land back on top of
40
+ * the clear — the clear only decides what happens to the keys the preset does NOT set.
41
+ *
42
+ * The hazard this closes: with a STATIC clear set the affixes are named by no patch, so applying
43
+ * preset B (which ships only `prompt`) over a node carrying preset A's `promptPrefix` leaves A's
44
+ * prefix in place, silently wrapping B's prompt. The node then runs text no preset ever authored,
45
+ * and nothing in the UI attributes it — the prefix lives in a collapsed settings section.
46
+ *
47
+ * The rule is ASYMMETRIC on purpose, because the two directions have opposite promises:
48
+ * - A preset that owns prompt content is authoring the whole prompt, affixes included, so any
49
+ * leftover affix from a previous preset is stale — clear both.
50
+ * - A settings-only preset (provider / aspect ratio / seed …) promises to touch nothing about the
51
+ * prompt, so it leaves prompt AND affixes exactly as the user has them.
52
+ * - `prompt` is NEVER cleared, only overwritten when the preset ships it. That is the feature's
53
+ * core promise: apply a preset that ships only affixes and your typed prompt is kept, wrapped.
54
+ *
55
+ * Pure; `PRESET_APPLY_CLEAR_KEYS` itself stays a fixed set (it is also spread into
56
+ * `PRESET_EXCLUDED_KEYS`, and affixes must remain capture-INCLUDED for presets to ship them).
57
+ */
58
+ export function presetApplyClearKeys(
59
+ presetData: Readonly<Record<string, unknown>>,
60
+ ): readonly string[] {
61
+ const ownsPromptContent = PROMPT_CONTENT_KEYS.some((k) => presetData[k] !== undefined)
62
+ return ownsPromptContent
63
+ ? [...PRESET_APPLY_CLEAR_KEYS, PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
64
+ : PRESET_APPLY_CLEAR_KEYS
65
+ }
66
+
29
67
  /**
30
68
  * Config fields that are intentionally NOT part of a preset, because they are not portable
31
69
  * configuration — they identify the node on the canvas, wire it into one specific graph, or point