@nodaro/shared 3.9.0 → 3.11.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.
Files changed (48) hide show
  1. package/dist/index.cjs +448 -51
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +959 -30
  4. package/dist/index.d.ts +959 -30
  5. package/dist/index.js +410 -52
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/credit-identifiers.test.ts +51 -1
  9. package/src/__tests__/gvp-supported-providers.test.ts +20 -3
  10. package/src/__tests__/model-catalog-sections.test.ts +59 -0
  11. package/src/__tests__/model-tree.test.ts +2 -2
  12. package/src/__tests__/parameter-node-value.test.ts +47 -0
  13. package/src/__tests__/pricing-default-duration.test.ts +45 -0
  14. package/src/__tests__/prompt-length-limits.test.ts +4 -0
  15. package/src/__tests__/scene3d-delivery-review.test.ts +512 -0
  16. package/src/__tests__/template-categories.test.ts +45 -0
  17. package/src/__tests__/video-analysis.test.ts +15 -0
  18. package/src/__tests__/video-frame-fit.test.ts +189 -0
  19. package/src/__tests__/video-ref-limits.test.ts +11 -2
  20. package/src/catalog-projection.ts +3 -0
  21. package/src/character-motion-metadata.ts +19 -0
  22. package/src/credit-identifiers.ts +44 -9
  23. package/src/i18n/character-motion.ar.ts +1082 -0
  24. package/src/i18n/character-motion.de.ts +1082 -0
  25. package/src/i18n/character-motion.es.ts +1082 -0
  26. package/src/i18n/character-motion.fr.ts +1082 -0
  27. package/src/i18n/character-motion.he.ts +1082 -0
  28. package/src/i18n/character-motion.hi.ts +1082 -0
  29. package/src/i18n/character-motion.ja.ts +1082 -0
  30. package/src/i18n/character-motion.ko.ts +1082 -0
  31. package/src/i18n/character-motion.pt-BR.ts +1082 -0
  32. package/src/i18n/character-motion.ru.ts +1082 -0
  33. package/src/i18n/character-motion.zh-CN.ts +1082 -0
  34. package/src/i18n/types.ts +1 -0
  35. package/src/index.ts +26 -0
  36. package/src/model-catalog.ts +49 -32
  37. package/src/model-constants.ts +50 -11
  38. package/src/node-execution-state.ts +95 -0
  39. package/src/parameter-node-value.ts +59 -0
  40. package/src/presentation-utils.ts +1 -0
  41. package/src/pro-3d-render.ts +159 -0
  42. package/src/scene3d-delivery-notes.ts +490 -0
  43. package/src/scene3d-v2-plan.ts +8 -2
  44. package/src/smart-cut-windows.ts +15 -10
  45. package/src/template-categories.ts +67 -0
  46. package/src/video-analysis.ts +15 -0
  47. package/src/video-frame-fit.ts +228 -0
  48. package/src/video-output-canvas.ts +119 -0
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ computeFrameFitPlan,
4
+ resolveFrameFitAspect,
5
+ resolveFrameDelivery,
6
+ minimalRatioDimensions,
7
+ centreCropToAspect,
8
+ parseAspectToken,
9
+ FRAME_FIT_STRETCH_TOLERANCE,
10
+ } from "../video-frame-fit.js"
11
+ import { resolveOutputCanvas, measuredCanvasCombinations } from "../video-output-canvas.js"
12
+
13
+ /** The image behind every number in this file: a 1K 9:16 GPT image. */
14
+ const GPT_1K = { sourceWidth: 940, sourceHeight: 1672 }
15
+
16
+ describe("measured output canvases", () => {
17
+ it("answers the combinations we measured, case-insensitively on resolution", () => {
18
+ expect(resolveOutputCanvas("seedance-2-5", "720p", "9:16")).toEqual([720, 1280])
19
+ expect(resolveOutputCanvas("minimax-h3", "768P", "9:16")).toEqual([768, 1344])
20
+ expect(resolveOutputCanvas("minimax-h3", "2K", "16:9")).toEqual([2560, 1440])
21
+ })
22
+
23
+ it("keeps the 2.0 family apart from 2.5 at 480p — they really do differ", () => {
24
+ expect(resolveOutputCanvas("seedance-2-5", "480p", "16:9")).toEqual([854, 480])
25
+ expect(resolveOutputCanvas("seedance-2-fast", "480p", "16:9")).toEqual([864, 496])
26
+ })
27
+
28
+ it("answers undefined for anything never measured", () => {
29
+ expect(resolveOutputCanvas("seedance-2-5", "4k", "9:16")).toBeUndefined()
30
+ expect(resolveOutputCanvas("kling-3.0", "720p", "16:9")).toBeUndefined()
31
+ expect(resolveOutputCanvas(undefined, "720p", "9:16")).toBeUndefined()
32
+ })
33
+
34
+ it("stores only even, positive dimensions", () => {
35
+ for (const { provider, resolution, aspect, canvas } of measuredCanvasCombinations()) {
36
+ const where = `${provider} ${resolution} ${aspect}`
37
+ expect(canvas[0] % 2, where).toBe(0)
38
+ expect(canvas[1] % 2, where).toBe(0)
39
+ expect(canvas[0] > 0 && canvas[1] > 0, where).toBe(true)
40
+ }
41
+ })
42
+
43
+ it("does not claim a canvas whose ratio is wildly off the label", () => {
44
+ // H3's 768P 9:16 really is 0.5714 — a 1.6% lie we keep on purpose. Anything
45
+ // further out is a typo, not a provider quirk.
46
+ for (const { provider, resolution, aspect, canvas } of measuredCanvasCombinations()) {
47
+ const label = parseAspectToken(aspect)
48
+ if (label === undefined) continue
49
+ const actual = canvas[0] / canvas[1]
50
+ expect(Math.abs(actual - label) / label, `${provider} ${resolution} ${aspect}`).toBeLessThan(0.06)
51
+ }
52
+ })
53
+ })
54
+
55
+ describe("aspect resolution", () => {
56
+ it("uses an explicit ratio as-is", () => {
57
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "16:9", ...GPT_1K })).toBe("16:9")
58
+ })
59
+
60
+ it("snaps adaptive and Auto to the model's nearest listed ratio", () => {
61
+ // 940x1672 = 0.5622, which is 9:16 to within 0.05%.
62
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "adaptive", ...GPT_1K })).toBe("9:16")
63
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "Auto", ...GPT_1K })).toBe("9:16")
64
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: undefined, sourceWidth: 1920, sourceHeight: 1080 })).toBe("16:9")
65
+ })
66
+
67
+ it("gives up when the model declares no ratios", () => {
68
+ expect(resolveFrameFitAspect({ provider: "not-a-model", requestedAspect: "adaptive", ...GPT_1K })).toBeUndefined()
69
+ })
70
+ })
71
+
72
+ describe("computeFrameFitPlan", () => {
73
+ it("resizes the 1K image to the measured canvas — the case that fixed the snap", () => {
74
+ const plan = computeFrameFitPlan({
75
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16", ...GPT_1K,
76
+ })
77
+ expect(plan).toEqual({ width: 720, height: 1280, reason: "resolution" })
78
+ })
79
+
80
+ it("targets H3's real 768x1344 canvas rather than a true 9:16", () => {
81
+ const plan = computeFrameFitPlan({
82
+ fit: "resolution", provider: "minimax-h3", resolution: "768P", aspect: "9:16", ...GPT_1K,
83
+ })
84
+ expect(plan?.width).toBe(768)
85
+ expect(plan?.height).toBe(1344)
86
+ expect(plan?.crop).toBeUndefined() // 1.6% gap is inside the tolerance → stretch
87
+ })
88
+
89
+ it("does nothing when the frame is already the canvas", () => {
90
+ expect(computeFrameFitPlan({
91
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
92
+ sourceWidth: 720, sourceHeight: 1280,
93
+ })).toBeNull()
94
+ })
95
+
96
+ it("does nothing in original mode, whatever the size", () => {
97
+ expect(computeFrameFitPlan({
98
+ fit: "original", provider: "seedance-2-5", resolution: "720p", aspect: "9:16", ...GPT_1K,
99
+ })).toBeNull()
100
+ })
101
+
102
+ it("degrades resolution → ratio when the combination was never measured", () => {
103
+ const plan = computeFrameFitPlan({
104
+ fit: "resolution", provider: "seedance-2-5", resolution: "4k", aspect: "16:9",
105
+ sourceWidth: 1000, sourceHeight: 1000,
106
+ })
107
+ // No 4k canvas on file, so it falls back to the minimal change that makes 16:9.
108
+ expect(plan?.reason).toBe("ratio")
109
+ expect(plan!.width / plan!.height).toBeCloseTo(16 / 9, 2)
110
+ })
111
+
112
+ it("degrades to nothing when neither a canvas nor an aspect can be resolved", () => {
113
+ expect(computeFrameFitPlan({
114
+ fit: "resolution", provider: "not-a-model", resolution: "720p", aspect: undefined, ...GPT_1K,
115
+ })).toBeNull()
116
+ })
117
+
118
+ it("stretches inside the tolerance and crops outside it", () => {
119
+ // 4% off 9:16 — inside 5%, so a plain stretch, no crop.
120
+ const inside = computeFrameFitPlan({
121
+ fit: "ratio", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
122
+ sourceWidth: 1000, sourceHeight: 1710,
123
+ })
124
+ expect(Math.abs(1000 / 1710 - 9 / 16) / (9 / 16)).toBeLessThan(FRAME_FIT_STRETCH_TOLERANCE)
125
+ expect(inside?.crop).toBeUndefined()
126
+
127
+ // A square photo into 9:16 is a 78% gap — crop first, never squash.
128
+ const outside = computeFrameFitPlan({
129
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
130
+ sourceWidth: 1024, sourceHeight: 1024,
131
+ })
132
+ expect(outside).toMatchObject({ width: 720, height: 1280, reason: "resolution" })
133
+ expect(outside?.crop).toEqual({ left: 224, top: 0, width: 576, height: 1024 })
134
+ expect(outside!.crop!.width / outside!.crop!.height).toBeCloseTo(9 / 16, 3)
135
+ })
136
+
137
+ it("produces even dimensions (yuv420p rejects odd ones)", () => {
138
+ const plan = computeFrameFitPlan({
139
+ fit: "ratio", provider: "seedance-2-5", resolution: "720p", aspect: "16:9",
140
+ sourceWidth: 1001, sourceHeight: 667,
141
+ })
142
+ expect(plan!.width % 2).toBe(0)
143
+ expect(plan!.height % 2).toBe(0)
144
+ })
145
+ })
146
+
147
+ describe("geometry helpers", () => {
148
+ it("minimalRatioDimensions keeps the long side and moves the short one", () => {
149
+ // 940 wide is 9:16 at 1671.1 tall, which rounds back to the image's own
150
+ // 1672 — the 1K GPT image really is 9:16 to the nearest even pixel, so
151
+ // "match ratio" alone would leave it untouched (and the snap would stay).
152
+ expect(minimalRatioDimensions(940, 1672, 9 / 16)).toEqual({ width: 940, height: 1672 })
153
+ expect(minimalRatioDimensions(1920, 1000, 16 / 9)).toEqual({ width: 1920, height: 1080 })
154
+ })
155
+
156
+ it("centreCropToAspect drops the overhang evenly", () => {
157
+ expect(centreCropToAspect(1024, 1024, 9 / 16)).toEqual({ left: 224, top: 0, width: 576, height: 1024 })
158
+ expect(centreCropToAspect(1000, 1000, 16 / 9)).toEqual({ left: 0, top: 219, width: 1000, height: 562 })
159
+ })
160
+
161
+ it("parseAspectToken reads the tokens the catalog uses", () => {
162
+ expect(parseAspectToken("16:9")).toBeCloseTo(16 / 9, 6)
163
+ expect(parseAspectToken("9:16")).toBeCloseTo(9 / 16, 6)
164
+ expect(parseAspectToken("adaptive")).toBeUndefined()
165
+ expect(parseAspectToken(undefined)).toBeUndefined()
166
+ })
167
+ })
168
+
169
+ describe("frame delivery", () => {
170
+ it("sends the Seedance 2.0 family as references and everything else as frames", () => {
171
+ const ref = { requested: "auto" as const, supportsReferenceImages: true }
172
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", ...ref })).toBe("reference")
173
+ expect(resolveFrameDelivery({ provider: "seedance-2", ...ref })).toBe("reference")
174
+ expect(resolveFrameDelivery({ provider: "seedance-2-mini", ...ref })).toBe("reference")
175
+ expect(resolveFrameDelivery({ provider: "seedance-2-5", ...ref })).toBe("frame")
176
+ expect(resolveFrameDelivery({ provider: "wan-3", ...ref })).toBe("frame")
177
+ expect(resolveFrameDelivery({ provider: "veo3.1", ...ref })).toBe("frame")
178
+ })
179
+
180
+ it("honours an explicit choice", () => {
181
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", requested: "frame", supportsReferenceImages: true })).toBe("frame")
182
+ expect(resolveFrameDelivery({ provider: "veo3.1", requested: "reference", supportsReferenceImages: true })).toBe("reference")
183
+ })
184
+
185
+ it("never asks for reference delivery on a model that takes no references", () => {
186
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", requested: "auto", supportsReferenceImages: false })).toBe("frame")
187
+ expect(resolveFrameDelivery({ provider: "wan-i2v", requested: "reference", supportsReferenceImages: false })).toBe("frame")
188
+ })
189
+ })
@@ -57,11 +57,20 @@ describe("VIDEO_REF_LIMITS_BY_PROVIDER ⇄ MODEL_CATALOG drift guard", () => {
57
57
  })
58
58
 
59
59
  describe("VIDEO_REF_LIMITS_BY_PROVIDER caps for the newly-covered models", () => {
60
- it("VEO family carries images:3 (the REFERENCE_2_VIDEO slice cap in kie/video.ts)", () => {
61
- expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3"]).toEqual({ images: 3 })
60
+ it("the ref-capable VEO SKUs carry images:3 (the REFERENCE_2_VIDEO slice cap in kie/video.ts)", () => {
62
61
  expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3.1"]).toEqual({ images: 3 })
63
62
  expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3_lite"]).toEqual({ images: 3 })
64
63
  })
64
+
65
+ it("veo3 (VEO 3.1 QUALITY) carries NO cap — KIE serves reference-to-video on Fast/Lite only", () => {
66
+ // Its own words on a production job (2026-09-04, app-reports lane G):
67
+ // "Reference to video only supports the Veo Fast model and Veo Lite
68
+ // model." Until 2026-09-15 this entry claimed images:3, so every
69
+ // reference-carrying Quality run was a 422 after the credits were
70
+ // reserved. Absent ⇒ 0 everywhere the map is read.
71
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3"]).toBeUndefined()
72
+ expect(MODEL_CATALOG["veo3"]?.features).not.toContain("reference-image")
73
+ })
65
74
  it("kling-3-omni and grok-i2v carry images:7", () => {
66
75
  expect(VIDEO_REF_LIMITS_BY_PROVIDER["kling-3-omni"]).toEqual({ images: 7 })
67
76
  expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok-i2v"]).toEqual({ images: 7 })
@@ -1,3 +1,4 @@
1
+ import type { CharacterMotionMetadata } from "./character-motion-metadata.js"
1
2
  /**
2
3
  * Tag-free, policy-free wire shape for the `GET /v1/catalogs` projection — the
3
4
  * server-driven, pack-composed catalog view thin clients render their own
@@ -21,6 +22,8 @@ export interface ProjectedCatalogOption {
21
22
  */
22
23
  term?: string
23
24
  icon?: string
25
+ /** Authored Character Motion prerequisites and sequence state. Missing means unknown. */
26
+ motion?: CharacterMotionMetadata
24
27
  }
25
28
 
26
29
  export interface ProjectedCatalogDimension {
@@ -0,0 +1,19 @@
1
+ /** Structural public catalog API metadata. Authored values remain in @nodaro/prompts. */
2
+ export interface CharacterMotionMetadata {
3
+ /** Search terms, including previous display names. IDs remain stable. */
4
+ readonly aliases?: readonly string[]
5
+ /** Hidden from new choices; saved workflows still resolve this entry. */
6
+ readonly deprecated?: true
7
+ readonly replacementId?: string
8
+ /** Authored prerequisites; omission is unknown, never a compatibility claim. */
9
+ readonly requires?: readonly string[]
10
+ readonly startPose?: "standing" | "seated" | "floor" | "any"
11
+ readonly endPose?: "standing" | "seated" | "floor" | "any"
12
+ readonly endVisibility?: "in-frame" | "out-of-frame"
13
+ readonly handsAfter?: "free" | "occupied" | "holding-partner"
14
+ readonly needsFreeHands?: true
15
+ readonly kind?: "single" | "compound"
16
+ readonly fixedPace?: true
17
+ /** Non-human/dependent recipient substituted through the Partner handle. */
18
+ readonly counterpart?: string
19
+ }
@@ -15,7 +15,7 @@ import {
15
15
  RESOLUTION_DURATION_PRICING,
16
16
  VEO_RESOLUTION_TIERED_PROVIDERS,
17
17
  VIDEO_DURATION_TIERS,
18
- PRICING_DEFAULT_DURATION_SEC,
18
+ pricedOutputDurationSec,
19
19
  PRICING_DEFAULT_RESOLUTION,
20
20
  MOTION_DURATION_TIERS,
21
21
  T2I_TO_I2I_VARIANT,
@@ -27,8 +27,44 @@ import {
27
27
  normalizeWan3Resolution,
28
28
  getVideoAudioCapability,
29
29
  } from "./model-constants.js"
30
- import { isFlux2Model } from "./flux2-pricing.js"
31
- import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
30
+ import { isFlux2Model, FLUX2_RES_MP, type Flux2Model } from "./flux2-pricing.js"
31
+ import { MODEL_CATALOG, normalizeModelInput, defaultResolutionFor, type ModelInputAdjustment } from "./model-catalog.js"
32
+
33
+ /**
34
+ * The megapixel tier a Flux 2 credit identifier is keyed on, for ANY incoming
35
+ * `resolution` value.
36
+ *
37
+ * Flux 2 is the only family whose identifier INTERPOLATES the resolution
38
+ * instead of matching it against a known set, and its callers are not
39
+ * guaranteed to hand it a Flux 2 value:
40
+ * - the multi-provider cost preview prices one node's data against EVERY
41
+ * selected provider (`frontend/src/ee/hooks/use-providers-credits-sum.ts`),
42
+ * so a node whose `resolution` is "2K" (the flux / nano-banana-pro value
43
+ * space) reaches this branch verbatim;
44
+ * - node data written straight into workflow JSON by an agent, an import or
45
+ * a template never ran the config panel's provider-change fail-safe.
46
+ * Interpolating that produced the off-grid id `flux-2-pro:2KMP:0ref`, which no
47
+ * pricing row can answer — every cost badge 503'd `price_not_configured`
48
+ * (18 production app-reports, 2026-09-07..14).
49
+ *
50
+ * A value off the grid snaps to the model's DEFAULT tier — the same `preferred`
51
+ * value `normalizeModelInput` uses — so the preview asks for exactly the id the
52
+ * route will reserve (both routes and the orchestrator normalize through
53
+ * `resolveNormalizedImageGen` first, which makes this snap a no-op for them).
54
+ * An ABSENT resolution keeps its long-standing meaning ("this caller has no
55
+ * resolution dimension at all") and stays on 1 MP rather than moving to the
56
+ * model default, which would silently re-price every resolution-less node.
57
+ */
58
+ function flux2MegapixelTier(model: Flux2Model, resolution?: string): string {
59
+ const bare = (v: string) => v.replace(/\s*MP$/i, "").trim()
60
+ const raw = typeof resolution === "string" ? bare(resolution) : ""
61
+ if (raw === "") return "1"
62
+ // Numeric match so "2.0"/" 2 MP" land on the same grid point as "2", and the
63
+ // GRID's own spelling is what gets emitted — returning the caller's "2.0"
64
+ // would build ":2.0MP:", another id no pricing row carries.
65
+ const tier = FLUX2_RES_MP.find((t) => Number(t) === Number(raw))
66
+ return tier ?? bare(defaultResolutionFor(model) ?? "1 MP")
67
+ }
32
68
 
33
69
  /**
34
70
  * Compute composite model identifier for variable credit pricing.
@@ -54,8 +90,7 @@ export function buildCreditModelIdentifier(
54
90
  // their cost formula charges per input MP, so the reserved identifier must
55
91
  // reflect refs (there is no metered true-up to correct an under-reserved tier).
56
92
  if (isFlux2Model(provider)) {
57
- const mp = (resolution ?? "1 MP").replace(/\s*MP$/i, "").trim()
58
- return `${provider}:${mp}MP:${Math.min(referenceImageCount ?? 0, 8)}ref`
93
+ return `${provider}:${flux2MegapixelTier(provider, resolution)}MP:${Math.min(referenceImageCount ?? 0, 8)}ref`
59
94
  }
60
95
  if (HIGH_QUALITY_PROVIDERS.has(provider) && quality === "high") {
61
96
  return `${provider}:high`
@@ -329,10 +364,10 @@ export function buildVideoCreditModelIdentifier(
329
364
 
330
365
  // A named-provider request with NO duration renders the model's own default
331
366
  // (kie/models.ts extraParams), so price that default — not the global 5s —
332
- // for providers whose per-second tiers wouldn't snap 5 up to it (minimax-h3).
333
- const durationFallback = PRICING_DEFAULT_DURATION_SEC[effectiveProvider] ?? 5
334
- const parsed = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? durationFallback)
335
- const durationSec = Number.isNaN(parsed) ? durationFallback : parsed
367
+ // for providers whose per-second tiers wouldn't snap 5 up to it
368
+ // (minimax-h3, seedance-2-5). One helper, shared with the dynamic
369
+ // reference-video reservations, so the tier and the scaled reserve agree.
370
+ const durationSec = pricedOutputDurationSec(effectiveProvider, duration)
336
371
  const tiers = VIDEO_DURATION_TIERS[effectiveProvider]
337
372
  if (!tiers) return effectiveProvider
338
373