@nodaro/shared 2.5.0 → 2.8.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/dist/index.cjs +221 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -20
- package/dist/index.d.ts +198 -20
- package/dist/index.js +212 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/entity-image-handle.test.ts +33 -1
- package/src/__tests__/group-aggregation.test.ts +39 -0
- package/src/__tests__/infer-music-video.test.ts +76 -0
- package/src/__tests__/llm-models.test.ts +137 -10
- package/src/__tests__/seedance-2-5-catalog.test.ts +16 -8
- package/src/entity-image-handle.ts +37 -3
- package/src/group-aggregation.ts +32 -0
- package/src/index.ts +8 -0
- package/src/llm-models.ts +116 -2
- package/src/model-catalog.ts +48 -3
- package/src/model-constants.ts +23 -0
- package/src/node-default-mappings.ts +3 -2
- package/src/reduce-strategy-registry.ts +31 -13
- package/src/video-analysis.ts +70 -0
package/src/llm-models.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
export type LlmTier = "economy" | "standard" | "premium"
|
|
17
17
|
export type KieApiFormat = "chat-completions" | "messages" | "responses"
|
|
18
|
+
export type LlmVendor = "anthropic" | "google" | "openai" | "xai"
|
|
18
19
|
|
|
19
20
|
export const LLM_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const
|
|
20
21
|
export type LlmReasoningEffort = (typeof LLM_REASONING_EFFORTS)[number]
|
|
@@ -32,7 +33,7 @@ export interface LlmModelDef {
|
|
|
32
33
|
* For messages: the model id sent in the body (e.g. "claude-haiku-4-5-v1messages").
|
|
33
34
|
* For responses: the model id sent in the body (e.g. "gpt-5-4"). */
|
|
34
35
|
kieSlugOrModel: string
|
|
35
|
-
vendor:
|
|
36
|
+
vendor: LlmVendor
|
|
36
37
|
supportsImages: boolean
|
|
37
38
|
maxOutputTokens: number
|
|
38
39
|
/**
|
|
@@ -159,6 +160,30 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
159
160
|
// direct is the reliability fallback only.
|
|
160
161
|
directGeminiModel: "gemini-3.6-flash",
|
|
161
162
|
},
|
|
163
|
+
{
|
|
164
|
+
id: "gemini-3.7-flash",
|
|
165
|
+
displayName: "Gemini 3.7 Flash",
|
|
166
|
+
desc: "Newest fast Gemini, agentic-tuned",
|
|
167
|
+
tier: "economy",
|
|
168
|
+
kieFormat: "chat-completions",
|
|
169
|
+
// KIE serves it on the OpenAI-compatible dialect under this slug
|
|
170
|
+
// (docs.kie.ai/market/gemini/gemini-3-7-flash-openai.md) — same
|
|
171
|
+
// chat-completions path shape as gemini-3.6-flash.
|
|
172
|
+
kieSlugOrModel: "gemini-3-7-flash-openai",
|
|
173
|
+
vendor: "google",
|
|
174
|
+
structuredOutputMode: "kie-response-format",
|
|
175
|
+
supportsImages: true,
|
|
176
|
+
// Google's own cap is 65,536, but the field feeds BOTH lanes and the KIE
|
|
177
|
+
// flash endpoints cap at 8192 (the measured 3.6 posture) — stay at the
|
|
178
|
+
// KIE-safe intersection, same reasoning as `reasoningEfforts` below.
|
|
179
|
+
maxOutputTokens: 8192,
|
|
180
|
+
// KIE's 3.7 endpoint enumerates reasoning_effort low | high (verified
|
|
181
|
+
// against its OpenAPI spec 2026-08-18), identical to 3.6.
|
|
182
|
+
reasoningEfforts: ["low", "high"],
|
|
183
|
+
// Assumed parity with 3.6 pending a live probe on the direct lane.
|
|
184
|
+
directReasoningEfforts: ["none", "low", "medium", "high"],
|
|
185
|
+
directGeminiModel: "gemini-3.7-flash",
|
|
186
|
+
},
|
|
162
187
|
{
|
|
163
188
|
id: "claude-haiku-4.5",
|
|
164
189
|
displayName: "Claude Haiku 4.5",
|
|
@@ -321,7 +346,35 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
321
346
|
reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
|
|
322
347
|
supportsTemperature: false,
|
|
323
348
|
},
|
|
324
|
-
|
|
349
|
+
{
|
|
350
|
+
id: "grok-4.6",
|
|
351
|
+
displayName: "Grok 4.6",
|
|
352
|
+
desc: "xAI flagship, strong reasoning",
|
|
353
|
+
tier: "standard",
|
|
354
|
+
kieFormat: "responses",
|
|
355
|
+
// KIE serves Grok on the responses dialect under its own family path —
|
|
356
|
+
// grok/v1/responses, NOT codex/v1/responses (llm-client derives the path
|
|
357
|
+
// from `vendor`). Live-verified end-to-end 2026-08-18: array `input`,
|
|
358
|
+
// `developer` system role, `input_image` URL vision, `text.format`
|
|
359
|
+
// json_schema enforcement, SSE `response.output_text.delta` stream, and
|
|
360
|
+
// `credits_consumed` actual-cost capture. (grok-4.5 was deferred 2026-07-13
|
|
361
|
+
// because none of this was live; 4.6 is its activation.)
|
|
362
|
+
kieSlugOrModel: "grok-4-6",
|
|
363
|
+
vendor: "xai",
|
|
364
|
+
structuredOutputMode: "responses-json-schema",
|
|
365
|
+
supportsImages: true,
|
|
366
|
+
maxOutputTokens: 16384,
|
|
367
|
+
// KIE's documented enum, each level live-verified (echoed back) 2026-08-18.
|
|
368
|
+
// No `none`: the endpoint reasons unconditionally (see thinkingDefaultOn).
|
|
369
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
370
|
+
// Live-probed 2026-08-18: `temperature` is silently IGNORED (request echo
|
|
371
|
+
// stays at the 0.7 default), so never send it — same treatment as GPT-5.5+.
|
|
372
|
+
supportsTemperature: false,
|
|
373
|
+
// Reasons with NO reasoning param sent (effort defaults to "low" server-side
|
|
374
|
+
// — a trivial probe spent 169 of 170 output tokens on reasoning), so every
|
|
375
|
+
// call needs output headroom, not just xhigh.
|
|
376
|
+
thinkingDefaultOn: true,
|
|
377
|
+
},
|
|
325
378
|
{
|
|
326
379
|
id: "claude-sonnet-5",
|
|
327
380
|
displayName: "Claude Sonnet 5",
|
|
@@ -407,6 +460,55 @@ export const STRUCTURED_VISION_MODELS = LLM_MODELS.filter(
|
|
|
407
460
|
(m) => m.supportsImages && m.structuredOutputMode != null,
|
|
408
461
|
)
|
|
409
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Vendor presentation order + labels for model pickers. Every LlmVendor MUST
|
|
465
|
+
* appear in the order list (guarded by a registry test) so a new vendor can't
|
|
466
|
+
* ship with its models silently sorted to the end of every menu unlabeled.
|
|
467
|
+
* Alphabetical on purpose: stable, and no vendor-preference fights.
|
|
468
|
+
*/
|
|
469
|
+
export const LLM_VENDOR_ORDER: readonly LlmVendor[] = ["anthropic", "google", "openai", "xai"]
|
|
470
|
+
export const LLM_VENDOR_LABELS: Record<LlmVendor, string> = {
|
|
471
|
+
anthropic: "Anthropic",
|
|
472
|
+
google: "Google",
|
|
473
|
+
openai: "OpenAI",
|
|
474
|
+
xai: "xAI",
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const TIER_RANK: Record<LlmTier, number> = { economy: 0, standard: 1, premium: 2 }
|
|
478
|
+
|
|
479
|
+
export interface LlmModelGroup {
|
|
480
|
+
vendor: LlmVendor
|
|
481
|
+
/** Display heading for the group (LLM_VENDOR_LABELS[vendor]). */
|
|
482
|
+
label: string
|
|
483
|
+
models: LlmModelDef[]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The ONE ordering every LLM model menu renders: grouped by vendor (in
|
|
488
|
+
* LLM_VENDOR_ORDER), and inside each group sorted economy → standard → premium
|
|
489
|
+
* (registry order breaks ties, which keeps family generations adjacent).
|
|
490
|
+
* A flat registry-order dump was genuinely hard to scan at 17 models — every
|
|
491
|
+
* picker (config panel, quick strips, quick toolbar) derives from this so the
|
|
492
|
+
* menus can't drift apart. Groups with no models (after `filter`) are omitted.
|
|
493
|
+
*/
|
|
494
|
+
export function groupLlmModelsByVendor(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelGroup[] {
|
|
495
|
+
const groups: LlmModelGroup[] = []
|
|
496
|
+
for (const vendor of LLM_VENDOR_ORDER) {
|
|
497
|
+
const members = models
|
|
498
|
+
.filter((m) => m.vendor === vendor)
|
|
499
|
+
.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier])
|
|
500
|
+
if (members.length > 0) groups.push({ vendor, label: LLM_VENDOR_LABELS[vendor], models: members })
|
|
501
|
+
}
|
|
502
|
+
return groups
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** {@link groupLlmModelsByVendor} flattened — for menus that can't render
|
|
506
|
+
* group headers (e.g. the compact node quick strips) but should still read
|
|
507
|
+
* vendor-clustered and tier-ordered. */
|
|
508
|
+
export function orderedLlmModels(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelDef[] {
|
|
509
|
+
return groupLlmModelsByVendor(models).flatMap((g) => g.models)
|
|
510
|
+
}
|
|
511
|
+
|
|
410
512
|
export type LlmFeature =
|
|
411
513
|
| "ai-writer"
|
|
412
514
|
| "llm-chat"
|
|
@@ -423,6 +525,10 @@ export type LlmFeature =
|
|
|
423
525
|
| "generate-script"
|
|
424
526
|
| "translate"
|
|
425
527
|
| "image-critic"
|
|
528
|
+
// Choose Best (reduce) — the pick-best-llm strategy's judge. Its own
|
|
529
|
+
// feature (not ai-writer, which it used to piggyback on) so the model
|
|
530
|
+
// default and the tiered credit ids are the strategy's own.
|
|
531
|
+
| "pick-best-llm"
|
|
426
532
|
|
|
427
533
|
/** Engine-dependent LlmFeature for the motion-graphics node (design §8: every credit-id site must branch on engine). */
|
|
428
534
|
export function motionGraphicsFeature(engine?: string): LlmFeature {
|
|
@@ -446,6 +552,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
|
|
|
446
552
|
"generate-script": "gemini-3.6-flash",
|
|
447
553
|
"translate": "gemini-3.6-flash",
|
|
448
554
|
"image-critic": "claude-sonnet-4.6",
|
|
555
|
+
"pick-best-llm": "claude-sonnet-4.6",
|
|
449
556
|
}
|
|
450
557
|
|
|
451
558
|
/**
|
|
@@ -460,6 +567,12 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
|
|
|
460
567
|
export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean; audio: boolean }> = {
|
|
461
568
|
"gemini-3-flash": { image: true, video: true, audio: true },
|
|
462
569
|
"gemini-3.6-flash": { image: true, video: true, audio: true },
|
|
570
|
+
// gemini-3.7-flash is IMAGE-ONLY by DECISION, not omission: full video+audio
|
|
571
|
+
// caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS (derived below) and
|
|
572
|
+
// force a video-analysis tier + pricing decision that is deliberately
|
|
573
|
+
// deferred while the smart-family A/B routes this model internally (#747).
|
|
574
|
+
// Flip these two flags ONLY together with that VA-side decision.
|
|
575
|
+
"gemini-3.7-flash": { image: true, video: false, audio: false },
|
|
463
576
|
"gemini-3.1-pro": { image: true, video: true, audio: true },
|
|
464
577
|
"claude-haiku-4.5": { image: true, video: false, audio: false },
|
|
465
578
|
"claude-sonnet-4.6": { image: true, video: false, audio: false },
|
|
@@ -470,6 +583,7 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
|
|
|
470
583
|
"gpt-5.6-luna": { image: true, video: false, audio: false },
|
|
471
584
|
"gpt-5.6-terra": { image: true, video: false, audio: false },
|
|
472
585
|
"gpt-5.6-sol": { image: true, video: false, audio: false },
|
|
586
|
+
"grok-4.6": { image: true, video: false, audio: false },
|
|
473
587
|
"claude-sonnet-5": { image: true, video: false, audio: false },
|
|
474
588
|
"claude-opus-4.8": { image: true, video: false, audio: false },
|
|
475
589
|
"claude-opus-5": { image: true, video: false, audio: false },
|
package/src/model-catalog.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/model-constants.ts
CHANGED
|
@@ -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
|
|
188
|
-
|
|
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: "
|
|
25
|
-
description: "
|
|
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
|
-
|
|
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: "
|
|
42
|
-
description: "
|
|
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
|
|
52
|
-
description: "
|
|
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: "
|
|
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: "
|
|
72
|
-
description: "
|
|
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: "
|
|
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,
|
package/src/video-analysis.ts
CHANGED
|
@@ -100,6 +100,21 @@ export type VideoAnalysisTransition = (typeof VIDEO_ANALYSIS_TRANSITIONS)[number
|
|
|
100
100
|
* the majority of shots.
|
|
101
101
|
*/
|
|
102
102
|
export const VIDEO_ANALYSIS_SPEED_EFFECTS = ["slow-motion", "ramp-in", "ramp-out", "timelapse", "freeze", "reverse"] as const
|
|
103
|
+
|
|
104
|
+
/** CHRONICLE TIME (2026-08-17): the STORY clock, per scene, as read from the
|
|
105
|
+
* pictures — light, sky, practicals. "ambiguous" is the honest answer for a
|
|
106
|
+
* windowless interior; guessing day is exactly the kind of tidy inference
|
|
107
|
+
* the analysis doctrine forbids. */
|
|
108
|
+
export const VIDEO_ANALYSIS_TIMES_OF_DAY = ["dawn", "day", "dusk", "night", "ambiguous"] as const
|
|
109
|
+
|
|
110
|
+
/** STORY JUMP since the PREVIOUS scene in the list: how much narrative time
|
|
111
|
+
* passed across the cut, judged from evidence (wardrobe change, aged
|
|
112
|
+
* subjects, season, a title card), not from the cut itself. Time outranks
|
|
113
|
+
* location for continuity judgements (same person, new place, continuous
|
|
114
|
+
* time ⇒ same outfit; same place, years later ⇒ anything may differ), which
|
|
115
|
+
* is why this is a structured field and not prose. "unclear" is the honest
|
|
116
|
+
* default; the FIRST scene of a clip is "continuous" by convention. */
|
|
117
|
+
export const VIDEO_ANALYSIS_STORY_JUMPS = ["continuous", "same-day", "another-day", "years-later", "unclear"] as const
|
|
103
118
|
export type VideoAnalysisSpeedEffect = (typeof VIDEO_ANALYSIS_SPEED_EFFECTS)[number]
|
|
104
119
|
|
|
105
120
|
/**
|
|
@@ -268,6 +283,12 @@ const windowSceneBase = z.object({
|
|
|
268
283
|
angle: z.enum(VIDEO_ANALYSIS_SHOT_ANGLES).optional(),
|
|
269
284
|
/** Time manipulation. Absent ⇒ normal speed. */
|
|
270
285
|
speed: z.enum(VIDEO_ANALYSIS_SPEED_EFFECTS).optional(),
|
|
286
|
+
/** CHRONICLE TIME (2026-08-17) — see the consts' docstrings. Both optional:
|
|
287
|
+
* absent on every pre-2.6.0 analysis, and legitimately absent when the
|
|
288
|
+
* analyser cannot read the clock. Enum + optional keeps the window decode
|
|
289
|
+
* grammar congruence-safe (no ints, no maxItems). */
|
|
290
|
+
timeOfDay: z.enum(VIDEO_ANALYSIS_TIMES_OF_DAY).optional(),
|
|
291
|
+
storyJump: z.enum(VIDEO_ANALYSIS_STORY_JUMPS).optional(),
|
|
271
292
|
visual: z.string().min(1),
|
|
272
293
|
/**
|
|
273
294
|
* Text burned into the PICTURE of this shot — titles, captions, lower-thirds,
|
|
@@ -521,3 +542,52 @@ export function aspectRatioFromDims(w: number, h: number): string {
|
|
|
521
542
|
const g = gcd(Math.round(w), Math.round(h))
|
|
522
543
|
return `${Math.round(w) / g}:${Math.round(h) / g}`
|
|
523
544
|
}
|
|
545
|
+
|
|
546
|
+
/** Sung-vocal evidence in a music layer's gen-ready description. Word-bounded
|
|
547
|
+
* and deliberately WITHOUT bare "song"/"music" (an instrumental bed is
|
|
548
|
+
* routinely described as a "pop song"). Quoted text of some length inside a
|
|
549
|
+
* music layer counts too — analysers quote lyrics. */
|
|
550
|
+
const MUSIC_VOCAL_RE = /\b(?:lyrics?|sung|sings?|singing|vocals?|chorus|verse|rap(?:ping|ped)?|a cappella)\b/i
|
|
551
|
+
const MUSIC_VOCAL_QUOTE_RE = /["\u201c][^"\u201d]{6,}["\u201d]/
|
|
552
|
+
/** Negated-vocal phrasing — a layer matching this contributes NO vocal
|
|
553
|
+
* evidence (it does not veto other layers). */
|
|
554
|
+
const MUSIC_NO_VOCAL_RE = /\b(?:no|without|non)[- ](?:vocals?|lyrics?|singing)\b|\binstrumental\b|\bwordless\b/i
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* MUSIC-VIDEO INFERENCE (2026-08-17): is this clip a music video — one whose
|
|
558
|
+
* soundtrack IS the content, to be taken as-is with no stem separation?
|
|
559
|
+
*
|
|
560
|
+
* Lives in SHARED because two sides must agree BYTE-FOR-BYTE on the answer:
|
|
561
|
+
* the recast route derives `music.mode` from it server-side, and the client
|
|
562
|
+
* both prices the original-audio prep and GUARDS on the server's derived mode
|
|
563
|
+
* at generate time — two hand-written copies of this heuristic would drift
|
|
564
|
+
* into that guard firing on honest runs. Deterministic, throw-proof on any
|
|
565
|
+
* malformed analysis (absent fields ⇒ false).
|
|
566
|
+
*
|
|
567
|
+
* The rule is conservative toward FALSE (a false positive keeps unwanted
|
|
568
|
+
* dialogue in the render; a false negative merely runs the separation, which
|
|
569
|
+
* was yesterday's default): at least 4 scenes, at least 80% of scenes carry a
|
|
570
|
+
* music layer, and at least one music layer carries sung-vocal evidence that
|
|
571
|
+
* is not negated ("instrumental", "no vocals").
|
|
572
|
+
*
|
|
573
|
+
* An EXPLICIT analyze-time flag always wins — callers use
|
|
574
|
+
* `flag === true || inferMusicVideo(analysis)` and never let a cached false
|
|
575
|
+
* suppress the inference (the flag can only ever be set true; false means
|
|
576
|
+
* "unset", not "denied").
|
|
577
|
+
*/
|
|
578
|
+
export function inferMusicVideo(analysis: {
|
|
579
|
+
scenes?: ReadonlyArray<{ audio?: ReadonlyArray<{ mode?: string; content?: string }> }>
|
|
580
|
+
} | undefined | null): boolean {
|
|
581
|
+
const scenes = analysis?.scenes ?? []
|
|
582
|
+
if (scenes.length < 4) return false
|
|
583
|
+
const musicLayers = (sc: (typeof scenes)[number]) => (sc.audio ?? []).filter((a) => a?.mode === "music")
|
|
584
|
+
const withMusic = scenes.filter((sc) => musicLayers(sc).length > 0).length
|
|
585
|
+
if (withMusic / scenes.length < 0.8) return false
|
|
586
|
+
return scenes.some((sc) =>
|
|
587
|
+
musicLayers(sc).some((a) => {
|
|
588
|
+
const content = typeof a.content === "string" ? a.content : ""
|
|
589
|
+
if (MUSIC_NO_VOCAL_RE.test(content)) return false
|
|
590
|
+
return MUSIC_VOCAL_RE.test(content) || MUSIC_VOCAL_QUOTE_RE.test(content)
|
|
591
|
+
}),
|
|
592
|
+
)
|
|
593
|
+
}
|