@nodaro/shared 2.20.0 → 2.21.1

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.
@@ -133,6 +133,24 @@ export interface ModelCatalogEntry {
133
133
  features?: readonly string[]
134
134
  aspectRatios?: readonly string[]
135
135
  resolutions?: readonly string[]
136
+ /**
137
+ * What this model actually RENDERS for a resolution outside `resolutions`.
138
+ *
139
+ * Most providers honour whatever band they are sent, so an off-list value is
140
+ * best snapped to the NEAREST supported one — a 4k request on a 1080p-max
141
+ * model renders 1080p. A few instead COLLAPSE anything unrecognised to a
142
+ * fixed default (MiniMax H3 → 2K, Wan 3.0 → 720p), and for those the nearest
143
+ * band is the wrong answer in the most expensive direction: snapping a stale
144
+ * "720p" to H3's cheap 768P would send a value the provider ignores, so we
145
+ * would bill the 768P tier against a 2K render, and `commit_credits` never
146
+ * collects the shortfall.
147
+ *
148
+ * Declare it in the catalog's own spelling. `normalizeVideoRequestParams`
149
+ * uses it in place of the nearest-band snap; `video-collapse-parity.test.ts`
150
+ * pins each declaration to what the provider normalizer actually returns, so
151
+ * the two cannot drift.
152
+ */
153
+ unlistedResolutionRendersAs?: string
136
154
  qualities?: readonly string[]
137
155
  durations?: readonly number[]
138
156
  pricing: readonly PriceVariant[]
@@ -155,6 +173,12 @@ export interface ModelCatalogEntry {
155
173
  * Frontend pickers ignore this flag.
156
174
  */
157
175
  mcpHidden?: boolean
176
+ /**
177
+ * The provider's safety filter is known to be non-deterministic on this
178
+ * model — the platform retries a blocked request once; `fallback` names
179
+ * the catalog model to offer when it blocks again.
180
+ */
181
+ safetyFilter?: { stochastic: true; fallback?: string }
158
182
  }
159
183
 
160
184
  /**
@@ -177,7 +201,7 @@ export const MODEL_RECOMMENDATIONS: readonly ModelRecommendation[] = [
177
201
  { intent: "cheapest realistic image", modelIds: ["z-image", "qwen", "imagen4-fast"], note: "Z-Image is the cheapest. Qwen / Imagen4 Fast for slightly higher quality." },
178
202
  { intent: "highest fidelity image", modelIds: ["nano-banana-pro", "imagen4-ultra", "flux-flex"], note: "Pick by family preference; all three are premium tiers." },
179
203
  { intent: "image edit / restyle", modelIds: ["flux-kontext", "ideogram-remix", "seedream-5-pro-i2i"], note: "Flux Kontext preserves identity; Ideogram Remix is character-aware; Seedream 5 Pro for instruction-based edits (5 Lite is the budget option)." },
180
- { intent: "highest-resolution image (4K / 8K)", modelIds: ["topaz-image-upscale", "nano-banana-pro", "gpt-image-2"], note: "Generate at native then Topaz upscale for 8K." },
204
+ { intent: "highest-resolution image", modelIds: ["topaz-image-upscale", "nano-banana-pro", "gpt-image-2"], note: "Generate at the model's top tier, then Topaz upscale 4x (Topaz's only lever is the 1x/2x/4x factor)." },
181
205
  { intent: "background removal / cutout", modelIds: ["recraft-remove-bg"], note: "Cheap, no prompt needed." },
182
206
  // video
183
207
  { intent: "best cinematic video", modelIds: ["veo3", "kling-3.0", "seedance-2"], note: "VEO 3.1 Quality for premium narrative; Kling 3.0 for music-synced motion; Seedance 2 for reference-driven consistency." },
@@ -545,6 +569,7 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
545
569
  { identifier: "gpt-image-2:2K", credits: 30, note: "2K" },
546
570
  { identifier: "gpt-image-2:4K", credits: 60, note: "4K" },
547
571
  ],
572
+ safetyFilter: { stochastic: true, fallback: "nano-banana-pro" },
548
573
  },
549
574
  "gpt-image-2-i2i": {
550
575
  id: "gpt-image-2-i2i",
@@ -554,6 +579,7 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
554
579
  label: "GPT Image 2 (I2I)",
555
580
  series: "GPT Image",
556
581
  description: "Image-to-image with GPT Image 2.",
582
+ safetyFilter: { stochastic: true, fallback: "nano-banana-pro" },
557
583
  useCases: ["edit", "high-res"],
558
584
  features: ["reference-image"],
559
585
  aspectRatios: GPT_IMAGE_2_RATIOS,
@@ -975,14 +1001,15 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
975
1001
  family: "Topaz",
976
1002
  label: "Topaz Image Upscale",
977
1003
  series: "Topaz",
978
- description: "High-quality image upscale up to 8K. Best for production-ready output.",
1004
+ description: "High-quality image upscale at 1x (enhance only), 2x or 4x. Best for production-ready output.",
979
1005
  useCases: ["upscale", "high-res", "premium"],
980
1006
  features: ["reference-image"],
981
- resolutions: ["2K", "4K", "8K"],
1007
+ // No `resolutions`: the provider's only quality lever is `upscale_factor`
1008
+ // (1/2/4) — see resolveTopazUpscale. The 2K/4K/8K menu this used to
1009
+ // advertise had no provider parameter behind it.
982
1010
  pricing: [
983
- { identifier: "topaz-image-upscale", credits: 25, note: "2K default" },
984
- { identifier: "topaz-image-upscale:4K", credits: 50, note: "4K" },
985
- { identifier: "topaz-image-upscale:8K", credits: 100, note: "8K" },
1011
+ { identifier: "topaz-image-upscale", credits: 25, note: "1x / 2x" },
1012
+ { identifier: "topaz-image-upscale:4K", credits: 50, note: "4x (legacy identifier)" },
986
1013
  ],
987
1014
  },
988
1015
  }
@@ -1017,6 +1044,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1017
1044
  // docs.kie.ai/market/minimax-h3/{text,image,reference}-to-video.
1018
1045
  "minimax-h3": {
1019
1046
  id: "minimax-h3",
1047
+ unlistedResolutionRendersAs: "2K",
1020
1048
  kind: "video",
1021
1049
  modes: ["i2v", "t2v"] as const,
1022
1050
  family: "MiniMax",
@@ -1505,6 +1533,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1505
1533
  // declared explicitly in PRICING_DEFAULT_RESOLUTION, never by array position.
1506
1534
  "wan-3": {
1507
1535
  id: "wan-3",
1536
+ unlistedResolutionRendersAs: "720p",
1508
1537
  kind: "video",
1509
1538
  modes: ["i2v", "t2v"] as const,
1510
1539
  family: "Alibaba",
@@ -1529,6 +1558,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1529
1558
  },
1530
1559
  "wan-3-prime": {
1531
1560
  id: "wan-3-prime",
1561
+ unlistedResolutionRendersAs: "720p",
1532
1562
  kind: "video",
1533
1563
  modes: ["i2v", "t2v"] as const,
1534
1564
  family: "Alibaba",
@@ -1697,6 +1727,76 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1697
1727
  { identifier: "wan-2.7-t2v", credits: 188, note: "5s 720p default" },
1698
1728
  ],
1699
1729
  },
1730
+ // Lightricks LTX 2.3 (Replicate, not KIE). Both variants run one endpoint per
1731
+ // variant and switch behaviour with a `task` discriminator, so t2v and i2v are
1732
+ // the SAME id — no VIDEO_MODE_ALIASES row. `extend` / `retake` are separate
1733
+ // priced ops on the same model (ids below) but are NOT listed in `modes`:
1734
+ // they are driven by the Extend Video / Video Retake nodes' own pickers, and
1735
+ // adding the mode here would duplicate LTX in those menus.
1736
+ // Bands are lowercase to match LTX_DURATION_TIERS' keys in credit-identifiers.ts
1737
+ // and QUALITY_MAP in node-default-mappings.ts.
1738
+ "ltx-2.3-pro": {
1739
+ id: "ltx-2.3-pro",
1740
+ kind: "video",
1741
+ modes: ["i2v", "t2v"] as const,
1742
+ family: "Lightricks",
1743
+ label: "LTX 2.3 Pro",
1744
+ series: "LTX",
1745
+ description: "Lightricks LTX 2.3 Pro — text/image/audio→video up to 4K, 6/8/10s, end-frame interpolation.",
1746
+ useCases: ["premium", "high-res", "narrative"],
1747
+ features: ["end-frame"],
1748
+ aspectRatios: ["16:9", "9:16"] as const,
1749
+ resolutions: ["1080p", "2k", "4k"] as const,
1750
+ durations: [6, 8, 10],
1751
+ pricing: [
1752
+ { identifier: "ltx-2.3-pro", credits: 240, note: "default 1080p 6s" },
1753
+ { identifier: "ltx-2.3-pro:1080p:6s", credits: 240 },
1754
+ { identifier: "ltx-2.3-pro:1080p:8s", credits: 320 },
1755
+ { identifier: "ltx-2.3-pro:1080p:10s", credits: 400 },
1756
+ { identifier: "ltx-2.3-pro:2k:6s", credits: 480 },
1757
+ { identifier: "ltx-2.3-pro:2k:8s", credits: 640 },
1758
+ { identifier: "ltx-2.3-pro:2k:10s", credits: 800 },
1759
+ { identifier: "ltx-2.3-pro:4k:6s", credits: 960 },
1760
+ { identifier: "ltx-2.3-pro:4k:8s", credits: 1280 },
1761
+ { identifier: "ltx-2.3-pro:4k:10s", credits: 1600 },
1762
+ { identifier: "ltx-2.3-pro-extend:per-second", credits: 40, note: "extend, per second of new footage" },
1763
+ { identifier: "ltx-2.3-pro-retake:per-second", credits: 40, note: "retake, per second re-rendered" },
1764
+ ],
1765
+ },
1766
+ "ltx-2.3-fast": {
1767
+ id: "ltx-2.3-fast",
1768
+ kind: "video",
1769
+ modes: ["i2v", "t2v"] as const,
1770
+ family: "Lightricks",
1771
+ label: "LTX 2.3 Fast",
1772
+ series: "LTX",
1773
+ description: "Lightricks LTX 2.3 Fast — text/image→video up to 20s at 1080p (6/8/10s at 2K and 4K). No audio input, no extend.",
1774
+ useCases: ["long-form", "fast", "narrative"],
1775
+ features: ["end-frame"],
1776
+ aspectRatios: ["16:9", "9:16"] as const,
1777
+ resolutions: ["1080p", "2k", "4k"] as const,
1778
+ // Flat union across bands — 12–20s exist only at 1080p (LTX_DURATION_TIERS
1779
+ // in credit-identifiers.ts is the per-band authority and snaps a 2k/4k
1780
+ // request back onto 6/8/10s, so the reservation is always a real tier).
1781
+ durations: [6, 8, 10, 12, 14, 16, 18, 20],
1782
+ pricing: [
1783
+ { identifier: "ltx-2.3-fast", credits: 180, note: "default 1080p 6s" },
1784
+ { identifier: "ltx-2.3-fast:1080p:6s", credits: 180 },
1785
+ { identifier: "ltx-2.3-fast:1080p:8s", credits: 240 },
1786
+ { identifier: "ltx-2.3-fast:1080p:10s", credits: 300 },
1787
+ { identifier: "ltx-2.3-fast:1080p:12s", credits: 360 },
1788
+ { identifier: "ltx-2.3-fast:1080p:14s", credits: 420 },
1789
+ { identifier: "ltx-2.3-fast:1080p:16s", credits: 480 },
1790
+ { identifier: "ltx-2.3-fast:1080p:18s", credits: 540 },
1791
+ { identifier: "ltx-2.3-fast:1080p:20s", credits: 600 },
1792
+ { identifier: "ltx-2.3-fast:2k:6s", credits: 360 },
1793
+ { identifier: "ltx-2.3-fast:2k:8s", credits: 480 },
1794
+ { identifier: "ltx-2.3-fast:2k:10s", credits: 600 },
1795
+ { identifier: "ltx-2.3-fast:4k:6s", credits: 720 },
1796
+ { identifier: "ltx-2.3-fast:4k:8s", credits: 960 },
1797
+ { identifier: "ltx-2.3-fast:4k:10s", credits: 1200 },
1798
+ ],
1799
+ },
1700
1800
  // ── HappyHorse (1.1 — ids kept version-less; repointed in place when KIE
1701
1801
  // delisted 1.0, same param surface, so saved workflows keep working) ──
1702
1802
  "happyhorse": {
@@ -2714,6 +2814,187 @@ export function normalizeModelInput(
2714
2814
  return out
2715
2815
  }
2716
2816
 
2817
+ /** Aspect tokens that mean "match the input" or "let the provider decide". They
2818
+ * are NOT concrete ratios, so snapping them onto one is always wrong — the
2819
+ * provider adapter (applySeedance2Params, applyMinimaxH3Params) resolves them
2820
+ * once it knows the run's mode. */
2821
+ const PASSTHROUGH_ASPECT_TOKENS = new Set(["auto", "adaptive"])
2822
+
2823
+ /** Approximate vertical pixel count per band token, for nearest-band matching.
2824
+ * Anything of the form `<N>p` is parsed directly; these are the named bands
2825
+ * the catalogs use that do not follow that form. */
2826
+ const RESOLUTION_BAND_PIXELS: Record<string, number> = {
2827
+ "2k": 1440,
2828
+ "4k": 2160,
2829
+ "8k": 4320,
2830
+ }
2831
+
2832
+ function bandPixels(token: string): number | undefined {
2833
+ const t = token.trim().toLowerCase()
2834
+ if (RESOLUTION_BAND_PIXELS[t] !== undefined) return RESOLUTION_BAND_PIXELS[t]
2835
+ const m = /^(\d+)p$/.exec(t)
2836
+ return m ? Number(m[1]) : undefined
2837
+ }
2838
+
2839
+ /** Nearest member of `allowed` to `token` by vertical pixels. An UNPARSEABLE
2840
+ * request falls back to the highest declared band — never the cheapest, which
2841
+ * is what a catalog-order fallback would give (R7). Highest is computed from
2842
+ * the pixel counts, not from the array position: most video catalogs list
2843
+ * ascending but not all do (minimax-h3 is `["2K", "768P"]`), and an ordering
2844
+ * convention is not something a money path should depend on. */
2845
+ function nearestResolutionBand(token: string, allowed: readonly string[]): string {
2846
+ let highest = allowed[allowed.length - 1]!
2847
+ let highestPx = -Infinity
2848
+ for (const a of allowed) {
2849
+ const px = bandPixels(a)
2850
+ if (px !== undefined && px > highestPx) { highestPx = px; highest = a }
2851
+ }
2852
+ const want = bandPixels(token)
2853
+ if (want === undefined) return highest
2854
+ let best = highest
2855
+ let bestDist = Infinity
2856
+ for (const a of allowed) {
2857
+ const px = bandPixels(a)
2858
+ if (px === undefined) continue
2859
+ const d = Math.abs(px - want)
2860
+ if (d < bestDist) { bestDist = d; best = a }
2861
+ }
2862
+ return best
2863
+ }
2864
+
2865
+ /** Nearest aspect ratio in log space. Mirrors
2866
+ * `backend/src/providers/video/aspect-ratio.ts` exactly — that helper cannot
2867
+ * be imported here (wrong package), and the totality test in
2868
+ * `backend/src/lib/mcp/__tests__/video-normalizer-totality.test.ts` pins the
2869
+ * two implementations to the same answer for every catalogued model. */
2870
+ function nearestAspectRatio(token: string, allowed: readonly string[]): string | undefined {
2871
+ const [w, h] = token.split(":").map(Number)
2872
+ if (!w || !h) return undefined
2873
+ const target = Math.log(w / h)
2874
+ let best: string | undefined
2875
+ let bestDist = Infinity
2876
+ for (const c of allowed) {
2877
+ const [cw, ch] = c.split(":").map(Number)
2878
+ if (!cw || !ch) continue
2879
+ const d = Math.abs(Math.log(cw / ch) - target)
2880
+ if (d < bestDist) { bestDist = d; best = c }
2881
+ }
2882
+ return best
2883
+ }
2884
+
2885
+ /**
2886
+ * Read a catalog lever off an UNVALIDATED input as a trimmed string.
2887
+ *
2888
+ * `normalizeVideoRequestParams` runs in both routes' creditGuard preHandler —
2889
+ * which fires BEFORE the route's Zod parse — and in all four `buildPayload`
2890
+ * branches, whose `data` is persisted workflow JSON that an agent/import/
2891
+ * FieldMapping can write any JSON type into. A bare `.trim()` therefore throws
2892
+ * `TypeError` on `{"resolution": 1080}`: a 500 where the route used to return a
2893
+ * clean Zod 400, and in the DAG a thrown lever takes the WHOLE run down after
2894
+ * sibling nodes have already reserved and generated. So coerce rather than
2895
+ * assume — same posture as `sameOptionValue`, which has always used `String(v)`.
2896
+ *
2897
+ * `null`/`undefined`/blank mean "absent" (there is nothing to snap) and read as
2898
+ * `undefined`, so the priced fill downstream supplies the band the identifier
2899
+ * assumes — price and wire still agree. Everything else is stringified: a
2900
+ * numeric `1080` becomes `"1080"` and snaps like its string form.
2901
+ */
2902
+ function readOptionToken(value: unknown): string | undefined {
2903
+ if (value === undefined || value === null) return undefined
2904
+ const s = (typeof value === "string" ? value : String(value)).trim()
2905
+ return s === "" ? undefined : s
2906
+ }
2907
+
2908
+ export interface NormalizedVideoRequest {
2909
+ aspectRatio?: string
2910
+ resolution?: string
2911
+ adjustments: ModelInputAdjustment[]
2912
+ }
2913
+
2914
+ /**
2915
+ * The video lane's LIMITED normalizer: `aspectRatio` + `resolution` only, and
2916
+ * only when the catalog actually declares the option list.
2917
+ *
2918
+ * Three deliberate differences from `normalizeModelInput`:
2919
+ * 1. It never DROPS a lever. `normalizeModelInput` removes a value whose list
2920
+ * the catalog doesn't declare; on the video lane `resolution` is a pricing
2921
+ * input, so dropping it would lower the reserved tier and `commit_credits`
2922
+ * never collects an upward delta — a params fix would become a money bug.
2923
+ * 2. "Auto" / "adaptive" pass through (see PASSTHROUGH_ASPECT_TOKENS).
2924
+ * 3. It snaps to the NEAREST option, not to `allowed[0]`. `normalizeModelInput`
2925
+ * would turn a portrait 9:21 into landscape 16:9 and a 4k request into the
2926
+ * cheapest 480p; nearest matches what the provider adapters and the MCP
2927
+ * normalizer already do, so all three give ONE answer. Its snap policy is
2928
+ * deliberately left alone — it is shared with the image lane and the
2929
+ * copilot, and PR 4 defers changing it.
2930
+ *
2931
+ * `duration` is deliberately NOT normalized here: the LTX bands make duration
2932
+ * legality resolution-dependent, which a flat catalog list can't express, and
2933
+ * `buildVideoCreditModelIdentifier` already snaps duration onto a seeded tier.
2934
+ * Carrying THAT tier to the wire is `pricedVideoSelection`'s job
2935
+ * (`credit-identifiers.ts`), which runs after this one, once resolution is known.
2936
+ *
2937
+ * An OMITTED lever stays omitted here — filling it is a pricing decision, not a
2938
+ * catalog one, and likewise belongs to `pricedVideoSelection`.
2939
+ *
2940
+ * Case is canonicalised to the catalog's own spelling — the credit identifiers
2941
+ * key their tables case-SENSITIVELY (LTX_DURATION_TIERS), so this is the single
2942
+ * place a "4K" becomes "4k", and it runs before every identifier site.
2943
+ *
2944
+ * Pure — so the credit-identifier preHandler, `computeCredits`, the reservation
2945
+ * site and the payload build can all call it and cannot disagree (spec §4).
2946
+ */
2947
+ export function normalizeVideoRequestParams(
2948
+ modelId: string,
2949
+ input: { aspectRatio?: string; resolution?: string },
2950
+ ): NormalizedVideoRequest {
2951
+ const m = MODEL_CATALOG[modelId]
2952
+ // Seeded from the COERCED tokens, never the raw input: this function's own
2953
+ // return type promises `string | undefined`, and its callers feed it straight
2954
+ // to the credit identifier and the provider wire. Handing back the caller's
2955
+ // `1080` or `[]` verbatim would break that promise at exactly the sites that
2956
+ // must agree on one value.
2957
+ const aspect = readOptionToken(input.aspectRatio)
2958
+ const res = readOptionToken(input.resolution)
2959
+ const out: NormalizedVideoRequest = { aspectRatio: aspect, resolution: res, adjustments: [] }
2960
+ if (!m) return out
2961
+
2962
+ if (m.aspectRatios?.length && aspect && !PASSTHROUGH_ASPECT_TOKENS.has(aspect.toLowerCase())) {
2963
+ const allowed = m.aspectRatios
2964
+ const exact = allowed.find((a) => a === aspect) ?? allowed.find((a) => a.toLowerCase() === aspect.toLowerCase())
2965
+ if (exact !== undefined) {
2966
+ out.aspectRatio = exact
2967
+ } else {
2968
+ const next = nearestAspectRatio(aspect, allowed.filter((a) => !PASSTHROUGH_ASPECT_TOKENS.has(a.toLowerCase()))) ?? allowed[0]!
2969
+ out.adjustments.push({
2970
+ field: "aspectRatio", from: aspect, to: next,
2971
+ reason: `${m.label} does not support aspect ratio "${aspect}" — using "${next}" instead. Supported: ${allowed.join(", ")}.`,
2972
+ })
2973
+ out.aspectRatio = next
2974
+ }
2975
+ }
2976
+
2977
+ if (m.resolutions?.length && res) {
2978
+ const allowed = m.resolutions
2979
+ const exact = allowed.find((a) => a === res) ?? allowed.find((a) => a.toLowerCase() === res.toLowerCase())
2980
+ if (exact !== undefined) {
2981
+ out.resolution = exact
2982
+ } else {
2983
+ // A provider that COLLAPSES an unrecognised band renders its declared
2984
+ // default no matter what we send, so that default — not the nearest band
2985
+ // — is the value the wire and the price must both carry.
2986
+ const next = m.unlistedResolutionRendersAs ?? nearestResolutionBand(res, allowed)
2987
+ out.adjustments.push({
2988
+ field: "resolution", from: res, to: next,
2989
+ reason: `${m.label} does not support resolution "${res}" — using "${next}" instead. Supported: ${allowed.join(", ")}.`,
2990
+ })
2991
+ out.resolution = next
2992
+ }
2993
+ }
2994
+
2995
+ return out
2996
+ }
2997
+
2717
2998
  // =============================================================================
2718
2999
  // Frontend picker helpers — return `{value, label}[]` shapes that the
2719
3000
  // existing config-panel components expect, derived from the catalog so we
@@ -2742,6 +3023,7 @@ const MODEL_VALUE_LABELS: Record<string, string> = {
2742
3023
  "2K": "2K (High)",
2743
3024
  "4K": "4K (Ultra)",
2744
3025
  "4k": "4K",
3026
+ "2k": "2K",
2745
3027
  "8K": "8K (Ultra)",
2746
3028
  // qualities
2747
3029
  "medium": "Medium (Balanced)",
@@ -1900,6 +1900,89 @@ export const VIDEO_REF_LIMITS_BY_PROVIDER: Record<
1900
1900
  // verified provider path + the catalog `reference-image` feature.
1901
1901
  }
1902
1902
 
1903
+ /** Per-reference-video duration bounds, in seconds. */
1904
+ export interface RefVideoDurationLimit {
1905
+ minSec: number
1906
+ maxSec: number
1907
+ /** Cap on the SUM of all reference-video durations, when the provider has one. */
1908
+ maxTotalSec?: number
1909
+ }
1910
+
1911
+ /**
1912
+ * Reference-video duration limits, provider-declared.
1913
+ *
1914
+ * `VIDEO_REF_LIMITS_BY_PROVIDER` above caps the reference COUNT; these cap the
1915
+ * duration of each clip. Both routes already ffprobe every reference video to
1916
+ * price the run (ee/billing/seedance2-ref-video-credits.ts and
1917
+ * minimax-h3-credits.ts), so the numbers are in hand before the job exists —
1918
+ * and until 2026-09-02 they were discarded, which is why "Each reference video
1919
+ * must be between 2 and 30 seconds" and "video duration 52838 ms, expected
1920
+ * [2000, 15000] ms" were reaching users as post-payment provider rejects
1921
+ * (app-reports §11.3).
1922
+ *
1923
+ * Data-driven, exactly like SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER above:
1924
+ * only providers with a VERIFIED documented limit are listed, so an unknown
1925
+ * provider is never false-rejected.
1926
+ *
1927
+ * Sources: docs.kie.ai/market/bytedance/seedance-2-5 ("Single video duration:
1928
+ * [2, 30] seconds"; "Total duration of reference videos must not exceed 30
1929
+ * seconds") and docs.kie.ai/market/minimax-h3/reference-to-video (reference
1930
+ * videos are "2 to 15 seconds" each and "the total duration of all reference
1931
+ * videos cannot exceed 15 seconds", up to 3 videos) — both fetched 2026-09-02.
1932
+ */
1933
+ export const VIDEO_REF_VIDEO_DURATION_LIMITS: Record<string, RefVideoDurationLimit | undefined> = {
1934
+ "seedance-2-5": { minSec: 2, maxSec: 30, maxTotalSec: 30 },
1935
+ // MiniMax Hailuo 3 — the provider states the per-clip bound in its own reject
1936
+ // text: "content[1].video_url: invalid param: video duration 52838 ms,
1937
+ // expected [2000, 15000] ms" (app-reports P4, 2 rows, the same clip retried).
1938
+ // The KIE reference-to-video doc adds the COMBINED cap — the same shape the
1939
+ // audio side already declares in SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER
1940
+ // above, whose h3 row cites the same page.
1941
+ "minimax-h3": { minSec: 2, maxSec: 15, maxTotalSec: 15 },
1942
+ }
1943
+
1944
+ /**
1945
+ * Validate probed reference-video durations against the provider's limits.
1946
+ *
1947
+ * Non-finite / non-positive entries are IGNORED, never rejected: the probe
1948
+ * helpers treat an unreadable clip as a worst-case duration for pricing, and
1949
+ * turning a failed ffprobe into a user-facing 400 would block legitimate runs
1950
+ * on a transient probe failure. Callers therefore pass the RAW per-URL probe
1951
+ * outcomes (a rejected ffprobe arrives here as NaN) — an ignored entry also
1952
+ * contributes nothing to the `maxTotalSec` sum, so a probe blip can never push
1953
+ * an otherwise-legal set over the total cap either.
1954
+ */
1955
+ export function checkRefVideoDurations(
1956
+ provider: string,
1957
+ durationsSec: readonly number[],
1958
+ ): { ok: true } | { ok: false; message: string } {
1959
+ const limit = VIDEO_REF_VIDEO_DURATION_LIMITS[provider]
1960
+ if (!limit) return { ok: true }
1961
+
1962
+ const usable = durationsSec.filter((d) => Number.isFinite(d) && d > 0)
1963
+ if (usable.length === 0) return { ok: true }
1964
+
1965
+ const offender = usable.find((d) => d < limit.minSec || d > limit.maxSec)
1966
+ if (offender !== undefined) {
1967
+ return {
1968
+ ok: false,
1969
+ message: `Each reference video must be between ${limit.minSec} and ${limit.maxSec} seconds — one is ${offender.toFixed(1)}s. Trim it (a Trim Video node upstream works) and run again.`,
1970
+ }
1971
+ }
1972
+
1973
+ if (limit.maxTotalSec !== undefined) {
1974
+ const total = usable.reduce((a, b) => a + b, 0)
1975
+ if (total > limit.maxTotalSec) {
1976
+ return {
1977
+ ok: false,
1978
+ message: `Reference videos must not exceed ${limit.maxTotalSec} seconds in total — these add up to ${total.toFixed(1)}s. Remove one or trim them and run again.`,
1979
+ }
1980
+ }
1981
+ }
1982
+
1983
+ return { ok: true }
1984
+ }
1985
+
1903
1986
  /**
1904
1987
  * Video models where credit cost depends on resolution AND whether a video
1905
1988
  * reference is connected. Identifier suffix: `:{resolution}[-ref]`.
@@ -2087,8 +2170,8 @@ export interface VideoAudioCapability {
2087
2170
  /**
2088
2171
  * Per-model audio capability. Only models that produce SOME audio are listed;
2089
2172
  * anything absent defaults to `{ mode: "none" }` via `getVideoAudioCapability`,
2090
- * so silent models (minimax, hailuo, wan, grok-i2v, gemini-omni-video, runway,
2091
- * pika, …) need no entry. New audio-capable models MUST be added here — the
2173
+ * so silent models (minimax, hailuo, wan-i2v, grok-i2v, runway, pika, …) need
2174
+ * no entry. New audio-capable models MUST be added here — the
2092
2175
  * `video-audio-capability` guard test cross-checks this map against the model
2093
2176
  * configs' `extraParams.sound` / `generate_audio` + VEO/Seedance-2 sets so a
2094
2177
  * forgotten entry fails CI rather than silently disabling audio.
@@ -2137,10 +2220,39 @@ export const VIDEO_AUDIO_CAPABILITY: Record<string, VideoAudioCapability> = {
2137
2220
  // and skip the lip-sync pass. `defaultOn` mirrors the KIE default so an
2138
2221
  // intent-less request is described honestly; audio is priced into the uniform
2139
2222
  // per-second rate, so NOT cost-affecting (no `:audio` composite).
2140
- // gemini-omni-flash is deliberately ABSENT — gemini-omni-video is absent too
2141
- // (mode "none"), and the siblings must not disagree.
2142
2223
  "wan-3": { mode: "ambient", field: "audio", defaultOn: true },
2143
2224
  "wan-3-prime": { mode: "ambient", field: "audio", defaultOn: true },
2225
+ // Gemini Omni (both SKUs — the pro `gemini-omni-video` and the faster
2226
+ // `gemini-omni-flash`; one model at two speeds, so their rows are identical
2227
+ // and a test pins that). Settled 2026-09-03 from Google's own documentation
2228
+ // at https://ai.google.dev/gemini-api/docs/omni, having been unlisted — and
2229
+ // therefore reported as SILENT — while the catalog described both as "native
2230
+ // audio".
2231
+ //
2232
+ // "ambient", and alwaysOn with no toggle field, on three sentences from that
2233
+ // page:
2234
+ // - "By default the model will try to generate an appropriate audio track
2235
+ // for a video." Audio on every render, and the KIE input schema
2236
+ // (prompt / image_urls / first+last_frame_url / audio_ids / video_list /
2237
+ // character_ids / duration / aspect_ratio / seed / resolution — see
2238
+ // docs.kie.ai/market/gemini-omni-video and
2239
+ // docs.kie.ai/market/google/gemini-omni-flash-1-1) carries no on/off
2240
+ // lever, so there is nothing for applyVideoAudioToggle to write.
2241
+ // - NOT native_speech: "Multi-turn voice extension: Generating spoken
2242
+ // dialogue or speech is supported when extending previously generated
2243
+ // videos via multi-turn (`previous_interaction_id`)" — a field KIE's
2244
+ // createTask schema does not expose, so the dialogue path is unreachable
2245
+ // on our transport. Held to the Wan 3.0 bar: no documented dialogue
2246
+ // guarantee on the path we actually drive ⇒ ambient, and upgrade only on
2247
+ // a live probe (the kling-3.0 standard). Classifying it native_speech
2248
+ // would reroute the Story→Video dialogue pipeline past the lip-sync pass.
2249
+ // - NOT audio_driven either: "Uploading audio references is unsupported in
2250
+ // the current version of the API", and "any audio in a video reference is
2251
+ // ignored" — there is no reference-audio transport to be driven by, and
2252
+ // runGeminiOmni never sends `audio_ids`.
2253
+ // Audio is priced into the per-tier rate, so NOT cost-affecting.
2254
+ "gemini-omni-video": { mode: "ambient", alwaysOn: true },
2255
+ "gemini-omni-flash": { mode: "ambient", alwaysOn: true },
2144
2256
  }
2145
2257
 
2146
2258
  const VIDEO_AUDIO_NONE: VideoAudioCapability = { mode: "none" }
package/src/node-refs.ts CHANGED
@@ -50,6 +50,10 @@ export function canonicalVarName(label: string): string {
50
50
  * SUPPRESS auto-injection of a connected node the author already placed
51
51
  * explicitly via `{label}` (so it isn't injected twice). `matchAll` over the
52
52
  * global pattern does not mutate its lastIndex, so sharing the constant is safe.
53
+ *
54
+ * Deliberately NOT `REF_TOKEN_NAMESPACE_PREFIXES`: widening this changes
55
+ * run-time auto-injection on BOTH engines (follow-up ticket), not just the
56
+ * editor.
53
57
  */
54
58
  export function extractReferencedLabels(
55
59
  ...texts: ReadonlyArray<string | undefined | null>
@@ -251,3 +255,81 @@ export function resolveNodeRefs(
251
255
  }
252
256
  return result
253
257
  }
258
+
259
+ /**
260
+ * Token namespaces that are NOT node-label references. `{image:1:face}` is the
261
+ * unified-reference grammar, `{slot:x}` is recast's, `{video:N}`/`{audio:N}`
262
+ * are reference handles, and `{ref:<id>}` is the id-addressed reference form —
263
+ * all five are substituted by their own resolvers (`resolveReferenceTokens`,
264
+ * `resolveRefIdTokens`, the recast slot pass), not by resolveNodeRefs. Before
265
+ * this list existed, only `image:` was excluded, so `{video:1}` classified as a
266
+ * MISSING node ref.
267
+ *
268
+ * Compared against the LOWERCASED token name: `REFERENCE_TOKEN_RE` is `/gi` and
269
+ * `REF_ID_TOKEN_RE` spells `[rR][eE][fF]`, so `{Image:1}` / `{Ref:x}` really do
270
+ * resolve downstream and must never read as a missing node ref.
271
+ */
272
+ export const REF_TOKEN_NAMESPACE_PREFIXES: readonly string[] = [
273
+ "image:", "video:", "audio:", "slot:", "ref:",
274
+ ]
275
+
276
+ export type RefTokenKind = "wired" | "reserved" | "missing" | "skip" | "unknown"
277
+
278
+ /**
279
+ * Classify a parsed `{...}` token name against the resolvable label set.
280
+ * `resolvable === null` means the caller has no ref data at all — such tokens
281
+ * classify `unknown` and must render like wired, so "no data" never
282
+ * masquerades as "nothing wired".
283
+ *
284
+ * Single source of truth for the editor decoration, the missing-refs chip
285
+ * (frontend/src/lib/prompt-ref-scan.ts delegates here) and the execution
286
+ * engine's dispatch guard.
287
+ */
288
+ export function classifyRefToken(
289
+ name: string,
290
+ resolvable: ReadonlySet<string> | null,
291
+ ): RefTokenKind {
292
+ const lower = name.toLowerCase()
293
+ if (name === "" || REF_TOKEN_NAMESPACE_PREFIXES.some((p) => lower.startsWith(p))) return "skip"
294
+ if (RESERVED_TEMPLATE_VARS.has(name)) return "reserved"
295
+ if (resolvable === null) return "unknown"
296
+ return resolvable.has(canonicalVarName(name)) ? "wired" : "missing"
297
+ }
298
+
299
+ /**
300
+ * The `{Label}` tokens in `text` that `resolveNodeRefs` would leave LITERAL and
301
+ * that no upstream node can explain — i.e. exactly what would reach a provider
302
+ * as the characters `{Label}`. Evidence for why this matters: `{Describe Image}`
303
+ * ×2 (gpt-image-2) and `{gravity flip}` / `{rewind}` (seedance-2-5) in the
304
+ * 2026-09-01 app-reports export.
305
+ *
306
+ * `resolvable` — labels with a value in the ref map (canonical/lowercase).
307
+ * `known` — canonical labels of the nodes the caller considers to EXIST
308
+ * (no state requirement; the backend passes every node in the
309
+ * run's graph).
310
+ *
311
+ * A token whose label is in `known` but not `resolvable` PASSES: the node
312
+ * exists and simply produced nothing, which is the caller's to substitute (the
313
+ * backend resolves it to empty text), not this function's to refuse. A token
314
+ * with an explicit `|| fallback` passes too — the fallback is substituted.
315
+ * Returns original-cased names, de-duplicated by canonical form, for the
316
+ * user-facing message.
317
+ */
318
+ export function unresolvedRefTokens(
319
+ text: string,
320
+ opts: { resolvable: ReadonlySet<string>; known: ReadonlySet<string> },
321
+ ): string[] {
322
+ if (typeof text !== "string" || text.length === 0) return []
323
+ const seen = new Set<string>()
324
+ const out: string[] = []
325
+ for (const m of text.matchAll(NODE_REF_PATTERN)) {
326
+ const { name, fallback } = parseNodeRef(m[1] ?? "")
327
+ if (fallback !== null) continue
328
+ if (classifyRefToken(name, opts.resolvable) !== "missing") continue
329
+ const canon = canonicalVarName(name)
330
+ if (opts.known.has(canon) || seen.has(canon)) continue
331
+ seen.add(canon)
332
+ out.push(name)
333
+ }
334
+ return out
335
+ }
@@ -16,6 +16,18 @@ export const EXECUTION_DATA_KEYS: ReadonlySet<string> = new Set([
16
16
  "currentJobId",
17
17
  "currentJobProgress",
18
18
  "errorMessage",
19
+ // Structured detail alongside errorMessage for a safety-filter block
20
+ // (see `JobErrorHint` in the app's frontend/src/types/nodes.ts). Same
21
+ // lifecycle as errorMessage: a RESULT the user expects to survive reload,
22
+ // never user-edited config.
23
+ "errorHint",
24
+ // A job policy registered by the deployment held this node's result for a
25
+ // human reviewer (`jobs.status = "pending_review"`). Pure run state, like
26
+ // executionStatus: the node is still "running" and the flag disappears the
27
+ // moment the review resolves — so it is ALSO in TRANSIENT_RUNTIME_KEYS
28
+ // below. Without the transient half, a flip into review marks a passive tab
29
+ // dirty and a preset captures "awaiting review".
30
+ "jobAwaitingReview",
19
31
  "isStreaming",
20
32
  "generatedImageUrl",
21
33
  "generatedVideoUrl",
@@ -80,6 +92,7 @@ export const TRANSIENT_RUNTIME_KEYS: ReadonlySet<string> = new Set([
80
92
  "executionStatus",
81
93
  "currentJobId",
82
94
  "currentJobProgress",
95
+ "jobAwaitingReview",
83
96
  "isStreaming",
84
97
  "subWorkflowProgress",
85
98
  "__listTotal",