@nodaro/shared 1.11.0 → 1.13.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 +69 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -7
- package/dist/index.d.ts +45 -7
- package/dist/index.js +66 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +27 -0
- package/src/__tests__/video-analysis-pricing.test.ts +29 -3
- package/src/index.ts +5 -0
- package/src/llm-models.ts +58 -10
- package/src/model-catalog.ts +20 -0
- package/src/parameter-node-value.ts +20 -0
- package/src/video-analysis-pricing.ts +19 -1
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
effectiveReasoningEffort,
|
|
13
13
|
} from "../llm-models.js"
|
|
14
14
|
import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
|
|
15
|
+
import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
|
|
15
16
|
|
|
16
17
|
// The provider-$ per-token rate table and `calculateLlmCost` moved to
|
|
17
18
|
// backend/src/lib/pricing/llm-cost.ts (S5) — its tests live in
|
|
@@ -128,6 +129,32 @@ describe("LLM_MODEL_IDS", () => {
|
|
|
128
129
|
// getLlmModel
|
|
129
130
|
// ---------------------------------------------------------------------------
|
|
130
131
|
describe("getLlmModel", () => {
|
|
132
|
+
// Dash-alias resolution: wire contracts (PIPELINE_PINNABLE_SCRIPT_LLMS,
|
|
133
|
+
// provider slugs, persisted configs) carry dash forms while LLM_MODELS keys
|
|
134
|
+
// canonical dot ids — getLlmModel must accept both or the film pipeline's
|
|
135
|
+
// own script default throws "Unknown LLM model" at run time.
|
|
136
|
+
it("resolves dash-form aliases to their canonical dot-form models", () => {
|
|
137
|
+
expect(getLlmModel("claude-sonnet-4-6")?.id).toBe("claude-sonnet-4.6")
|
|
138
|
+
expect(getLlmModel("claude-opus-4-7")?.id).toBe("claude-opus-4.7")
|
|
139
|
+
expect(getLlmModel("claude-haiku-4-5")?.id).toBe("claude-haiku-4.5")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("resolves every PIPELINE_PINNABLE_SCRIPT_LLMS member (the film pipeline's pin surface)", () => {
|
|
143
|
+
for (const id of PIPELINE_PINNABLE_SCRIPT_LLMS) {
|
|
144
|
+
expect(getLlmModel(id), `pinnable script llm ${id} must resolve`).toBeDefined()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("resolves provider slugs as historical aliases", () => {
|
|
149
|
+
const dated = LLM_MODELS.find((m) => m.directFallbackModel && m.directFallbackModel !== m.id)
|
|
150
|
+
if (dated) expect(getLlmModel(dated.directFallbackModel!)?.id).toBe(dated.id)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("still returns undefined for a genuinely unknown id", () => {
|
|
154
|
+
expect(getLlmModel("claude-sonnet-9-9")).toBeUndefined()
|
|
155
|
+
expect(getLlmModel("not-a-model")).toBeUndefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
131
158
|
it('returns model def for "gemini-3-flash"', () => {
|
|
132
159
|
const model = getLlmModel("gemini-3-flash")
|
|
133
160
|
expect(model).toBeDefined()
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "../video-analysis-pricing.js"
|
|
8
8
|
import {
|
|
9
9
|
VIDEO_ANALYSIS_LLM_MODELS, VIDEO_ANALYSIS_TIERS, VIDEO_ANALYSIS_TIER_ORDER,
|
|
10
|
+
VIDEO_ANALYSIS_MIXED_TIERS,
|
|
10
11
|
DEFAULT_VIDEO_ANALYSIS_TIER, DEFAULT_VIDEO_ANALYSIS_MODEL, resolveVideoAnalysisModel,
|
|
11
12
|
} from "../llm-models.js"
|
|
12
13
|
|
|
@@ -43,26 +44,51 @@ describe("video-analysis-pricing", () => {
|
|
|
43
44
|
expect(VIDEO_ANALYSIS_LLM_MODELS).toEqual(["gemini-3-flash", "gemini-3.1-pro"])
|
|
44
45
|
})
|
|
45
46
|
|
|
46
|
-
it("tier layer: every tier maps to a real model AND every model is tier-reachable (no vendor leak)", () => {
|
|
47
|
+
it("tier layer: every model-backed tier maps to a real model AND every model is tier-reachable (no vendor leak)", () => {
|
|
47
48
|
// Adding a video-analysis model without a tier would silently leave it
|
|
48
49
|
// unreachable / unnamed — this fails until a tier decision is made.
|
|
49
50
|
const tierTargets = Object.values(VIDEO_ANALYSIS_TIERS)
|
|
50
51
|
for (const m of tierTargets) expect(VIDEO_ANALYSIS_LLM_MODELS).toContain(m)
|
|
51
52
|
for (const m of VIDEO_ANALYSIS_LLM_MODELS) expect(tierTargets).toContain(m)
|
|
52
|
-
|
|
53
|
+
// TIER_ORDER = model-backed tiers + mixed roll-plan tiers, exactly.
|
|
54
|
+
expect(new Set(VIDEO_ANALYSIS_TIER_ORDER)).toEqual(
|
|
55
|
+
new Set([...Object.keys(VIDEO_ANALYSIS_TIERS), ...VIDEO_ANALYSIS_MIXED_TIERS]),
|
|
56
|
+
)
|
|
57
|
+
// Mixed tiers are SENTINELS, never model ids — a mixed id leaking into the
|
|
58
|
+
// model list would break the roll-plan dispatch in the analysis engine.
|
|
59
|
+
for (const t of VIDEO_ANALYSIS_MIXED_TIERS) expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain(t)
|
|
53
60
|
})
|
|
54
61
|
|
|
55
|
-
it("resolveVideoAnalysisModel: tier → model, raw model passthrough, default pro on empty/unknown", () => {
|
|
62
|
+
it("resolveVideoAnalysisModel: tier → model, mixed → sentinel, raw model passthrough, default pro on empty/unknown", () => {
|
|
56
63
|
expect(DEFAULT_VIDEO_ANALYSIS_TIER).toBe("pro")
|
|
57
64
|
expect(DEFAULT_VIDEO_ANALYSIS_MODEL).toBe("gemini-3.1-pro")
|
|
58
65
|
expect(resolveVideoAnalysisModel("pro")).toBe("gemini-3.1-pro")
|
|
59
66
|
expect(resolveVideoAnalysisModel("fast")).toBe("gemini-3-flash")
|
|
67
|
+
expect(resolveVideoAnalysisModel("mixed")).toBe("mixed") // roll-plan sentinel passthrough
|
|
68
|
+
expect(resolveVideoAnalysisModel("mixed-fast")).toBe("mixed-fast")
|
|
60
69
|
expect(resolveVideoAnalysisModel("gemini-3-flash")).toBe("gemini-3-flash") // raw passthrough
|
|
61
70
|
expect(resolveVideoAnalysisModel(undefined)).toBe("gemini-3.1-pro") // default → pro
|
|
62
71
|
expect(resolveVideoAnalysisModel("")).toBe("gemini-3.1-pro")
|
|
63
72
|
expect(resolveVideoAnalysisModel("nonsense")).toBe("gemini-3.1-pro") // unknown → default, never throws
|
|
64
73
|
})
|
|
65
74
|
|
|
75
|
+
it("mixed tiers price under ONE shared credit family (video-analysis:mixed:*)", () => {
|
|
76
|
+
// Both variants are the identical compute plan — a per-variant price split
|
|
77
|
+
// would be a phantom distinction and double the admin surface.
|
|
78
|
+
for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
|
|
79
|
+
expect(buildVideoAnalysisCreditId("mixed", bucketSec)).toBe(`video-analysis:mixed:${bucketSec}s`)
|
|
80
|
+
expect(buildVideoAnalysisCreditId("mixed-fast", bucketSec)).toBe(`video-analysis:mixed:${bucketSec}s`)
|
|
81
|
+
const credits = VIDEO_ANALYSIS_BUCKET_CREDITS[`video-analysis:mixed:${bucketSec}s`]
|
|
82
|
+
expect(credits, `missing mixed entry for ${bucketSec}s`).toBeDefined()
|
|
83
|
+
expect(Number.isInteger(credits)).toBe(true)
|
|
84
|
+
// Sanity: mixed (3 fast + 2 pro rolls + refine) must never price below
|
|
85
|
+
// the pro tier it supersets.
|
|
86
|
+
expect(credits).toBeGreaterThanOrEqual(
|
|
87
|
+
VIDEO_ANALYSIS_BUCKET_CREDITS[`video-analysis:gemini-3.1-pro:${bucketSec}s`],
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
66
92
|
// Full drift-detection against the live $-formula lives in
|
|
67
93
|
// backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts (this
|
|
68
94
|
// package cannot see the formula post-S5). This is a lightweight shape
|
package/src/index.ts
CHANGED
|
@@ -255,11 +255,15 @@ export {
|
|
|
255
255
|
VIDEO_ANALYSIS_LLM_MODELS,
|
|
256
256
|
VIDEO_ANALYSIS_TIERS,
|
|
257
257
|
type VideoAnalysisTier,
|
|
258
|
+
type VideoAnalysisModelTier,
|
|
259
|
+
VIDEO_ANALYSIS_MIXED_TIERS,
|
|
260
|
+
type VideoAnalysisMixedTier,
|
|
258
261
|
VIDEO_ANALYSIS_TIER_ORDER,
|
|
259
262
|
DEFAULT_VIDEO_ANALYSIS_TIER,
|
|
260
263
|
DEFAULT_VIDEO_ANALYSIS_MODEL,
|
|
261
264
|
VIDEO_ANALYSIS_TIER_LABELS,
|
|
262
265
|
isVideoAnalysisTier,
|
|
266
|
+
isVideoAnalysisMixedTier,
|
|
263
267
|
resolveVideoAnalysisModel,
|
|
264
268
|
LLM_FEATURE_DEFAULTS,
|
|
265
269
|
LLM_MODALITY_CAPS,
|
|
@@ -463,6 +467,7 @@ export type { LocationCatalogRef } from "./location-preset-catalog-map.js"
|
|
|
463
467
|
|
|
464
468
|
export {
|
|
465
469
|
PARAMETER_NODE_TYPES,
|
|
470
|
+
HINT_EXEMPT_PARAMETER_TYPES,
|
|
466
471
|
getParameterValue,
|
|
467
472
|
} from "./parameter-node-value.js"
|
|
468
473
|
|
package/src/llm-models.ts
CHANGED
|
@@ -327,8 +327,29 @@ export function getLlmModalityCaps(modelId: string | undefined): { image: boolea
|
|
|
327
327
|
return LLM_MODALITY_CAPS[modelId] ?? { image: true, video: false, audio: false }
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Dash-form aliases resolve to their canonical dot-form ids. Several wire
|
|
332
|
+
* contracts carry dash forms (`PIPELINE_PINNABLE_SCRIPT_LLMS`, provider slugs,
|
|
333
|
+
* configs persisted before the id scheme settled), while `LLM_MODELS` keys the
|
|
334
|
+
* canonical `major.minor` form — an exact-only lookup makes every such caller
|
|
335
|
+
* throw "Unknown LLM model" at run time. Normalizing here (instead of editing
|
|
336
|
+
* the enums) keeps stored configs and published-package consumers valid.
|
|
337
|
+
*/
|
|
338
|
+
function dashAliasToCanonical(id: string): string {
|
|
339
|
+
return id.replace(/-(\d+)-(\d+)$/, "-$1.$2")
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
export function getLlmModel(id: string): LlmModelDef | undefined {
|
|
331
|
-
|
|
343
|
+
const exact = LLM_MODELS.find((m) => m.id === id)
|
|
344
|
+
if (exact) return exact
|
|
345
|
+
const canonical = dashAliasToCanonical(id)
|
|
346
|
+
if (canonical !== id) {
|
|
347
|
+
const aliased = LLM_MODELS.find((m) => m.id === canonical)
|
|
348
|
+
if (aliased) return aliased
|
|
349
|
+
}
|
|
350
|
+
// Last resort: provider slugs double as historical aliases (e.g. the
|
|
351
|
+
// dated Anthropic slugs) — accept any model whose slug matches exactly.
|
|
352
|
+
return LLM_MODELS.find((m) => m.kieSlugOrModel === id || m.directFallbackModel === id)
|
|
332
353
|
}
|
|
333
354
|
|
|
334
355
|
export function getLlmTier(id: string): LlmTier {
|
|
@@ -396,26 +417,53 @@ export const VIDEO_ANALYSIS_LLM_MODELS: string[] = LLM_MODELS
|
|
|
396
417
|
* so adding a video model forces a tier decision instead of silently leaking.
|
|
397
418
|
*/
|
|
398
419
|
export const VIDEO_ANALYSIS_TIERS = { fast: "gemini-3-flash", pro: "gemini-3.1-pro" } as const
|
|
399
|
-
export type
|
|
420
|
+
export type VideoAnalysisModelTier = keyof typeof VIDEO_ANALYSIS_TIERS
|
|
421
|
+
/**
|
|
422
|
+
* MIXED tiers — advanced multi-engine analysis plans whose identifier resolves
|
|
423
|
+
* to an engine-plan SENTINEL consumed by the analysis engine, never to a single
|
|
424
|
+
* model id. Two variants, same price (one shared `video-analysis:mixed:*`
|
|
425
|
+
* credit family): `mixed` targets maximum result quality; `mixed-fast` targets
|
|
426
|
+
* run-to-run output consistency. What each plan does internally is deliberately
|
|
427
|
+
* NOT published here (Apache irrevocability; only the wire vocabulary below is
|
|
428
|
+
* contract — the engine lives in the private analysis plugin).
|
|
429
|
+
*/
|
|
430
|
+
export const VIDEO_ANALYSIS_MIXED_TIERS = ["mixed", "mixed-fast"] as const
|
|
431
|
+
export type VideoAnalysisMixedTier = (typeof VIDEO_ANALYSIS_MIXED_TIERS)[number]
|
|
400
432
|
/** UI/listing order — recommended (pro) first. */
|
|
401
|
-
export const VIDEO_ANALYSIS_TIER_ORDER = ["pro", "fast"] as const
|
|
433
|
+
export const VIDEO_ANALYSIS_TIER_ORDER = ["pro", "fast", "mixed", "mixed-fast"] as const
|
|
434
|
+
export type VideoAnalysisTier = (typeof VIDEO_ANALYSIS_TIER_ORDER)[number]
|
|
402
435
|
export const DEFAULT_VIDEO_ANALYSIS_TIER: VideoAnalysisTier = "pro"
|
|
403
436
|
export const DEFAULT_VIDEO_ANALYSIS_MODEL: string = VIDEO_ANALYSIS_TIERS[DEFAULT_VIDEO_ANALYSIS_TIER]
|
|
404
437
|
/** Neutral, vendor-free display labels for the UI. */
|
|
405
|
-
export const VIDEO_ANALYSIS_TIER_LABELS: Record<VideoAnalysisTier, string> = {
|
|
438
|
+
export const VIDEO_ANALYSIS_TIER_LABELS: Record<VideoAnalysisTier, string> = {
|
|
439
|
+
fast: "Fast",
|
|
440
|
+
pro: "Pro",
|
|
441
|
+
mixed: "Mixed",
|
|
442
|
+
"mixed-fast": "Mixed (consistent)",
|
|
443
|
+
}
|
|
406
444
|
|
|
407
445
|
export function isVideoAnalysisTier(v: string): v is VideoAnalysisTier {
|
|
408
|
-
return
|
|
446
|
+
return (VIDEO_ANALYSIS_TIER_ORDER as readonly string[]).includes(v)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function isVideoAnalysisMixedTier(v: string): v is VideoAnalysisMixedTier {
|
|
450
|
+
return (VIDEO_ANALYSIS_MIXED_TIERS as readonly string[]).includes(v)
|
|
409
451
|
}
|
|
410
452
|
|
|
411
453
|
/**
|
|
412
|
-
* Resolve a user-supplied tier
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
454
|
+
* Resolve a user-supplied tier OR a raw internal model id to the analysis
|
|
455
|
+
* ENGINE IDENTIFIER carried in the worker payload:
|
|
456
|
+
* - model-backed tiers ("fast"/"pro") → the internal model id;
|
|
457
|
+
* - mixed tiers ("mixed"/"mixed-fast") → the sentinel ITSELF (the engine
|
|
458
|
+
* expands it to a multi-model roll plan);
|
|
459
|
+
* - raw model ids pass through (back-compat for stored `llmModel` values);
|
|
460
|
+
* - empty/unknown → the default tier's model (never an error).
|
|
416
461
|
*/
|
|
417
462
|
export function resolveVideoAnalysisModel(input?: string | null): string {
|
|
418
|
-
if (input &&
|
|
463
|
+
if (input && isVideoAnalysisMixedTier(input)) return input
|
|
464
|
+
if (input && Object.prototype.hasOwnProperty.call(VIDEO_ANALYSIS_TIERS, input)) {
|
|
465
|
+
return VIDEO_ANALYSIS_TIERS[input as VideoAnalysisModelTier]
|
|
466
|
+
}
|
|
419
467
|
if (input && VIDEO_ANALYSIS_LLM_MODELS.includes(input)) return input
|
|
420
468
|
return DEFAULT_VIDEO_ANALYSIS_MODEL
|
|
421
469
|
}
|
package/src/model-catalog.ts
CHANGED
|
@@ -1839,6 +1839,26 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1839
1839
|
{ identifier: "video-analysis:gemini-3.1-pro:600s", credits: 11, note: "10-min ceiling" },
|
|
1840
1840
|
],
|
|
1841
1841
|
},
|
|
1842
|
+
// Both mixed tiers are variants of the same advanced multi-engine analysis
|
|
1843
|
+
// and share this ONE credit family (videoAnalysisCreditSegment maps both;
|
|
1844
|
+
// plan internals live in the private analysis plugin).
|
|
1845
|
+
"mixed-video-analysis": {
|
|
1846
|
+
id: "mixed-video-analysis",
|
|
1847
|
+
kind: "video",
|
|
1848
|
+
modes: ["video-analysis"] as const,
|
|
1849
|
+
family: "Nodaro",
|
|
1850
|
+
label: "Video Analysis (Mixed)",
|
|
1851
|
+
series: "Video Analysis",
|
|
1852
|
+
description: "Our most advanced analysis tier — multiple analysis engines combined into one result for maximum completeness and accuracy. Billed per duration bucket.",
|
|
1853
|
+
useCases: ["video-analysis", "shot-list", "premium", "most-complete"],
|
|
1854
|
+
pricing: [
|
|
1855
|
+
{ identifier: "video-analysis:mixed", credits: 14, note: "10-min ceiling (no duration given)" },
|
|
1856
|
+
{ identifier: "video-analysis:mixed:60s", credits: 3 },
|
|
1857
|
+
{ identifier: "video-analysis:mixed:180s", credits: 4 },
|
|
1858
|
+
{ identifier: "video-analysis:mixed:360s", credits: 9 },
|
|
1859
|
+
{ identifier: "video-analysis:mixed:600s", credits: 14, note: "10-min ceiling" },
|
|
1860
|
+
],
|
|
1861
|
+
},
|
|
1842
1862
|
}
|
|
1843
1863
|
|
|
1844
1864
|
// =============================================================================
|
|
@@ -57,6 +57,26 @@ export const PARAMETER_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
|
57
57
|
"voice-delivery",
|
|
58
58
|
])
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Parameter types that intentionally produce NO prompt hint from
|
|
62
|
+
* `getParameterPromptHint` (in `@nodaro/prompts`). They carry pure runtime
|
|
63
|
+
* parameters — counts, durations, aspect ratios, legacy motion intensity —
|
|
64
|
+
* consumed by the executor directly, never appended to a downstream prompt.
|
|
65
|
+
*
|
|
66
|
+
* Consumers that treat "parameter node" as "text producer" (the `{Label}`
|
|
67
|
+
* auto-fill in `main-text-handle.ts`, ref-map builders) must exclude these:
|
|
68
|
+
* their extracted output is undefined, so an auto-filled `{Label}` would stay
|
|
69
|
+
* in the outgoing prompt as literal brace text. Guarded by
|
|
70
|
+
* `parameter-registry-sync.test.ts` (each member really returns "") and
|
|
71
|
+
* `main-text-handle.test.ts` (members are not text-producing).
|
|
72
|
+
*/
|
|
73
|
+
export const HINT_EXEMPT_PARAMETER_TYPES: ReadonlySet<string> = new Set([
|
|
74
|
+
"motion",
|
|
75
|
+
"scene-count",
|
|
76
|
+
"duration",
|
|
77
|
+
"aspect-ratio",
|
|
78
|
+
])
|
|
79
|
+
|
|
60
80
|
export function getParameterValue(
|
|
61
81
|
data: Record<string, unknown>,
|
|
62
82
|
nodeType: string,
|
|
@@ -54,6 +54,24 @@ export const VIDEO_ANALYSIS_BUCKET_CREDITS: Record<string, number> = {
|
|
|
54
54
|
"video-analysis:gemini-3.1-pro:180s": 3,
|
|
55
55
|
"video-analysis:gemini-3.1-pro:360s": 7,
|
|
56
56
|
"video-analysis:gemini-3.1-pro:600s": 11,
|
|
57
|
+
// Mixed tiers (`mixed` + `mixed-fast`) share ONE credit family — they are
|
|
58
|
+
// variants of the same engine plan (plan internals live in the private
|
|
59
|
+
// analysis plugin). Admin-tunable via model_pricing like every other row.
|
|
60
|
+
"video-analysis:mixed:60s": 3,
|
|
61
|
+
"video-analysis:mixed:180s": 4,
|
|
62
|
+
"video-analysis:mixed:360s": 9,
|
|
63
|
+
"video-analysis:mixed:600s": 14,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The credit-id MODEL SEGMENT for an engine identifier: both mixed-tier
|
|
68
|
+
* sentinels share the `mixed` price family (same engine plan); everything
|
|
69
|
+
* else prices under its own identifier. Single source of truth — used by
|
|
70
|
+
* `buildVideoAnalysisCreditId` below, so route/orchestrator/UI callers can
|
|
71
|
+
* never diverge on where a sentinel prices.
|
|
72
|
+
*/
|
|
73
|
+
export function videoAnalysisCreditSegment(modelOrSentinel: string): string {
|
|
74
|
+
return modelOrSentinel === "mixed-fast" ? "mixed" : modelOrSentinel
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
export function pickVideoAnalysisBucket(durationSec: number): number {
|
|
@@ -65,7 +83,7 @@ export function buildVideoAnalysisCreditId(model: string, durationSec?: number):
|
|
|
65
83
|
const bucket = durationSec !== undefined && durationSec > 0
|
|
66
84
|
? pickVideoAnalysisBucket(Math.min(durationSec, VIDEO_ANALYSIS_MAX_DURATION_SEC))
|
|
67
85
|
: VIDEO_ANALYSIS_MAX_DURATION_SEC // unknown → ceiling composite (the ONLY silent-ceiling path)
|
|
68
|
-
return `video-analysis:${model}:${bucket}s`
|
|
86
|
+
return `video-analysis:${videoAnalysisCreditSegment(model)}:${bucket}s`
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
export function bucketSecondsFromCreditId(creditId: string): number | null {
|