@nodaro/shared 2.3.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.
@@ -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",
@@ -605,8 +606,13 @@ export const IMAGE_I2I_PROVIDERS = [
605
606
  "kontext-multi",
606
607
  // BFL Flux 2 Pro — runs through Replicate with safety_tolerance=5 (max for Pro)
607
608
  "flux-2-pro",
609
+ // BFL FLUX Fill Pro — dedicated masked inpainting via Replicate (white = edit area)
610
+ "flux-fill",
608
611
  // BFL Flux 2 Max — runs through Replicate with safety_tolerance=5, up to 8 refs
609
612
  "flux-2-max",
613
+ // BFL FLUX Fill Pro — dedicated inpainting via Replicate (image + mask + prompt,
614
+ // white = edit area). Second mask-capable i2i provider alongside ideogram-edit.
615
+ "flux-fill",
610
616
  ] as const
611
617
 
612
618
  /** Image editing providers (upscale, remove bg, etc.) */
@@ -618,8 +624,29 @@ export const IMAGE_EDIT_PROVIDERS = [
618
624
  // grok-upscale takes a prior Grok generation's task_id (NOT an image URL) —
619
625
  // see edit-image route for the taskId-vs-imageUrl branching.
620
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",
621
633
  ] as const
622
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
+
623
650
  /** Modify image providers (I2I + edit-with-prompt) */
624
651
  export const MODIFY_IMAGE_PROVIDERS = [
625
652
  ...IMAGE_I2I_PROVIDERS,
@@ -1039,7 +1066,7 @@ export type VoiceDesignModel = typeof VOICE_DESIGN_MODELS[number]
1039
1066
  export const DEFAULT_VOICE_DESIGN_MODEL: VoiceDesignModel = "eleven_ttv_v3"
1040
1067
 
1041
1068
  /** I2I providers that support mask-based inpainting */
1042
- export const I2I_MASK_SUPPORT = new Set(["ideogram-edit"])
1069
+ export const I2I_MASK_SUPPORT = new Set(["ideogram-edit", "flux-fill"])
1043
1070
 
1044
1071
  /**
1045
1072
  * Mask edit tier per image-gen provider (single source of truth for inpaint).
@@ -1066,6 +1093,7 @@ export const IMAGE_MASK_MODE: Record<ImageGenProvider, ImageMaskMode> = {
1066
1093
  "flux": "composite",
1067
1094
  "flux-flex": "composite",
1068
1095
  "grok": "composite",
1096
+ "grok-2": "composite",
1069
1097
  "imagen4": "composite",
1070
1098
  "imagen4-fast": "composite",
1071
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" } },
@@ -65,6 +65,7 @@ export function validateModeActivation(
65
65
  // Tier → max parallel pipelines (Architecture §5.4)
66
66
  export const TIER_PIPELINE_PARALLELISM: Record<string, number> = {
67
67
  free: 0,
68
+ payg: 1, // derived tier — pipeline entitlements copy basic's
68
69
  basic: 1,
69
70
  standard: 2,
70
71
  pro: 3,
@@ -80,6 +81,7 @@ export const TIER_PIPELINE_PARALLELISM: Record<string, number> = {
80
81
  // the user paid for, instead of the intended two-thirds.
81
82
  export const TIER_MAX_PIPELINE_COST_CREDITS: Record<string, number> = {
82
83
  free: 0,
84
+ payg: 3000, // derived tier — copies basic
83
85
  basic: 3000,
84
86
  standard: 8000,
85
87
  pro: 20000,
@@ -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,
@@ -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
+ }