@nodaro/shared 2.5.0 → 2.7.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.5.0",
3
+ "version": "2.7.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",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, sourceRefKey } from "../entity-image-handle.js"
2
+ import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, AGGREGATE_LANE_SOURCE_TYPES, sourceRefKey } from "../entity-image-handle.js"
3
3
 
4
4
  const ENTITY_REF_HANDLE: Record<string, string> = {
5
5
  character: "characterRef",
@@ -62,3 +62,35 @@ describe("sourceRefKey (handle-scoped ref key — prevents node-id collision)",
62
62
  expect(sourceRefKey("n1", undefined, "character")).toBe("n1")
63
63
  })
64
64
  })
65
+
66
+ describe("resolveEffectiveSourceType (aggregate lane handle → plain producer of that type)", () => {
67
+ const LANE_TO_PRODUCER: Record<string, string> = {
68
+ "out-image": "upload-image",
69
+ "out-video": "upload-video",
70
+ "out-audio": "upload-audio",
71
+ "out-text": "list",
72
+ }
73
+
74
+ it("remaps every Collect / Group lane to the plain producer of its media type", () => {
75
+ for (const agg of AGGREGATE_LANE_SOURCE_TYPES) {
76
+ for (const [lane, producer] of Object.entries(LANE_TO_PRODUCER)) {
77
+ expect(resolveEffectiveSourceType(agg, lane)).toBe(producer)
78
+ }
79
+ }
80
+ })
81
+
82
+ it("leaves a non-lane / missing handle as the raw aggregate type", () => {
83
+ expect(resolveEffectiveSourceType("collect", "out")).toBe("collect")
84
+ expect(resolveEffectiveSourceType("collect", undefined)).toBe("collect")
85
+ expect(resolveEffectiveSourceType("group", null)).toBe("group")
86
+ })
87
+
88
+ it("does not remap lane-shaped handles on non-aggregate producers", () => {
89
+ expect(resolveEffectiveSourceType("generate-image", "out-image")).toBe("generate-image")
90
+ expect(resolveEffectiveSourceType("list", "out-text")).toBe("list")
91
+ })
92
+
93
+ it("AGGREGATE_LANE_SOURCE_TYPES is exactly group + collect", () => {
94
+ expect([...AGGREGATE_LANE_SOURCE_TYPES].sort()).toEqual(["collect", "group"])
95
+ })
96
+ })
@@ -5,7 +5,9 @@ import {
5
5
  groupHandleId,
6
6
  parseGroupHandle,
7
7
  isAggregateableType,
8
+ computeAggregateLanes,
8
9
  AGGREGATEABLE_TYPES,
10
+ type AggregationBuckets,
9
11
  type Member,
10
12
  } from "../group-aggregation.js"
11
13
 
@@ -81,3 +83,40 @@ describe("isAggregateableType", () => {
81
83
  expect(isAggregateableType(undefined)).toBe(false)
82
84
  })
83
85
  })
86
+
87
+ describe("computeAggregateLanes", () => {
88
+ const EMPTY: AggregationBuckets = { text: [], image: [], video: [], audio: [] }
89
+
90
+ it("returns empty when nothing is wired, bucketed, or referenced", () => {
91
+ expect(computeAggregateLanes("c1", [], EMPTY, [])).toEqual([])
92
+ })
93
+
94
+ it("exposes lanes for wired types even with empty buckets (pre-run)", () => {
95
+ expect(computeAggregateLanes("c1", ["image", "image"], EMPTY, [])).toEqual(["image"])
96
+ })
97
+
98
+ it("exposes lanes for bucket contents (post-run)", () => {
99
+ const buckets: AggregationBuckets = { text: ["t"], image: [], video: [], audio: [] }
100
+ expect(computeAggregateLanes("c1", [], buckets, [])).toEqual(["text"])
101
+ })
102
+
103
+ it("keeps a lane alive when an outgoing edge references it", () => {
104
+ const edges = [{ source: "c1", sourceHandle: "out-video" }]
105
+ expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual(["video"])
106
+ })
107
+
108
+ it("ignores outgoing edges of other nodes and non-lane handles", () => {
109
+ const edges = [
110
+ { source: "other", sourceHandle: "out-video" },
111
+ { source: "c1", sourceHandle: "out" },
112
+ { source: "c1", sourceHandle: null },
113
+ ]
114
+ expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual([])
115
+ })
116
+
117
+ it("unions all three sources and orders by AGGREGATEABLE_TYPES", () => {
118
+ const buckets: AggregationBuckets = { text: [], image: [], video: [], audio: ["a"] }
119
+ const edges = [{ source: "c1", sourceHandle: "out-video" }]
120
+ expect(computeAggregateLanes("c1", ["text"], buckets, edges)).toEqual(["text", "video", "audio"])
121
+ })
122
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { inferMusicVideo, videoAnalysisResultSchema, VIDEO_ANALYSIS_TIMES_OF_DAY, VIDEO_ANALYSIS_STORY_JUMPS } from "../video-analysis"
3
+
4
+ /**
5
+ * The inference decides whether a recast takes the original soundtrack AS-IS
6
+ * (no stem separation). Server (recast route mode derivation) and client
7
+ * (prep pricing + the generate-time mode-mismatch guard) both call THIS
8
+ * function, so its exact boundary behaviour is contract, not detail.
9
+ */
10
+
11
+ const music = (content: string) => ({ mode: "music" as const, content })
12
+ const scene = (audio: Array<{ mode: string; content: string }>) => ({ audio })
13
+ const vocalScenes = (n: number) => Array.from({ length: n }, () => scene([music("upbeat pop track with sung lyrics")]))
14
+
15
+ describe("inferMusicVideo", () => {
16
+ it("true when ≥80% of ≥4 scenes carry music and vocals are evidenced", () => {
17
+ expect(inferMusicVideo({ scenes: vocalScenes(5) })).toBe(true)
18
+ // 4 of 5 with music (80% exactly), one vocal layer
19
+ expect(inferMusicVideo({ scenes: [...vocalScenes(4), scene([])] })).toBe(true)
20
+ })
21
+
22
+ it("quoted lyric text inside a music layer is vocal evidence on its own", () => {
23
+ const scenes = Array.from({ length: 4 }, () => scene([music('gentle track, "la la la, choosing you tonight"')]))
24
+ expect(inferMusicVideo({ scenes })).toBe(true)
25
+ })
26
+
27
+ it("false below the coverage bar, the scene floor, or with no vocal evidence", () => {
28
+ expect(inferMusicVideo({ scenes: [...vocalScenes(3), scene([]), scene([])] })).toBe(false) // 60%
29
+ expect(inferMusicVideo({ scenes: vocalScenes(3) })).toBe(false) // < 4 scenes
30
+ const instrumentalOnly = Array.from({ length: 5 }, () => scene([music("sweeping orchestral score")]))
31
+ expect(inferMusicVideo({ scenes: instrumentalOnly })).toBe(false)
32
+ })
33
+
34
+ it("negated vocals ('instrumental', 'no vocals') are not evidence; sfx/speech layers never are", () => {
35
+ const negated = Array.from({ length: 5 }, () => scene([music("instrumental version of the song, no vocals")]))
36
+ expect(inferMusicVideo({ scenes: negated })).toBe(false)
37
+ const speechy = Array.from({ length: 5 }, () => scene([{ mode: "speech", content: "she sings later" }, music("soft bed")]))
38
+ expect(inferMusicVideo({ scenes: speechy })).toBe(false)
39
+ })
40
+
41
+ it("bare 'song'/'music' descriptions are NOT vocal evidence (instrumental beds are described as songs)", () => {
42
+ const bare = Array.from({ length: 5 }, () => scene([music("upbeat pop song under the action")]))
43
+ expect(inferMusicVideo({ scenes: bare })).toBe(false)
44
+ })
45
+
46
+ it("throw-proof on malformed or absent input", () => {
47
+ expect(inferMusicVideo(undefined)).toBe(false)
48
+ expect(inferMusicVideo(null)).toBe(false)
49
+ expect(inferMusicVideo({})).toBe(false)
50
+ expect(inferMusicVideo({ scenes: [{}, { audio: [{}] }, {}, {}] } as never)).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe("chronicle-time fields (2.6.0)", () => {
55
+ const base = {
56
+ startSec: 0, endSec: 2, label: "l", shotType: "Wide", camera: "static",
57
+ visual: "a scene", audio: [], sceneNumber: 1, visualResolved: "a scene", slotRefs: [],
58
+ }
59
+ const result = (sceneOver: Record<string, unknown>) => ({
60
+ meta: { durationSec: 2, width: 10, height: 10, aspectRatio: "1:1" },
61
+ slots: [],
62
+ scenes: [{ ...base, ...sceneOver }],
63
+ })
64
+
65
+ it("are optional (pre-2.6.0 analyses parse unchanged) and enum-validated when present", () => {
66
+ expect(videoAnalysisResultSchema.safeParse(result({})).success).toBe(true)
67
+ expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "dusk", storyJump: "years-later" })).success).toBe(true)
68
+ expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "noon" })).success).toBe(false)
69
+ expect(videoAnalysisResultSchema.safeParse(result({ storyJump: "later" })).success).toBe(false)
70
+ })
71
+
72
+ it("the enums stay congruence-safe for the window decode grammar (no ints, no maxItems)", () => {
73
+ expect(VIDEO_ANALYSIS_TIMES_OF_DAY).toContain("ambiguous")
74
+ expect(VIDEO_ANALYSIS_STORY_JUMPS).toContain("years-later")
75
+ })
76
+ })
@@ -382,10 +382,11 @@ describe("LLM_FEATURE_DEFAULTS", () => {
382
382
  "generate-script",
383
383
  "translate",
384
384
  "image-critic",
385
+ "pick-best-llm",
385
386
  ]
386
387
 
387
- it("has entries for all 15 features", () => {
388
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(15)
388
+ it("has entries for all 16 features", () => {
389
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(16)
389
390
  for (const feature of ALL_FEATURES) {
390
391
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
391
392
  }
@@ -23,10 +23,14 @@ const ID = "seedance-2-5"
23
23
  * api.kie.ai on 2026-08-08, NOT by the published schema — KIE's docs advertise
24
24
  * a wider surface than the proxy actually accepts. These tests pin the probed
25
25
  * reality so a future "the spec sheet says 4K/180s" edit has to re-probe first.
26
+ *
27
+ * Re-probed 2026-08-17 (KIE "Seedance 2.5 now supports 1080P" release):
28
+ * 1080p passes resolution validation; 1440p/2k/4k are still rejected with
29
+ * "not within the range of allowed options", and duration 31 is still refused.
26
30
  */
27
- describe("seedance-2-5 catalog (probe-verified 2026-08-08)", () => {
28
- it("is 480p/720p ONLY — KIE rejects 1080p/4k/2k the same way it rejects a nonsense value", () => {
29
- expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p"])
31
+ describe("seedance-2-5 catalog (probe-verified 2026-08-08, re-probed 2026-08-17)", () => {
32
+ it("is 480p/720p/1080p — KIE still rejects 4k/2k/1440p the same way it rejects a nonsense value", () => {
33
+ expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p", "1080p"])
30
34
  })
31
35
 
32
36
  it("runs 4-30s contiguously — 30 is the probed ceiling (31 was rejected)", () => {
@@ -101,9 +105,13 @@ describe("seedance-2-5 pricing identifiers", () => {
101
105
  }
102
106
  })
103
107
 
108
+ it("prices 1080p at its own tier — a supported resolution must never clamp away", () => {
109
+ expect(build(8, "1080p")).toBe("seedance-2-5:8s:1080p")
110
+ expect(build(30, "1080p")).toBe("seedance-2-5:30s:1080p")
111
+ })
112
+
104
113
  it("clamps an unsupported resolution to the top real tier so the id is always seeded", () => {
105
- expect(build(8, "1080p")).toBe("seedance-2-5:8s:720p")
106
- expect(build(8, "4k")).toBe("seedance-2-5:8s:720p")
114
+ expect(build(8, "4k")).toBe("seedance-2-5:8s:1080p")
107
115
  })
108
116
 
109
117
  it("selects the cheaper -ref ladder when a reference video is wired", () => {
@@ -114,7 +122,7 @@ describe("seedance-2-5 pricing identifiers", () => {
114
122
  it("resolves every catalog duration x resolution x ref-mode to a distinct tier", () => {
115
123
  const seen = new Set<string>()
116
124
  for (const d of MODEL_CATALOG[ID].durations!) {
117
- for (const res of ["480p", "720p"]) {
125
+ for (const res of ["480p", "720p", "1080p"]) {
118
126
  for (const ref of [false, true]) {
119
127
  const identifier = build(d, res, ref)
120
128
  expect(identifier).toBe(`${ID}:${d}s:${res}${ref ? "-ref" : ""}`)
@@ -122,7 +130,7 @@ describe("seedance-2-5 pricing identifiers", () => {
122
130
  }
123
131
  }
124
132
  }
125
- // 27 durations x 2 resolutions x 2 ref-modes, none collapsing onto another.
126
- expect(seen.size).toBe(108)
133
+ // 27 durations x 3 resolutions x 2 ref-modes, none collapsing onto another.
134
+ expect(seen.size).toBe(162)
127
135
  })
128
136
  })
@@ -19,11 +19,41 @@ export const ENTITY_IMAGE_HANDLE_TYPES: ReadonlySet<string> = new Set([
19
19
  "creature",
20
20
  ])
21
21
 
22
+ /**
23
+ * Aggregate node types (Group / Collect) whose source handles are typed LANES
24
+ * — `out-text` / `out-image` / `out-video` / `out-audio` — each emitting that
25
+ * media type. Like an entity's `image` handle, a wire leaving a lane is a plain
26
+ * producer of that lane's type, so it must reach the typed inputs of every
27
+ * consumer (Image Collage, Combine Videos, Merge Lists, prompts, …) exactly as
28
+ * an upload node of that type would. Neither node type is in any producer set
29
+ * itself: the NODE emits nothing, its LANES do.
30
+ */
31
+ export const AGGREGATE_LANE_SOURCE_TYPES: ReadonlySet<string> = new Set(["group", "collect"])
32
+
33
+ /**
34
+ * The plain producer each aggregate lane behaves as. `upload-*` are the
35
+ * canonical single-media producers present in every typed accepts set
36
+ * (IMAGE/VIDEO/AUDIO_PRODUCER_TYPES). `out-text` maps to `list`, not
37
+ * `text-prompt`: the lane is a LIST of texts, so it must reach both prompt
38
+ * inputs (TEXT_PRODUCER_TYPES) and the list consumers (Merge Lists / Sort /
39
+ * Dedup / Selector — LIST_PRODUCER_TYPES), exactly like a List text column.
40
+ * (`list` is also a DYNAMIC producer, so the text lane is admitted at media
41
+ * inputs too — the same latitude a List column already has on canvas.)
42
+ */
43
+ const AGGREGATE_LANE_EFFECTIVE_TYPE: Readonly<Record<string, string>> = {
44
+ "out-image": "upload-image",
45
+ "out-video": "upload-video",
46
+ "out-audio": "upload-audio",
47
+ "out-text": "list",
48
+ }
49
+
22
50
  /**
23
51
  * The effective output TYPE a given source handle emits. Returns the raw node
24
- * type for every `(type, handle)` pair EXCEPT an entity `image` handle, which
25
- * resolves to `"upload-image"` (a plain image producer). Pure — safe for both
26
- * frontend and backend.
52
+ * type for every `(type, handle)` pair EXCEPT:
53
+ * - an entity `image` handle → `"upload-image"` (a plain image producer);
54
+ * - an aggregate (group / collect) lane handle → the plain producer of that
55
+ * lane's media type (see AGGREGATE_LANE_EFFECTIVE_TYPE).
56
+ * Pure — safe for both frontend and backend.
27
57
  */
28
58
  export function resolveEffectiveSourceType(
29
59
  rawSourceType: string | undefined | null,
@@ -32,6 +62,10 @@ export function resolveEffectiveSourceType(
32
62
  if (sourceHandleId === "image" && ENTITY_IMAGE_HANDLE_TYPES.has(rawSourceType ?? "")) {
33
63
  return "upload-image"
34
64
  }
65
+ if (AGGREGATE_LANE_SOURCE_TYPES.has(rawSourceType ?? "")) {
66
+ const effective = AGGREGATE_LANE_EFFECTIVE_TYPE[sourceHandleId ?? ""]
67
+ if (effective) return effective
68
+ }
35
69
  return rawSourceType ?? ""
36
70
  }
37
71
 
@@ -73,3 +73,35 @@ export function buildChildrenByParent<N extends { id: string; parentId?: string
73
73
  export function isCollectInEdge(e: { targetHandle?: string | null }): boolean {
74
74
  return (e.targetHandle ?? COLLECT_IN_HANDLE) === COLLECT_IN_HANDLE
75
75
  }
76
+
77
+ /**
78
+ * The source-pip lanes an aggregate node (Group / Collect) must EXPOSE.
79
+ *
80
+ * Bucket contents alone are not enough: buckets are computed from upstream
81
+ * RESULTS, so before anything ran the node rendered zero source handles — a
82
+ * pre-authored edge out of the node (MCP-written JSON, import, paste) pointed
83
+ * at a handle that did not exist and React Flow silently hid it ("connected
84
+ * in the panel, invisible on canvas"), and there was no pip to drag outward
85
+ * from. A lane therefore exists when ANY of these hold:
86
+ * (a) a wired member / input of that aggregateable type exists (pre-run),
87
+ * (b) the bucket for that type has values (post-run),
88
+ * (c) an existing outgoing edge references the lane's handle — so an edge
89
+ * can never point at a missing handle, whatever state authored it
90
+ * (also covers results being cleared after wiring downstream).
91
+ * Ordered by AGGREGATEABLE_TYPES so the handle layout is stable.
92
+ */
93
+ export function computeAggregateLanes(
94
+ nodeId: string,
95
+ wiredTypes: Iterable<AggregateableType>,
96
+ buckets: AggregationBuckets,
97
+ edges: ReadonlyArray<{ source: string; sourceHandle?: string | null }>,
98
+ ): AggregateableType[] {
99
+ const present = new Set<AggregateableType>(wiredTypes)
100
+ for (const t of presentTypes(buckets)) present.add(t)
101
+ for (const e of edges) {
102
+ if (e.source !== nodeId) continue
103
+ const lane = parseGroupHandle(e.sourceHandle)
104
+ if (lane) present.add(lane)
105
+ }
106
+ return AGGREGATEABLE_TYPES.filter((t) => present.has(t))
107
+ }
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ export {
62
62
  IMAGE_GEN_PROVIDERS,
63
63
  IMAGE_I2I_PROVIDERS,
64
64
  IMAGE_EDIT_PROVIDERS,
65
+ TASK_CHAINED_EDIT_PROVIDERS,
65
66
  IMAGE_TO_VIDEO_PROVIDERS,
66
67
  TEXT_TO_VIDEO_PROVIDERS,
67
68
  VIDEO_GEN_PROVIDERS,
@@ -270,6 +271,7 @@ export {
270
271
  parseGroupHandle,
271
272
  isCollectInEdge,
272
273
  buildChildrenByParent,
274
+ computeAggregateLanes,
273
275
  type AggregateableType,
274
276
  type Member,
275
277
  type AggregationBuckets,
package/src/llm-models.ts CHANGED
@@ -423,6 +423,10 @@ export type LlmFeature =
423
423
  | "generate-script"
424
424
  | "translate"
425
425
  | "image-critic"
426
+ // Choose Best (reduce) — the pick-best-llm strategy's judge. Its own
427
+ // feature (not ai-writer, which it used to piggyback on) so the model
428
+ // default and the tiered credit ids are the strategy's own.
429
+ | "pick-best-llm"
426
430
 
427
431
  /** Engine-dependent LlmFeature for the motion-graphics node (design §8: every credit-id site must branch on engine). */
428
432
  export function motionGraphicsFeature(engine?: string): LlmFeature {
@@ -446,6 +450,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
446
450
  "generate-script": "gemini-3.6-flash",
447
451
  "translate": "gemini-3.6-flash",
448
452
  "image-critic": "claude-sonnet-4.6",
453
+ "pick-best-llm": "claude-sonnet-4.6",
449
454
  }
450
455
 
451
456
  /**
@@ -872,6 +872,47 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
872
872
  useCases: ["upscale"],
873
873
  pricing: [{ identifier: "grok-upscale", credits: 25 }],
874
874
  },
875
+ // Grok Imagine Image 2.0 — t2i plus task-chained region editing. The edit
876
+ // and segment-map endpoints take a PRIOR grok-2 generation's task id (the
877
+ // job's `kieTaskId` output), not an image URL — same contract as
878
+ // grok-upscale (see TASK_CHAINED_EDIT_PROVIDERS in model-constants).
879
+ "grok-2": {
880
+ id: "grok-2",
881
+ kind: "image",
882
+ modes: ["t2i"] as const,
883
+ family: "xAI",
884
+ label: "Grok Imagine 2",
885
+ series: "Grok",
886
+ description:
887
+ "Grok Imagine Image 2.0 — expressive, high-contrast t2i. Generations chain into grok-2-segment (free named region masks) and grok-2-edit (region-targeted edits).",
888
+ useCases: ["stylized", "expressive", "general"],
889
+ aspectRatios: GROK_RATIOS,
890
+ pricing: [{ identifier: "grok-2", credits: 10 }],
891
+ },
892
+ "grok-2-edit": {
893
+ id: "grok-2-edit",
894
+ kind: "image",
895
+ modes: ["edit"] as const,
896
+ family: "xAI",
897
+ label: "Grok Imagine 2 Edit",
898
+ series: "Grok",
899
+ description:
900
+ "Prompt-edit a prior grok-2 generation by task id. Optional mask indexes (from grok-2-segment) restrict the edit to named regions.",
901
+ useCases: ["edit", "region-edit"],
902
+ pricing: [{ identifier: "grok-2-edit", credits: 10 }],
903
+ },
904
+ "grok-2-segment": {
905
+ id: "grok-2-segment",
906
+ kind: "image",
907
+ modes: ["edit"] as const,
908
+ family: "xAI",
909
+ label: "Grok Imagine 2 Segment Map",
910
+ series: "Grok",
911
+ description:
912
+ "FREE semantic segment map of a prior grok-2 generation — named region masks whose indexes feed grok-2-edit's region targeting.",
913
+ useCases: ["segmentation", "region-edit"],
914
+ pricing: [{ identifier: "grok-2-segment", credits: 0, note: "free" }],
915
+ },
875
916
 
876
917
  // ── Utilities ──
877
918
  "recraft-remove-bg": {
@@ -1354,7 +1395,8 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1354
1395
  },
1355
1396
  // Seedance 2.5 — the next Seedance generation, not a tier of the 2.0 ladder.
1356
1397
  // Two levers differ from every 2.0 SKU and drive its own entries throughout:
1357
- // durations run to 30s (2.0 caps at 15s) and there is NO 1080p/4K tier.
1398
+ // durations run to 30s (2.0 caps at 15s) and there is NO 4K tier (1080p
1399
+ // arrived on KIE 2026-08-17 — probe-verified; 4k/2k/1440p still rejected).
1358
1400
  // Reference caps are also wider (30 images / 10 videos / 10 audio) — see
1359
1401
  // SEEDANCE_2_5_REF_LIMITS in model-constants.
1360
1402
  "seedance-2-5": {
@@ -1364,20 +1406,23 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1364
1406
  family: "Bytedance",
1365
1407
  label: "Seedance 2.5",
1366
1408
  series: "Seedance",
1367
- description: "Seedance 2.5 — up to 30s in one shot, native audio, wide multimodal references. 480p/720p.",
1409
+ description: "Seedance 2.5 — up to 30s in one shot, native audio, wide multimodal references. 480p/720p/1080p.",
1368
1410
  useCases: ["premium", "narrative", "long-form"],
1369
1411
  features: ["end-frame", "audio", "reference-image", "video-reference"],
1370
1412
  aspectRatios: VIDEO_RATIOS_SEEDANCE_2,
1371
1413
  durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
1372
- resolutions: ["480p", "720p"],
1414
+ resolutions: ["480p", "720p", "1080p"],
1373
1415
  pricing: [
1374
1416
  { identifier: "seedance-2-5", credits: 1260, note: "default 8s 720p — see :Ns:res variants for exact" },
1375
1417
  { identifier: "seedance-2-5:8s:480p", credits: 560, note: "8s 480p" },
1376
1418
  { identifier: "seedance-2-5:8s:720p", credits: 1260, note: "8s 720p" },
1419
+ { identifier: "seedance-2-5:8s:1080p", credits: 2280, note: "8s 1080p" },
1377
1420
  { identifier: "seedance-2-5:8s:480p-ref", credits: 340, note: "8s 480p with reference video" },
1378
1421
  { identifier: "seedance-2-5:8s:720p-ref", credits: 760, note: "8s 720p with reference video" },
1422
+ { identifier: "seedance-2-5:8s:1080p-ref", credits: 1370, note: "8s 1080p with reference video" },
1379
1423
  { identifier: "seedance-2-5:30s:480p", credits: 2100, note: "30s 480p (max)" },
1380
1424
  { identifier: "seedance-2-5:30s:720p", credits: 4730, note: "30s 720p (max)" },
1425
+ { identifier: "seedance-2-5:30s:1080p", credits: 8550, note: "30s 1080p (max)" },
1381
1426
  ],
1382
1427
  },
1383
1428
 
@@ -558,6 +558,7 @@ export const IMAGE_GEN_PROVIDERS = [
558
558
  "nano-banana-2",
559
559
  "nano-banana-2-lite",
560
560
  "grok",
561
+ "grok-2",
561
562
  "gpt-image",
562
563
  "gpt-image-2",
563
564
  "imagen4",
@@ -623,8 +624,29 @@ export const IMAGE_EDIT_PROVIDERS = [
623
624
  // grok-upscale takes a prior Grok generation's task_id (NOT an image URL) —
624
625
  // see edit-image route for the taskId-vs-imageUrl branching.
625
626
  "grok-upscale",
627
+ // Grok Imagine 2 task-chained ops — same task_id contract as grok-upscale.
628
+ // grok-2-edit: prompt edit of a prior grok-2 generation, optionally region-
629
+ // targeted via mask indexes from grok-2-segment. grok-2-segment: FREE named
630
+ // segment-mask map of a prior grok-2 generation.
631
+ "grok-2-edit",
632
+ "grok-2-segment",
626
633
  ] as const
627
634
 
635
+ /**
636
+ * Edit providers that take a PRIOR KIE Grok generation's task id instead of
637
+ * an image URL. Single source of truth for the taskId-vs-imageUrl branching:
638
+ * the edit-image route requires `taskId` (imageUrl alone is rejected), the
639
+ * worker routes `taskId` into the provider call, and the KIE model config
640
+ * (`imageParam: "task_id"`) places it in the request body. Membership here
641
+ * must match the KIE configs with `imageParam: "task_id"` — guarded by
642
+ * backend/src/routes/__tests__/edit-image.test.ts.
643
+ */
644
+ export const TASK_CHAINED_EDIT_PROVIDERS: ReadonlySet<string> = new Set([
645
+ "grok-upscale",
646
+ "grok-2-edit",
647
+ "grok-2-segment",
648
+ ])
649
+
628
650
  /** Modify image providers (I2I + edit-with-prompt) */
629
651
  export const MODIFY_IMAGE_PROVIDERS = [
630
652
  ...IMAGE_I2I_PROVIDERS,
@@ -1071,6 +1093,7 @@ export const IMAGE_MASK_MODE: Record<ImageGenProvider, ImageMaskMode> = {
1071
1093
  "flux": "composite",
1072
1094
  "flux-flex": "composite",
1073
1095
  "grok": "composite",
1096
+ "grok-2": "composite",
1074
1097
  "imagen4": "composite",
1075
1098
  "imagen4-fast": "composite",
1076
1099
  "imagen4-ultra": "composite",
@@ -184,8 +184,9 @@ const QUALITY_MAP: Record<string, QualityMapping> = {
184
184
  "seedance-2": { field: "resolution", values: { low: "720p", mid: "1080p", high: "1080p" } },
185
185
  "seedance-2-fast": { field: "resolution", values: { low: "480p", mid: "720p", high: "720p" } },
186
186
  "seedance-2-mini": { field: "resolution", values: { low: "480p", mid: "720p", high: "720p" } },
187
- // Seedance 2.5 tops out at 720p on KIE (1080p/4k probe-rejected 2026-08-08).
188
- "seedance-2-5": { field: "resolution", values: { low: "480p", mid: "720p", high: "720p" } },
187
+ // Seedance 2.5 spans 480p/720p/1080p on KIE (1080p accepted since the
188
+ // 2026-08-17 re-probe; 4k still rejected), so each quality rung gets its own tier.
189
+ "seedance-2-5": { field: "resolution", values: { low: "480p", mid: "720p", high: "1080p" } },
189
190
  "wan-2.7-i2v": { field: "resolution", values: { low: "720p", mid: "1080p", high: "1080p" } },
190
191
  "wan-2.7-t2v": { field: "resolution", values: { low: "720p", mid: "1080p", high: "1080p" } },
191
192
  "happyhorse": { field: "resolution", values: { low: "720p", mid: "1080p", high: "1080p" } },
@@ -17,12 +17,23 @@ export type ReduceStrategy<TConfig = unknown> = {
17
17
  readonly defaultConfig: TConfig
18
18
  readonly outputType: OutputType
19
19
  readonly creditCostKey: string
20
+ /**
21
+ * The strategy calls an LLM (its judge model). Everything that treats
22
+ * "an LLM strategy" specially — the connected-install cloud proxy, the
23
+ * tiered credit id — reads this rather than matching on the id, so a new
24
+ * LLM strategy is covered by declaring it here.
25
+ */
26
+ readonly usesLlm?: boolean
20
27
  }
21
28
 
29
+ // User-facing copy lives HERE (single source of truth) and flows into the node
30
+ // body, the config panel dropdown, the SDK docs and the MCP tool. Written for
31
+ // the person building the flow, not the engine: say what happens to their
32
+ // candidates, never "survivor" / "fan-in" / "reduce" / model names.
22
33
  const PICK_BEST_LLM_STRATEGY = {
23
34
  id: "pick-best-llm",
24
- label: "Pick best (LLM judge)",
25
- description: "Sonnet picks the best item against your criteria.",
35
+ label: "AI picks the best",
36
+ description: "AI compares every candidate against your criteria and picks one.",
26
37
  configSchema: z.object({
27
38
  // Default to the sensible "best quality" criteria when omitted (matches
28
39
  // defaultConfig) so a reduce({strategyId:"pick-best-llm"}) call with no
@@ -30,16 +41,23 @@ const PICK_BEST_LLM_STRATEGY = {
30
41
  // rejects via min(1).
31
42
  criteria: z.string().min(1, "criteria cannot be empty").default("Pick the highest-quality result."),
32
43
  inputKind: z.enum(["text", "image-url"]).default("text"),
44
+ // The judge model, like every other LLM node (llmModel + LlmModelSelect).
45
+ // Optional: omitted → LLM_FEATURE_DEFAULTS["pick-best-llm"]. Validated
46
+ // against LLM_MODEL_IDS at the route (the registry can't import the model
47
+ // list without a cycle), and its tier drives the credit price via
48
+ // buildLlmCreditIdentifier — economy / standard / premium.
49
+ llmModel: z.string().optional(),
33
50
  }),
34
51
  defaultConfig: { criteria: "Pick the highest-quality result.", inputKind: "text" as const },
35
52
  outputType: "text" as OutputType,
36
53
  creditCostKey: "reduce:pick-best-llm",
37
- } as const satisfies ReduceStrategy<{ criteria: string; inputKind: "text" | "image-url" }>
54
+ usesLlm: true,
55
+ } as const satisfies ReduceStrategy<{ criteria: string; inputKind: "text" | "image-url"; llmModel?: string }>
38
56
 
39
57
  const CONCAT_STRATEGY = {
40
58
  id: "concat",
41
- label: "Concatenate",
42
- description: "Join all survivors with a separator.",
59
+ label: "Join into one text",
60
+ description: "Puts every candidate into a single text, one after another, with a separator between them.",
43
61
  configSchema: z.object({ separator: z.string().default("\n\n") }),
44
62
  defaultConfig: { separator: "\n\n" },
45
63
  outputType: "text" as OutputType,
@@ -48,8 +66,8 @@ const CONCAT_STRATEGY = {
48
66
 
49
67
  const FIRST_NON_EMPTY_STRATEGY = {
50
68
  id: "first-non-empty",
51
- label: "First non-empty",
52
- description: "Return the first survivor (empty strings filtered).",
69
+ label: "First that has content",
70
+ description: "Takes the first candidate that is not empty and ignores the rest.",
53
71
  configSchema: z.object({}),
54
72
  defaultConfig: {},
55
73
  outputType: "text" as OutputType,
@@ -58,8 +76,8 @@ const FIRST_NON_EMPTY_STRATEGY = {
58
76
 
59
77
  const COUNT_STRATEGY = {
60
78
  id: "count",
61
- label: "Count",
62
- description: "Return how many survivors came through.",
79
+ label: "Count them",
80
+ description: "Outputs how many candidates arrived.",
63
81
  configSchema: z.object({}),
64
82
  defaultConfig: {},
65
83
  outputType: "data" as OutputType,
@@ -68,8 +86,8 @@ const COUNT_STRATEGY = {
68
86
 
69
87
  const VOTE_STRATEGY = {
70
88
  id: "vote",
71
- label: "Majority vote",
72
- description: "Return the most common survivor (ties first).",
89
+ label: "Most common answer",
90
+ description: "Picks the candidate that appears most often (ties go to the first).",
73
91
  configSchema: z.object({ caseSensitive: z.boolean().default(false) }),
74
92
  defaultConfig: { caseSensitive: false },
75
93
  outputType: "text" as OutputType,
@@ -78,8 +96,8 @@ const VOTE_STRATEGY = {
78
96
 
79
97
  const MERGE_JSON_STRATEGY = {
80
98
  id: "merge-json",
81
- label: "Merge JSON",
82
- description: "Parse each survivor as JSON and merge into one object.",
99
+ label: "Merge JSON objects",
100
+ description: "Reads every candidate as JSON and merges them into one object.",
83
101
  configSchema: z.object({ strategy: z.enum(["deep", "shallow"]).default("deep") }),
84
102
  defaultConfig: { strategy: "deep" as const },
85
103
  outputType: "data" as OutputType,