@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.
- package/dist/index.cjs +176 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +220 -21
- package/dist/index.d.ts +220 -21
- package/dist/index.js +167 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/effective-tier.test.ts +116 -0
- 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 +3 -2
- package/src/__tests__/seedance-2-5-catalog.test.ts +16 -8
- package/src/effective-tier.ts +72 -0
- package/src/entity-image-handle.ts +37 -3
- package/src/group-aggregation.ts +32 -0
- package/src/index.ts +9 -0
- package/src/llm-models.ts +5 -0
- package/src/model-catalog.ts +48 -3
- package/src/model-constants.ts +29 -1
- package/src/node-default-mappings.ts +3 -2
- package/src/pipeline-defaults.ts +2 -0
- package/src/reduce-strategy-registry.ts +31 -13
- package/src/video-analysis.ts +70 -0
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
resolveStoredTier,
|
|
4
|
+
resolveEffectiveTier,
|
|
5
|
+
isPaygRetentionActive,
|
|
6
|
+
PAYG_RETENTION_DAYS,
|
|
7
|
+
} from "../effective-tier.js"
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
const NOW = new Date("2026-08-12T12:00:00Z")
|
|
11
|
+
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY_MS)
|
|
12
|
+
|
|
13
|
+
describe("resolveStoredTier", () => {
|
|
14
|
+
it("prefers tier, falls back to subscription_tier, then free", () => {
|
|
15
|
+
expect(resolveStoredTier({ tier: "pro", subscription_tier: "basic" })).toBe("pro")
|
|
16
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: "basic" })).toBe("basic")
|
|
17
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: null })).toBe("free")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("resolveEffectiveTier — the payg derivation matrix (design §9)", () => {
|
|
22
|
+
it("never-paid free user stays free", () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
25
|
+
).toBe("free")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("free user with NET lifetime > 0 derives payg — even at zero current balance", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 3300 })
|
|
31
|
+
).toBe("payg")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("refunded-to-zero user drops back to free (NET lifetime)", () => {
|
|
35
|
+
expect(
|
|
36
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
37
|
+
).toBe("free")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("#489 pre-subscribe carryover fixture: topup balance without purchase stays free", () => {
|
|
41
|
+
// Cancel-path carryover moves a pre-subscribe balance into topup_credits
|
|
42
|
+
// with NO purchase — lifetime stays 0, so the user must NOT derive payg.
|
|
43
|
+
expect(
|
|
44
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
45
|
+
).toBe("free")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("every stored paid tier passes through untouched, regardless of lifetime", () => {
|
|
49
|
+
for (const t of ["basic", "standard", "pro", "business"]) {
|
|
50
|
+
expect(
|
|
51
|
+
resolveEffectiveTier({ tier: t, subscription_tier: null, lifetime_topup_credits: 9999 })
|
|
52
|
+
).toBe(t)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("subscription_tier-only legacy rows resolve through the stored fallback", () => {
|
|
57
|
+
expect(
|
|
58
|
+
resolveEffectiveTier({ tier: null, subscription_tier: "standard", lifetime_topup_credits: 500 })
|
|
59
|
+
).toBe("standard")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("null-tier never-paid rows resolve free; with lifetime they derive payg", () => {
|
|
63
|
+
expect(
|
|
64
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 0 })
|
|
65
|
+
).toBe("free")
|
|
66
|
+
expect(
|
|
67
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 100 })
|
|
68
|
+
).toBe("payg")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("isPaygRetentionActive — 90-day activity window boundaries", () => {
|
|
73
|
+
it("exports the 90-day constant", () => {
|
|
74
|
+
expect(PAYG_RETENTION_DAYS).toBe(90)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("purchase activity: 89d active, 90d active (inclusive), 91d inactive", () => {
|
|
78
|
+
const base = { lifetimeTopupCredits: 100, lastSpendAt: null }
|
|
79
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(89) }, NOW)).toBe(true)
|
|
80
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(90) }, NOW)).toBe(true)
|
|
81
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(91) }, NOW)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("spend activity counts on its own (usage_logs MAX, not balance polls)", () => {
|
|
85
|
+
const base = { lifetimeTopupCredits: 100, lastTopupAt: daysAgo(200) }
|
|
86
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(10) }, NOW)).toBe(true)
|
|
87
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(91) }, NOW)).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("either source alone is sufficient; the most recent wins", () => {
|
|
91
|
+
expect(
|
|
92
|
+
isPaygRetentionActive(
|
|
93
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(120), lastSpendAt: daysAgo(5) },
|
|
94
|
+
NOW
|
|
95
|
+
)
|
|
96
|
+
).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("string timestamps (supabase rows) are accepted", () => {
|
|
100
|
+
expect(
|
|
101
|
+
isPaygRetentionActive(
|
|
102
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(5).toISOString(), lastSpendAt: null },
|
|
103
|
+
NOW
|
|
104
|
+
)
|
|
105
|
+
).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("all-null activity is inactive; never-paid users are never retention-active", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 100, lastTopupAt: null, lastSpendAt: null }, NOW)
|
|
111
|
+
).toBe(false)
|
|
112
|
+
expect(
|
|
113
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 0, lastTopupAt: daysAgo(1), lastSpendAt: null }, NOW)
|
|
114
|
+
).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, sourceRefKey } from "../entity-image-handle.js"
|
|
2
|
+
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, AGGREGATE_LANE_SOURCE_TYPES, sourceRefKey } from "../entity-image-handle.js"
|
|
3
3
|
|
|
4
4
|
const ENTITY_REF_HANDLE: Record<string, string> = {
|
|
5
5
|
character: "characterRef",
|
|
@@ -62,3 +62,35 @@ describe("sourceRefKey (handle-scoped ref key — prevents node-id collision)",
|
|
|
62
62
|
expect(sourceRefKey("n1", undefined, "character")).toBe("n1")
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
describe("resolveEffectiveSourceType (aggregate lane handle → plain producer of that type)", () => {
|
|
67
|
+
const LANE_TO_PRODUCER: Record<string, string> = {
|
|
68
|
+
"out-image": "upload-image",
|
|
69
|
+
"out-video": "upload-video",
|
|
70
|
+
"out-audio": "upload-audio",
|
|
71
|
+
"out-text": "list",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("remaps every Collect / Group lane to the plain producer of its media type", () => {
|
|
75
|
+
for (const agg of AGGREGATE_LANE_SOURCE_TYPES) {
|
|
76
|
+
for (const [lane, producer] of Object.entries(LANE_TO_PRODUCER)) {
|
|
77
|
+
expect(resolveEffectiveSourceType(agg, lane)).toBe(producer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("leaves a non-lane / missing handle as the raw aggregate type", () => {
|
|
83
|
+
expect(resolveEffectiveSourceType("collect", "out")).toBe("collect")
|
|
84
|
+
expect(resolveEffectiveSourceType("collect", undefined)).toBe("collect")
|
|
85
|
+
expect(resolveEffectiveSourceType("group", null)).toBe("group")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("does not remap lane-shaped handles on non-aggregate producers", () => {
|
|
89
|
+
expect(resolveEffectiveSourceType("generate-image", "out-image")).toBe("generate-image")
|
|
90
|
+
expect(resolveEffectiveSourceType("list", "out-text")).toBe("list")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("AGGREGATE_LANE_SOURCE_TYPES is exactly group + collect", () => {
|
|
94
|
+
expect([...AGGREGATE_LANE_SOURCE_TYPES].sort()).toEqual(["collect", "group"])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
groupHandleId,
|
|
6
6
|
parseGroupHandle,
|
|
7
7
|
isAggregateableType,
|
|
8
|
+
computeAggregateLanes,
|
|
8
9
|
AGGREGATEABLE_TYPES,
|
|
10
|
+
type AggregationBuckets,
|
|
9
11
|
type Member,
|
|
10
12
|
} from "../group-aggregation.js"
|
|
11
13
|
|
|
@@ -81,3 +83,40 @@ describe("isAggregateableType", () => {
|
|
|
81
83
|
expect(isAggregateableType(undefined)).toBe(false)
|
|
82
84
|
})
|
|
83
85
|
})
|
|
86
|
+
|
|
87
|
+
describe("computeAggregateLanes", () => {
|
|
88
|
+
const EMPTY: AggregationBuckets = { text: [], image: [], video: [], audio: [] }
|
|
89
|
+
|
|
90
|
+
it("returns empty when nothing is wired, bucketed, or referenced", () => {
|
|
91
|
+
expect(computeAggregateLanes("c1", [], EMPTY, [])).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("exposes lanes for wired types even with empty buckets (pre-run)", () => {
|
|
95
|
+
expect(computeAggregateLanes("c1", ["image", "image"], EMPTY, [])).toEqual(["image"])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("exposes lanes for bucket contents (post-run)", () => {
|
|
99
|
+
const buckets: AggregationBuckets = { text: ["t"], image: [], video: [], audio: [] }
|
|
100
|
+
expect(computeAggregateLanes("c1", [], buckets, [])).toEqual(["text"])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("keeps a lane alive when an outgoing edge references it", () => {
|
|
104
|
+
const edges = [{ source: "c1", sourceHandle: "out-video" }]
|
|
105
|
+
expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual(["video"])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("ignores outgoing edges of other nodes and non-lane handles", () => {
|
|
109
|
+
const edges = [
|
|
110
|
+
{ source: "other", sourceHandle: "out-video" },
|
|
111
|
+
{ source: "c1", sourceHandle: "out" },
|
|
112
|
+
{ source: "c1", sourceHandle: null },
|
|
113
|
+
]
|
|
114
|
+
expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("unions all three sources and orders by AGGREGATEABLE_TYPES", () => {
|
|
118
|
+
const buckets: AggregationBuckets = { text: [], image: [], video: [], audio: ["a"] }
|
|
119
|
+
const edges = [{ source: "c1", sourceHandle: "out-video" }]
|
|
120
|
+
expect(computeAggregateLanes("c1", ["text"], buckets, edges)).toEqual(["text", "video", "audio"])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { inferMusicVideo, videoAnalysisResultSchema, VIDEO_ANALYSIS_TIMES_OF_DAY, VIDEO_ANALYSIS_STORY_JUMPS } from "../video-analysis"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inference decides whether a recast takes the original soundtrack AS-IS
|
|
6
|
+
* (no stem separation). Server (recast route mode derivation) and client
|
|
7
|
+
* (prep pricing + the generate-time mode-mismatch guard) both call THIS
|
|
8
|
+
* function, so its exact boundary behaviour is contract, not detail.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const music = (content: string) => ({ mode: "music" as const, content })
|
|
12
|
+
const scene = (audio: Array<{ mode: string; content: string }>) => ({ audio })
|
|
13
|
+
const vocalScenes = (n: number) => Array.from({ length: n }, () => scene([music("upbeat pop track with sung lyrics")]))
|
|
14
|
+
|
|
15
|
+
describe("inferMusicVideo", () => {
|
|
16
|
+
it("true when ≥80% of ≥4 scenes carry music and vocals are evidenced", () => {
|
|
17
|
+
expect(inferMusicVideo({ scenes: vocalScenes(5) })).toBe(true)
|
|
18
|
+
// 4 of 5 with music (80% exactly), one vocal layer
|
|
19
|
+
expect(inferMusicVideo({ scenes: [...vocalScenes(4), scene([])] })).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("quoted lyric text inside a music layer is vocal evidence on its own", () => {
|
|
23
|
+
const scenes = Array.from({ length: 4 }, () => scene([music('gentle track, "la la la, choosing you tonight"')]))
|
|
24
|
+
expect(inferMusicVideo({ scenes })).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("false below the coverage bar, the scene floor, or with no vocal evidence", () => {
|
|
28
|
+
expect(inferMusicVideo({ scenes: [...vocalScenes(3), scene([]), scene([])] })).toBe(false) // 60%
|
|
29
|
+
expect(inferMusicVideo({ scenes: vocalScenes(3) })).toBe(false) // < 4 scenes
|
|
30
|
+
const instrumentalOnly = Array.from({ length: 5 }, () => scene([music("sweeping orchestral score")]))
|
|
31
|
+
expect(inferMusicVideo({ scenes: instrumentalOnly })).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("negated vocals ('instrumental', 'no vocals') are not evidence; sfx/speech layers never are", () => {
|
|
35
|
+
const negated = Array.from({ length: 5 }, () => scene([music("instrumental version of the song, no vocals")]))
|
|
36
|
+
expect(inferMusicVideo({ scenes: negated })).toBe(false)
|
|
37
|
+
const speechy = Array.from({ length: 5 }, () => scene([{ mode: "speech", content: "she sings later" }, music("soft bed")]))
|
|
38
|
+
expect(inferMusicVideo({ scenes: speechy })).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("bare 'song'/'music' descriptions are NOT vocal evidence (instrumental beds are described as songs)", () => {
|
|
42
|
+
const bare = Array.from({ length: 5 }, () => scene([music("upbeat pop song under the action")]))
|
|
43
|
+
expect(inferMusicVideo({ scenes: bare })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("throw-proof on malformed or absent input", () => {
|
|
47
|
+
expect(inferMusicVideo(undefined)).toBe(false)
|
|
48
|
+
expect(inferMusicVideo(null)).toBe(false)
|
|
49
|
+
expect(inferMusicVideo({})).toBe(false)
|
|
50
|
+
expect(inferMusicVideo({ scenes: [{}, { audio: [{}] }, {}, {}] } as never)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("chronicle-time fields (2.6.0)", () => {
|
|
55
|
+
const base = {
|
|
56
|
+
startSec: 0, endSec: 2, label: "l", shotType: "Wide", camera: "static",
|
|
57
|
+
visual: "a scene", audio: [], sceneNumber: 1, visualResolved: "a scene", slotRefs: [],
|
|
58
|
+
}
|
|
59
|
+
const result = (sceneOver: Record<string, unknown>) => ({
|
|
60
|
+
meta: { durationSec: 2, width: 10, height: 10, aspectRatio: "1:1" },
|
|
61
|
+
slots: [],
|
|
62
|
+
scenes: [{ ...base, ...sceneOver }],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("are optional (pre-2.6.0 analyses parse unchanged) and enum-validated when present", () => {
|
|
66
|
+
expect(videoAnalysisResultSchema.safeParse(result({})).success).toBe(true)
|
|
67
|
+
expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "dusk", storyJump: "years-later" })).success).toBe(true)
|
|
68
|
+
expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "noon" })).success).toBe(false)
|
|
69
|
+
expect(videoAnalysisResultSchema.safeParse(result({ storyJump: "later" })).success).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("the enums stay congruence-safe for the window decode grammar (no ints, no maxItems)", () => {
|
|
73
|
+
expect(VIDEO_ANALYSIS_TIMES_OF_DAY).toContain("ambiguous")
|
|
74
|
+
expect(VIDEO_ANALYSIS_STORY_JUMPS).toContain("years-later")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -382,10 +382,11 @@ describe("LLM_FEATURE_DEFAULTS", () => {
|
|
|
382
382
|
"generate-script",
|
|
383
383
|
"translate",
|
|
384
384
|
"image-critic",
|
|
385
|
+
"pick-best-llm",
|
|
385
386
|
]
|
|
386
387
|
|
|
387
|
-
it("has entries for all
|
|
388
|
-
expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(
|
|
388
|
+
it("has entries for all 16 features", () => {
|
|
389
|
+
expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(16)
|
|
389
390
|
for (const feature of ALL_FEATURES) {
|
|
390
391
|
expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
|
|
391
392
|
}
|
|
@@ -23,10 +23,14 @@ const ID = "seedance-2-5"
|
|
|
23
23
|
* api.kie.ai on 2026-08-08, NOT by the published schema — KIE's docs advertise
|
|
24
24
|
* a wider surface than the proxy actually accepts. These tests pin the probed
|
|
25
25
|
* reality so a future "the spec sheet says 4K/180s" edit has to re-probe first.
|
|
26
|
+
*
|
|
27
|
+
* Re-probed 2026-08-17 (KIE "Seedance 2.5 now supports 1080P" release):
|
|
28
|
+
* 1080p passes resolution validation; 1440p/2k/4k are still rejected with
|
|
29
|
+
* "not within the range of allowed options", and duration 31 is still refused.
|
|
26
30
|
*/
|
|
27
|
-
describe("seedance-2-5 catalog (probe-verified 2026-08-08)", () => {
|
|
28
|
-
it("is 480p/720p
|
|
29
|
-
expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p"])
|
|
31
|
+
describe("seedance-2-5 catalog (probe-verified 2026-08-08, re-probed 2026-08-17)", () => {
|
|
32
|
+
it("is 480p/720p/1080p — KIE still rejects 4k/2k/1440p the same way it rejects a nonsense value", () => {
|
|
33
|
+
expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p", "1080p"])
|
|
30
34
|
})
|
|
31
35
|
|
|
32
36
|
it("runs 4-30s contiguously — 30 is the probed ceiling (31 was rejected)", () => {
|
|
@@ -101,9 +105,13 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
101
105
|
}
|
|
102
106
|
})
|
|
103
107
|
|
|
108
|
+
it("prices 1080p at its own tier — a supported resolution must never clamp away", () => {
|
|
109
|
+
expect(build(8, "1080p")).toBe("seedance-2-5:8s:1080p")
|
|
110
|
+
expect(build(30, "1080p")).toBe("seedance-2-5:30s:1080p")
|
|
111
|
+
})
|
|
112
|
+
|
|
104
113
|
it("clamps an unsupported resolution to the top real tier so the id is always seeded", () => {
|
|
105
|
-
expect(build(8, "
|
|
106
|
-
expect(build(8, "4k")).toBe("seedance-2-5:8s:720p")
|
|
114
|
+
expect(build(8, "4k")).toBe("seedance-2-5:8s:1080p")
|
|
107
115
|
})
|
|
108
116
|
|
|
109
117
|
it("selects the cheaper -ref ladder when a reference video is wired", () => {
|
|
@@ -114,7 +122,7 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
114
122
|
it("resolves every catalog duration x resolution x ref-mode to a distinct tier", () => {
|
|
115
123
|
const seen = new Set<string>()
|
|
116
124
|
for (const d of MODEL_CATALOG[ID].durations!) {
|
|
117
|
-
for (const res of ["480p", "720p"]) {
|
|
125
|
+
for (const res of ["480p", "720p", "1080p"]) {
|
|
118
126
|
for (const ref of [false, true]) {
|
|
119
127
|
const identifier = build(d, res, ref)
|
|
120
128
|
expect(identifier).toBe(`${ID}:${d}s:${res}${ref ? "-ref" : ""}`)
|
|
@@ -122,7 +130,7 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
|
-
// 27 durations x
|
|
126
|
-
expect(seen.size).toBe(
|
|
133
|
+
// 27 durations x 3 resolutions x 2 ref-modes, none collapsing onto another.
|
|
134
|
+
expect(seen.size).toBe(162)
|
|
127
135
|
})
|
|
128
136
|
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective-tier resolution — the payg derivation.
|
|
3
|
+
*
|
|
4
|
+
* "payg" is a DERIVED tier, never stored: a user whose stored tier resolves
|
|
5
|
+
* to "free" but who has NET lifetime top-up credits (granted − refunded,
|
|
6
|
+
* clamped ≥ 0 by every SQL writer) is treated as "payg" by entitlement
|
|
7
|
+
* checks. Stored tier stays untouched, so subscription webhooks never learn
|
|
8
|
+
* about payg and a cancel→free transition re-derives it automatically.
|
|
9
|
+
*
|
|
10
|
+
* Lives in @nodaro/shared because the pipeline tier maps already do, and
|
|
11
|
+
* both backend (credit gates, workers) and read surfaces need one source.
|
|
12
|
+
*
|
|
13
|
+
* The profile fields are REQUIRED (non-optional) on purpose: a call site
|
|
14
|
+
* whose SELECT forgot to fetch `lifetime_topup_credits` becomes a compile
|
|
15
|
+
* error instead of a silent `?? 0` that quietly deactivates payg — that is
|
|
16
|
+
* the failure mode this shape exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Stored-tier resolution — mirrors SQL COALESCE(tier, subscription_tier, 'free'). */
|
|
20
|
+
export function resolveStoredTier(p: {
|
|
21
|
+
tier: string | null
|
|
22
|
+
subscription_tier: string | null
|
|
23
|
+
}): string {
|
|
24
|
+
return p.tier ?? p.subscription_tier ?? "free"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Effective tier: stored "free" with NET lifetime top-ups > 0 derives "payg".
|
|
29
|
+
* Every other stored tier passes through untouched.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveEffectiveTier(p: {
|
|
32
|
+
tier: string | null
|
|
33
|
+
subscription_tier: string | null
|
|
34
|
+
lifetime_topup_credits: number
|
|
35
|
+
}): string {
|
|
36
|
+
const stored = resolveStoredTier(p)
|
|
37
|
+
if (stored === "free" && p.lifetime_topup_credits > 0) return "payg"
|
|
38
|
+
return stored
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Media-retention activity window for payg users (design 2026-07-05 §4.6). */
|
|
42
|
+
export const PAYG_RETENTION_DAYS = 90
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is this payg user inside their retention-activity window?
|
|
46
|
+
*
|
|
47
|
+
* Activity = credit SPEND (callers supply MAX(usage_logs.created_at) — never
|
|
48
|
+
* `last_daily_reset`, which read-only balance polls bump) OR a top-up
|
|
49
|
+
* PURCHASE (`last_topup_at`). Within PAYG_RETENTION_DAYS of either → the
|
|
50
|
+
* nightly reaper leaves their media alone; past it, the standard free-tier
|
|
51
|
+
* reaper rules apply. A user with no lifetime purchases is never
|
|
52
|
+
* retention-active (they are not payg).
|
|
53
|
+
*/
|
|
54
|
+
export function isPaygRetentionActive(
|
|
55
|
+
p: {
|
|
56
|
+
lifetimeTopupCredits: number
|
|
57
|
+
lastTopupAt: string | Date | null
|
|
58
|
+
lastSpendAt: string | Date | null
|
|
59
|
+
},
|
|
60
|
+
now: Date
|
|
61
|
+
): boolean {
|
|
62
|
+
if (p.lifetimeTopupCredits <= 0) return false
|
|
63
|
+
const cutoff = now.getTime() - PAYG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
64
|
+
const at = (v: string | Date | null): number | null => {
|
|
65
|
+
if (v === null) return null
|
|
66
|
+
const t = v instanceof Date ? v.getTime() : new Date(v).getTime()
|
|
67
|
+
return Number.isFinite(t) ? t : null
|
|
68
|
+
}
|
|
69
|
+
const topup = at(p.lastTopupAt)
|
|
70
|
+
const spend = at(p.lastSpendAt)
|
|
71
|
+
return (topup !== null && topup >= cutoff) || (spend !== null && spend >= cutoff)
|
|
72
|
+
}
|
|
@@ -19,11 +19,41 @@ export const ENTITY_IMAGE_HANDLE_TYPES: ReadonlySet<string> = new Set([
|
|
|
19
19
|
"creature",
|
|
20
20
|
])
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Aggregate node types (Group / Collect) whose source handles are typed LANES
|
|
24
|
+
* — `out-text` / `out-image` / `out-video` / `out-audio` — each emitting that
|
|
25
|
+
* media type. Like an entity's `image` handle, a wire leaving a lane is a plain
|
|
26
|
+
* producer of that lane's type, so it must reach the typed inputs of every
|
|
27
|
+
* consumer (Image Collage, Combine Videos, Merge Lists, prompts, …) exactly as
|
|
28
|
+
* an upload node of that type would. Neither node type is in any producer set
|
|
29
|
+
* itself: the NODE emits nothing, its LANES do.
|
|
30
|
+
*/
|
|
31
|
+
export const AGGREGATE_LANE_SOURCE_TYPES: ReadonlySet<string> = new Set(["group", "collect"])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The plain producer each aggregate lane behaves as. `upload-*` are the
|
|
35
|
+
* canonical single-media producers present in every typed accepts set
|
|
36
|
+
* (IMAGE/VIDEO/AUDIO_PRODUCER_TYPES). `out-text` maps to `list`, not
|
|
37
|
+
* `text-prompt`: the lane is a LIST of texts, so it must reach both prompt
|
|
38
|
+
* inputs (TEXT_PRODUCER_TYPES) and the list consumers (Merge Lists / Sort /
|
|
39
|
+
* Dedup / Selector — LIST_PRODUCER_TYPES), exactly like a List text column.
|
|
40
|
+
* (`list` is also a DYNAMIC producer, so the text lane is admitted at media
|
|
41
|
+
* inputs too — the same latitude a List column already has on canvas.)
|
|
42
|
+
*/
|
|
43
|
+
const AGGREGATE_LANE_EFFECTIVE_TYPE: Readonly<Record<string, string>> = {
|
|
44
|
+
"out-image": "upload-image",
|
|
45
|
+
"out-video": "upload-video",
|
|
46
|
+
"out-audio": "upload-audio",
|
|
47
|
+
"out-text": "list",
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* The effective output TYPE a given source handle emits. Returns the raw node
|
|
24
|
-
* type for every `(type, handle)` pair EXCEPT
|
|
25
|
-
*
|
|
26
|
-
*
|
|
52
|
+
* type for every `(type, handle)` pair EXCEPT:
|
|
53
|
+
* - an entity `image` handle → `"upload-image"` (a plain image producer);
|
|
54
|
+
* - an aggregate (group / collect) lane handle → the plain producer of that
|
|
55
|
+
* lane's media type (see AGGREGATE_LANE_EFFECTIVE_TYPE).
|
|
56
|
+
* Pure — safe for both frontend and backend.
|
|
27
57
|
*/
|
|
28
58
|
export function resolveEffectiveSourceType(
|
|
29
59
|
rawSourceType: string | undefined | null,
|
|
@@ -32,6 +62,10 @@ export function resolveEffectiveSourceType(
|
|
|
32
62
|
if (sourceHandleId === "image" && ENTITY_IMAGE_HANDLE_TYPES.has(rawSourceType ?? "")) {
|
|
33
63
|
return "upload-image"
|
|
34
64
|
}
|
|
65
|
+
if (AGGREGATE_LANE_SOURCE_TYPES.has(rawSourceType ?? "")) {
|
|
66
|
+
const effective = AGGREGATE_LANE_EFFECTIVE_TYPE[sourceHandleId ?? ""]
|
|
67
|
+
if (effective) return effective
|
|
68
|
+
}
|
|
35
69
|
return rawSourceType ?? ""
|
|
36
70
|
}
|
|
37
71
|
|
package/src/group-aggregation.ts
CHANGED
|
@@ -73,3 +73,35 @@ export function buildChildrenByParent<N extends { id: string; parentId?: string
|
|
|
73
73
|
export function isCollectInEdge(e: { targetHandle?: string | null }): boolean {
|
|
74
74
|
return (e.targetHandle ?? COLLECT_IN_HANDLE) === COLLECT_IN_HANDLE
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The source-pip lanes an aggregate node (Group / Collect) must EXPOSE.
|
|
79
|
+
*
|
|
80
|
+
* Bucket contents alone are not enough: buckets are computed from upstream
|
|
81
|
+
* RESULTS, so before anything ran the node rendered zero source handles — a
|
|
82
|
+
* pre-authored edge out of the node (MCP-written JSON, import, paste) pointed
|
|
83
|
+
* at a handle that did not exist and React Flow silently hid it ("connected
|
|
84
|
+
* in the panel, invisible on canvas"), and there was no pip to drag outward
|
|
85
|
+
* from. A lane therefore exists when ANY of these hold:
|
|
86
|
+
* (a) a wired member / input of that aggregateable type exists (pre-run),
|
|
87
|
+
* (b) the bucket for that type has values (post-run),
|
|
88
|
+
* (c) an existing outgoing edge references the lane's handle — so an edge
|
|
89
|
+
* can never point at a missing handle, whatever state authored it
|
|
90
|
+
* (also covers results being cleared after wiring downstream).
|
|
91
|
+
* Ordered by AGGREGATEABLE_TYPES so the handle layout is stable.
|
|
92
|
+
*/
|
|
93
|
+
export function computeAggregateLanes(
|
|
94
|
+
nodeId: string,
|
|
95
|
+
wiredTypes: Iterable<AggregateableType>,
|
|
96
|
+
buckets: AggregationBuckets,
|
|
97
|
+
edges: ReadonlyArray<{ source: string; sourceHandle?: string | null }>,
|
|
98
|
+
): AggregateableType[] {
|
|
99
|
+
const present = new Set<AggregateableType>(wiredTypes)
|
|
100
|
+
for (const t of presentTypes(buckets)) present.add(t)
|
|
101
|
+
for (const e of edges) {
|
|
102
|
+
if (e.source !== nodeId) continue
|
|
103
|
+
const lane = parseGroupHandle(e.sourceHandle)
|
|
104
|
+
if (lane) present.add(lane)
|
|
105
|
+
}
|
|
106
|
+
return AGGREGATEABLE_TYPES.filter((t) => present.has(t))
|
|
107
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
IMAGE_GEN_PROVIDERS,
|
|
63
63
|
IMAGE_I2I_PROVIDERS,
|
|
64
64
|
IMAGE_EDIT_PROVIDERS,
|
|
65
|
+
TASK_CHAINED_EDIT_PROVIDERS,
|
|
65
66
|
IMAGE_TO_VIDEO_PROVIDERS,
|
|
66
67
|
TEXT_TO_VIDEO_PROVIDERS,
|
|
67
68
|
VIDEO_GEN_PROVIDERS,
|
|
@@ -270,6 +271,7 @@ export {
|
|
|
270
271
|
parseGroupHandle,
|
|
271
272
|
isCollectInEdge,
|
|
272
273
|
buildChildrenByParent,
|
|
274
|
+
computeAggregateLanes,
|
|
273
275
|
type AggregateableType,
|
|
274
276
|
type Member,
|
|
275
277
|
type AggregationBuckets,
|
|
@@ -540,6 +542,13 @@ export type {
|
|
|
540
542
|
SharedListing,
|
|
541
543
|
} from "./community.js"
|
|
542
544
|
|
|
545
|
+
export {
|
|
546
|
+
resolveStoredTier,
|
|
547
|
+
resolveEffectiveTier,
|
|
548
|
+
isPaygRetentionActive,
|
|
549
|
+
PAYG_RETENTION_DAYS,
|
|
550
|
+
} from "./effective-tier.js"
|
|
551
|
+
|
|
543
552
|
export {
|
|
544
553
|
NODE_DEFAULT_TYPES,
|
|
545
554
|
validateProviderForNodeType,
|
package/src/llm-models.ts
CHANGED
|
@@ -423,6 +423,10 @@ export type LlmFeature =
|
|
|
423
423
|
| "generate-script"
|
|
424
424
|
| "translate"
|
|
425
425
|
| "image-critic"
|
|
426
|
+
// Choose Best (reduce) — the pick-best-llm strategy's judge. Its own
|
|
427
|
+
// feature (not ai-writer, which it used to piggyback on) so the model
|
|
428
|
+
// default and the tiered credit ids are the strategy's own.
|
|
429
|
+
| "pick-best-llm"
|
|
426
430
|
|
|
427
431
|
/** Engine-dependent LlmFeature for the motion-graphics node (design §8: every credit-id site must branch on engine). */
|
|
428
432
|
export function motionGraphicsFeature(engine?: string): LlmFeature {
|
|
@@ -446,6 +450,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
|
|
|
446
450
|
"generate-script": "gemini-3.6-flash",
|
|
447
451
|
"translate": "gemini-3.6-flash",
|
|
448
452
|
"image-critic": "claude-sonnet-4.6",
|
|
453
|
+
"pick-best-llm": "claude-sonnet-4.6",
|
|
449
454
|
}
|
|
450
455
|
|
|
451
456
|
/**
|
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
|
|