@nodaro/shared 3.11.0 → 3.12.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.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/i18n/action-fx.he.ts +8 -8
- package/src/i18n/aesthetic.he.ts +8 -8
- package/src/i18n/animals.he.ts +34 -34
- package/src/i18n/atmosphere.he.ts +6 -6
- package/src/i18n/backdrop.he.ts +7 -7
- package/src/i18n/camera-format.he.ts +4 -4
- package/src/i18n/camera-motions.he.ts +20 -20
- package/src/i18n/character-fx.he.ts +16 -16
- package/src/i18n/character-motion.he.ts +184 -184
- package/src/i18n/color-look.he.ts +4 -4
- package/src/i18n/composition-effects.he.ts +3 -3
- package/src/i18n/era.he.ts +9 -9
- package/src/i18n/exposure-settings.he.ts +2 -2
- package/src/i18n/framing.he.ts +13 -13
- package/src/i18n/furniture.he.ts +52 -52
- package/src/i18n/held-prop.he.ts +10 -10
- package/src/i18n/instrumentation.he.ts +26 -26
- package/src/i18n/lens.he.ts +6 -6
- package/src/i18n/lighting.he.ts +17 -17
- package/src/i18n/loop-subject.he.ts +4 -4
- package/src/i18n/materials.he.ts +8 -8
- package/src/i18n/mood.he.ts +12 -12
- package/src/i18n/music-genre.he.ts +9 -9
- package/src/i18n/music-mood.he.ts +7 -7
- package/src/i18n/person.he.ts +94 -94
- package/src/i18n/photo-genre.he.ts +24 -24
- package/src/i18n/photographer.he.ts +21 -21
- package/src/i18n/pose.he.ts +14 -14
- package/src/i18n/post-process-effects.he.ts +4 -4
- package/src/i18n/render-quality.he.ts +7 -7
- package/src/i18n/setting.he.ts +14 -14
- package/src/i18n/style.he.ts +9 -9
- package/src/i18n/styling.he.ts +126 -126
- package/src/i18n/temporal.he.ts +8 -8
- package/src/i18n/transitions.he.ts +9 -9
- package/src/i18n/vehicles.he.ts +25 -25
- package/src/i18n/voice-character.he.ts +14 -14
- package/src/i18n/voice-delivery.he.ts +11 -11
- package/src/i18n/weapons.he.ts +23 -23
- package/src/index.ts +206 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Ads scraper node — shared vocabulary + credit identifiers.
|
|
3
|
+
*
|
|
4
|
+
* The node pulls PUBLIC ads (Facebook + Instagram placements) out of Meta's
|
|
5
|
+
* Ad Library, either by keyword search or by Facebook Page URL, and emits a
|
|
6
|
+
* JSON array of normalized ads. Everything the backend guard/reservation, the
|
|
7
|
+
* frontend credit badge and the docs formula must agree on lives here so the
|
|
8
|
+
* three cannot drift apart.
|
|
9
|
+
*
|
|
10
|
+
* The editor has a third, node-only mode — "advertiser": pick advertisers by
|
|
11
|
+
* name (a page lookup) and run as their Page urls. The wire contract stays
|
|
12
|
+
* search / pages; `metaAdsScrapeWireSources` is the ONE mapping both engines
|
|
13
|
+
* call, and `metaAdsScrapeSources` the ONE source count every quote reads.
|
|
14
|
+
*
|
|
15
|
+
* Pricing shape: 1 credit per REQUESTED ad, rounded UP to a fixed tier. The
|
|
16
|
+
* requested total is `count × sources` (search = 1 source; pages = one per
|
|
17
|
+
* URL). The route's Zod bounds `count ≤ 100` and `sources ≤ 5`, so the total
|
|
18
|
+
* can never land outside the tier set — an identifier missing from
|
|
19
|
+
* `model_pricing` is a 503 `price_not_configured`, never a silent fallback.
|
|
20
|
+
*/
|
|
21
|
+
import { LLM_FEATURE_DEFAULTS, getLlmTier } from "./llm-models.js"
|
|
22
|
+
|
|
23
|
+
export const META_ADS_SCRAPE_NODE_TYPE = "meta-ads-scrape" as const
|
|
24
|
+
|
|
25
|
+
/** The WIRE modes — what `POST /v1/meta-ads-scrape` accepts. */
|
|
26
|
+
export const META_ADS_SCRAPE_MODES = ["search", "pages"] as const
|
|
27
|
+
export type MetaAdsScrapeMode = (typeof META_ADS_SCRAPE_MODES)[number]
|
|
28
|
+
|
|
29
|
+
/** The NODE modes — the wire modes plus the editor-only advertiser picker (runs as pages). */
|
|
30
|
+
export const META_ADS_NODE_MODES = [...META_ADS_SCRAPE_MODES, "advertiser"] as const
|
|
31
|
+
export type MetaAdsNodeMode = (typeof META_ADS_NODE_MODES)[number]
|
|
32
|
+
|
|
33
|
+
/** Coerce stored node data to a node mode; anything unknown is the default keyword search. */
|
|
34
|
+
export function metaAdsNodeMode(value: unknown): MetaAdsNodeMode {
|
|
35
|
+
return typeof value === "string" && (META_ADS_NODE_MODES as readonly string[]).includes(value) ? (value as MetaAdsNodeMode) : "search"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An advertiser the user picked by name — stored on the node, run as its Page url. */
|
|
39
|
+
export interface MetaAdsAdvertiser {
|
|
40
|
+
readonly pageId: string
|
|
41
|
+
readonly name: string
|
|
42
|
+
/** The Facebook Page url. The actor resolves it to the Ad Library advertiser itself — the Page id and the advertiser id are NOT the same number. */
|
|
43
|
+
readonly url: string
|
|
44
|
+
readonly imageUrl?: string
|
|
45
|
+
readonly verified?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How many matches an advertiser lookup returns — more than the pick cap, so a same-name brand can be told apart by its badge / avatar. */
|
|
49
|
+
export const META_ADS_ADVERTISER_MAX_RESULTS = 8
|
|
50
|
+
const META_ADS_URL_MAX_LENGTH = 2048
|
|
51
|
+
|
|
52
|
+
function httpUrlOnHost(value: unknown, host: RegExp): value is string {
|
|
53
|
+
if (typeof value !== "string" || value.length > META_ADS_URL_MAX_LENGTH) return false
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(value)
|
|
56
|
+
return (url.protocol === "https:" || url.protocol === "http:") && host.test(url.hostname)
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** http(s) url on facebook.com (any subdomain) — the ONE predicate for a Page address, on the route's Zod and on stored picks alike. */
|
|
63
|
+
export function isFacebookPageUrl(value: unknown): value is string {
|
|
64
|
+
return httpUrlOnHost(value, /(^|\.)facebook\.com$/i)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A Page avatar lives on Meta's CDN; anything else is not stored (it would be fetched by every viewer's browser and our image proxy). */
|
|
68
|
+
export function isMetaCdnImageUrl(value: unknown): value is string {
|
|
69
|
+
return httpUrlOnHost(value, /(^|\.)(fbcdn\.net|facebook\.com)$/i)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stored / relayed advertisers, sanitized: a page id, a name, a facebook.com url, a Meta-CDN avatar; deduped by page id; at most `limit` (the pick cap by default). */
|
|
73
|
+
export function metaAdsAdvertisersFrom(raw: unknown, limit: number = META_ADS_SCRAPE_MAX_SOURCES): MetaAdsAdvertiser[] {
|
|
74
|
+
if (!Array.isArray(raw) || limit < 1) return []
|
|
75
|
+
const seen = new Set<string>()
|
|
76
|
+
const out: MetaAdsAdvertiser[] = []
|
|
77
|
+
for (const item of raw) {
|
|
78
|
+
if (!item || typeof item !== "object") continue
|
|
79
|
+
const r = item as Record<string, unknown>
|
|
80
|
+
const pageId =
|
|
81
|
+
typeof r.pageId === "string" ? r.pageId.trim() : typeof r.pageId === "number" && Number.isFinite(r.pageId) ? String(r.pageId) : ""
|
|
82
|
+
const name = typeof r.name === "string" ? r.name.trim().slice(0, 120) : ""
|
|
83
|
+
if (!pageId || !name || !isFacebookPageUrl(r.url) || seen.has(pageId)) continue
|
|
84
|
+
seen.add(pageId)
|
|
85
|
+
out.push({
|
|
86
|
+
pageId,
|
|
87
|
+
name,
|
|
88
|
+
url: r.url,
|
|
89
|
+
...(isMetaCdnImageUrl(r.imageUrl) ? { imageUrl: r.imageUrl } : {}),
|
|
90
|
+
...(r.verified === true ? { verified: true } : {}),
|
|
91
|
+
})
|
|
92
|
+
if (out.length >= limit) break
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const META_ADS_SCRAPE_PERIODS = ["24h", "7d", "30d", "all"] as const
|
|
98
|
+
export type MetaAdsScrapePeriod = (typeof META_ADS_SCRAPE_PERIODS)[number]
|
|
99
|
+
|
|
100
|
+
export const META_ADS_SCRAPE_STATUSES = ["active", "inactive", "all"] as const
|
|
101
|
+
export type MetaAdsScrapeStatus = (typeof META_ADS_SCRAPE_STATUSES)[number]
|
|
102
|
+
|
|
103
|
+
/** Meta's `publisher_platform` vocabulary — the values an ad's `platforms` carries and the filter the node accepts. */
|
|
104
|
+
export const META_ADS_PLATFORMS = ["FACEBOOK", "INSTAGRAM", "AUDIENCE_NETWORK", "MESSENGER", "WHATSAPP", "THREADS"] as const
|
|
105
|
+
export type MetaAdsPlatform = (typeof META_ADS_PLATFORMS)[number]
|
|
106
|
+
|
|
107
|
+
export function isMetaAdsPlatform(value: unknown): value is MetaAdsPlatform {
|
|
108
|
+
return typeof value === "string" && (META_ADS_PLATFORMS as readonly string[]).includes(value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Creative format, classified from the creative's measured pixels — the
|
|
113
|
+
* user's "phone vs web" question. `vertical` = Stories / Reels / mobile feed
|
|
114
|
+
* (9:16, 4:5), `square` = 1:1 (±5 %), `horizontal` = feed / web / banners
|
|
115
|
+
* (16:9, 1.91:1). One classifier for the route (node setting), the Results
|
|
116
|
+
* chips and the card, so they can never disagree.
|
|
117
|
+
*/
|
|
118
|
+
export const META_ADS_FORMATS = ["vertical", "square", "horizontal"] as const
|
|
119
|
+
export type MetaAdsFormat = (typeof META_ADS_FORMATS)[number]
|
|
120
|
+
export type MetaAdsCreativeFormat = MetaAdsFormat | "unknown"
|
|
121
|
+
|
|
122
|
+
export function isMetaAdsFormat(value: unknown): value is MetaAdsFormat {
|
|
123
|
+
return typeof value === "string" && (META_ADS_FORMATS as readonly string[]).includes(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The featured ad index, clamped so a rerun that returned fewer ads never indexes past the end. */
|
|
127
|
+
export function clampMetaAdsFeaturedIndex(stored: unknown, count: number): number {
|
|
128
|
+
if (count <= 0) return 0
|
|
129
|
+
const n = typeof stored === "number" && Number.isFinite(stored) ? Math.trunc(stored) : 0
|
|
130
|
+
return Math.min(Math.max(n, 0), count - 1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FeaturedMetaAdOutputs {
|
|
134
|
+
readonly text?: string
|
|
135
|
+
readonly imageUrl?: string
|
|
136
|
+
readonly videoUrl?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function urlStrings(value: unknown): string[] {
|
|
140
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0) : []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What the node's typed `text` / `image` / `video` handles carry: the
|
|
145
|
+
* FEATURED ad's copy (headline + body), first image (else the video poster)
|
|
146
|
+
* and first video. ONE derivation for the route's output_data, the backend
|
|
147
|
+
* saved-output hydration and the editor's extractNodeOutput, so a thumb pick
|
|
148
|
+
* re-hydrates the handles identically everywhere.
|
|
149
|
+
*/
|
|
150
|
+
export function featuredMetaAdOutputs(json: unknown, featuredIndex: unknown): FeaturedMetaAdOutputs {
|
|
151
|
+
if (!Array.isArray(json) || json.length === 0) return {}
|
|
152
|
+
const ad = json[clampMetaAdsFeaturedIndex(featuredIndex, json.length)]
|
|
153
|
+
if (!ad || typeof ad !== "object") return {}
|
|
154
|
+
const a = ad as Record<string, unknown>
|
|
155
|
+
const title = typeof a.title === "string" ? a.title.trim() : ""
|
|
156
|
+
const body = typeof a.text === "string" ? a.text.trim() : ""
|
|
157
|
+
const text = [title, body].filter((s) => s.length > 0).join("\n\n")
|
|
158
|
+
const imageUrl = urlStrings(a.images)[0] ?? urlStrings(a.videoPreviews)[0]
|
|
159
|
+
const videoUrl = urlStrings(a.videos)[0]
|
|
160
|
+
return {
|
|
161
|
+
...(text ? { text } : {}),
|
|
162
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
163
|
+
...(videoUrl ? { videoUrl } : {}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function classifyCreativeFormat(width: unknown, height: unknown): MetaAdsCreativeFormat {
|
|
168
|
+
if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
169
|
+
return "unknown"
|
|
170
|
+
}
|
|
171
|
+
const ratio = width / height
|
|
172
|
+
if (ratio < 0.95) return "vertical"
|
|
173
|
+
if (ratio <= 1.05) return "square"
|
|
174
|
+
return "horizontal"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Presets the config panel offers; the route accepts any integer 1..MAX_COUNT. */
|
|
178
|
+
export const META_ADS_SCRAPE_COUNT_OPTIONS = [10, 20, 50, 100] as const
|
|
179
|
+
export const META_ADS_SCRAPE_DEFAULT_COUNT = 20
|
|
180
|
+
export const META_ADS_SCRAPE_MAX_COUNT = 100
|
|
181
|
+
export const META_ADS_SCRAPE_MAX_SOURCES = 5
|
|
182
|
+
/** Meta caps Ad Library search terms at 100 characters. */
|
|
183
|
+
export const META_ADS_SCRAPE_MAX_QUERY_LENGTH = 100
|
|
184
|
+
export const META_ADS_SCRAPE_DEFAULT_COUNTRY = "ALL"
|
|
185
|
+
|
|
186
|
+
/** Requested-total buckets. Sorted ascending; the last one is `MAX_COUNT × MAX_SOURCES`. */
|
|
187
|
+
export const META_ADS_SCRAPE_TIERS = [10, 20, 50, 100, 200, 500] as const
|
|
188
|
+
export type MetaAdsScrapeTier = (typeof META_ADS_SCRAPE_TIERS)[number]
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Optional per-ad AI analysis — the "expert competitor ad analyst" pass.
|
|
192
|
+
* Priced per REQUESTED ad like the scrape, by the analysing model's tier,
|
|
193
|
+
* and folded into the SAME tiered identifier so every quote (guard,
|
|
194
|
+
* reservation, card badge, run total, backend estimator) stays one SKU:
|
|
195
|
+
*
|
|
196
|
+
* meta-ads-scrape:<tier> tier
|
|
197
|
+
* meta-ads-scrape:<tier>:analysis tier × (1 + 3) standard models
|
|
198
|
+
* meta-ads-scrape:<tier>:analysis:economy tier × (1 + 1)
|
|
199
|
+
* meta-ads-scrape:<tier>:analysis:premium tier × (1 + 4)
|
|
200
|
+
*
|
|
201
|
+
* The per-ad SKUs (`meta-ads-analysis[:economy|:premium]`) price the
|
|
202
|
+
* SETTLEMENT: a run commits tier + per-ad × ads actually analysed and
|
|
203
|
+
* refunds the rest (an ad the model failed on, or one the deadline skipped).
|
|
204
|
+
*/
|
|
205
|
+
export const META_ADS_ANALYSIS_TIERS = ["economy", "standard", "premium"] as const
|
|
206
|
+
export type MetaAdsAnalysisTier = (typeof META_ADS_ANALYSIS_TIERS)[number]
|
|
207
|
+
export const META_ADS_ANALYSIS_CREDITS_PER_AD: Record<MetaAdsAnalysisTier, number> = { economy: 1, standard: 3, premium: 4 }
|
|
208
|
+
export const META_ADS_ANALYSIS_CREDIT_ID = "meta-ads-analysis" as const
|
|
209
|
+
/** The user's optional analyst focus, appended to the fixed prompt. */
|
|
210
|
+
export const META_ADS_ANALYSIS_FOCUS_MAX = 500
|
|
211
|
+
|
|
212
|
+
/** The per-ad settlement SKU for a tier (the bare id is the standard tier). */
|
|
213
|
+
export function metaAdsAnalysisCreditId(tier: MetaAdsAnalysisTier): string {
|
|
214
|
+
return tier === "standard" ? META_ADS_ANALYSIS_CREDIT_ID : `${META_ADS_ANALYSIS_CREDIT_ID}:${tier}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The analysing model's tier; an absent model is the feature default. */
|
|
218
|
+
export function metaAdsAnalysisTier(modelId?: unknown): MetaAdsAnalysisTier {
|
|
219
|
+
const id = typeof modelId === "string" && modelId ? modelId : LLM_FEATURE_DEFAULTS["meta-ads-analysis"]
|
|
220
|
+
return getLlmTier(id)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What one analysed ad carries (`ad.analysis`); fixed fields + string lists only — never a map (Gemini via KIE drops map fields). */
|
|
224
|
+
export interface AdCreativeAnalysis {
|
|
225
|
+
readonly assetType: "static" | "motion" | "carousel" | "unknown"
|
|
226
|
+
readonly format: string
|
|
227
|
+
readonly visualHooks: readonly string[]
|
|
228
|
+
readonly audiences: readonly string[]
|
|
229
|
+
readonly graphicIdentity: string
|
|
230
|
+
readonly copywritingHooks: readonly string[]
|
|
231
|
+
readonly usps: readonly string[]
|
|
232
|
+
readonly cta: string
|
|
233
|
+
readonly summary: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const strList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === "string" && s.trim().length > 0) : [])
|
|
237
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "")
|
|
238
|
+
|
|
239
|
+
/** Read a stored analysis back defensively (a node's saved JSON is untrusted shape); null when there is none. */
|
|
240
|
+
export function adCreativeAnalysisFrom(raw: unknown): AdCreativeAnalysis | null {
|
|
241
|
+
if (!raw || typeof raw !== "object") return null
|
|
242
|
+
const r = raw as Record<string, unknown>
|
|
243
|
+
if (typeof r.summary !== "string" || !r.summary.trim()) return null
|
|
244
|
+
const assetType = r.assetType === "static" || r.assetType === "motion" || r.assetType === "carousel" ? r.assetType : "unknown"
|
|
245
|
+
return {
|
|
246
|
+
assetType,
|
|
247
|
+
format: str(r.format),
|
|
248
|
+
visualHooks: strList(r.visualHooks),
|
|
249
|
+
audiences: strList(r.audiences),
|
|
250
|
+
graphicIdentity: str(r.graphicIdentity),
|
|
251
|
+
copywritingHooks: strList(r.copywritingHooks),
|
|
252
|
+
usps: strList(r.usps),
|
|
253
|
+
cta: str(r.cta),
|
|
254
|
+
summary: r.summary,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The `:analysis[:tier]` suffix on a scrape SKU for an analysis tier. */
|
|
259
|
+
function analysisSuffix(tier: MetaAdsAnalysisTier): string {
|
|
260
|
+
return tier === "standard" ? ":analysis" : `:analysis:${tier}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildMetaAdsCreditCostTable(): Record<string, number> {
|
|
264
|
+
const table: Record<string, number> = { [META_ADS_SCRAPE_NODE_TYPE]: 20 }
|
|
265
|
+
for (const tier of META_ADS_ANALYSIS_TIERS) table[metaAdsAnalysisCreditId(tier)] = META_ADS_ANALYSIS_CREDITS_PER_AD[tier]
|
|
266
|
+
// Key by the TIER value directly (not via a count that clamps at MAX_COUNT):
|
|
267
|
+
// the 200 / 500 tiers are reachable through sources > 1, so their analysis
|
|
268
|
+
// rows must exist and be tier-based, not count-based.
|
|
269
|
+
for (const t of META_ADS_SCRAPE_TIERS) {
|
|
270
|
+
table[`${META_ADS_SCRAPE_NODE_TYPE}:${t}`] = t
|
|
271
|
+
for (const tier of META_ADS_ANALYSIS_TIERS) {
|
|
272
|
+
table[`${META_ADS_SCRAPE_NODE_TYPE}:${t}${analysisSuffix(tier)}`] = t * (1 + META_ADS_ANALYSIS_CREDITS_PER_AD[tier])
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return table
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Credit cost per SKU — mirror of the backend `STATIC_CREDIT_COSTS` rows and
|
|
280
|
+
* migrations 428 / 429, for the frontend badge / estimator. 1 credit per
|
|
281
|
+
* requested ad at every tier, plus the analysis multiples above; the bare
|
|
282
|
+
* identifier is the pre-Zod fallback (mid tier, never the max).
|
|
283
|
+
*/
|
|
284
|
+
export const META_ADS_SCRAPE_CREDIT_COSTS: Record<string, number> = buildMetaAdsCreditCostTable()
|
|
285
|
+
|
|
286
|
+
export const META_ADS_SCRAPE_FALLBACK_CREDIT_ID = "meta-ads-scrape:20"
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Page urls are typed one per line in the config panel (a FieldMapping
|
|
290
|
+
* injects the same text); the route wants an array. Tolerates commas and
|
|
291
|
+
* whitespace as separators (a url never contains either) and an array that
|
|
292
|
+
* already went through this once.
|
|
293
|
+
*/
|
|
294
|
+
export function splitMetaAdsPageUrls(value: unknown): string[] {
|
|
295
|
+
if (Array.isArray(value)) {
|
|
296
|
+
return value.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter((v) => v.length > 0)
|
|
297
|
+
}
|
|
298
|
+
if (typeof value !== "string") return []
|
|
299
|
+
return value.split(/[\n,\s]+/).map((v) => v.trim()).filter((v) => v.length > 0)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function isMetaAdsScrapeMode(value: unknown): value is MetaAdsScrapeMode {
|
|
303
|
+
return typeof value === "string" && (META_ADS_SCRAPE_MODES as readonly string[]).includes(value)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Advertiser NAMES to resolve at run time (advertiser mode driven by the `in`
|
|
308
|
+
* input) — one per line or comma-separated. Unlike page urls, a name contains
|
|
309
|
+
* spaces, so this never splits on whitespace. Trimmed, de-duped, each 2..100
|
|
310
|
+
* chars, capped at `MAX_SOURCES`.
|
|
311
|
+
*/
|
|
312
|
+
export function splitMetaAdsAdvertiserNames(value: unknown): string[] {
|
|
313
|
+
const raw = Array.isArray(value)
|
|
314
|
+
? value.filter((v): v is string => typeof v === "string")
|
|
315
|
+
: typeof value === "string"
|
|
316
|
+
? value.split(/[\n,]+/)
|
|
317
|
+
: []
|
|
318
|
+
const seen = new Set<string>()
|
|
319
|
+
const out: string[] = []
|
|
320
|
+
for (const item of raw) {
|
|
321
|
+
const name = item.trim()
|
|
322
|
+
if (name.length < 2 || name.length > META_ADS_SCRAPE_MAX_QUERY_LENGTH) continue
|
|
323
|
+
const key = name.toLowerCase()
|
|
324
|
+
if (seen.has(key)) continue
|
|
325
|
+
seen.add(key)
|
|
326
|
+
out.push(name)
|
|
327
|
+
if (out.length >= META_ADS_SCRAPE_MAX_SOURCES) break
|
|
328
|
+
}
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The node-data fields that decide what a run scrapes (and therefore what it costs). Index-signature so any node-data bag is accepted as-is. */
|
|
333
|
+
export interface MetaAdsNodeSourceFields {
|
|
334
|
+
readonly [key: string]: unknown
|
|
335
|
+
readonly mode?: unknown
|
|
336
|
+
readonly query?: unknown
|
|
337
|
+
readonly pageUrls?: unknown
|
|
338
|
+
readonly advertisers?: unknown
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* How many sources a run bills — the number the card badge, the run total,
|
|
343
|
+
* the pre-run estimator and the backend quote must all read, so an advertiser
|
|
344
|
+
* pick can never be quoted as one source while the server reserves five. An
|
|
345
|
+
* empty page list / no picks counts as one (the run then fails validation
|
|
346
|
+
* before anything is reserved).
|
|
347
|
+
*/
|
|
348
|
+
export function metaAdsScrapeSources(data: MetaAdsNodeSourceFields): number {
|
|
349
|
+
switch (metaAdsNodeMode(data.mode)) {
|
|
350
|
+
case "pages":
|
|
351
|
+
return Math.max(1, Math.min(splitMetaAdsPageUrls(data.pageUrls).length, META_ADS_SCRAPE_MAX_SOURCES))
|
|
352
|
+
case "advertiser":
|
|
353
|
+
return Math.max(1, metaAdsAdvertisersFrom(data.advertisers).length)
|
|
354
|
+
default:
|
|
355
|
+
return 1
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export type MetaAdsWireSources =
|
|
360
|
+
| { readonly mode: "search"; readonly query: string | undefined }
|
|
361
|
+
| { readonly mode: "pages"; readonly pageUrls: string[]; readonly advertiserNames?: string[] }
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The wire half of a request from node data — ONE mapping for the editor's
|
|
365
|
+
* executor and the orchestrator's payload builder. The keyword / page list
|
|
366
|
+
* falls back to the upstream text so a Prompt or List node can drive the
|
|
367
|
+
* scrape; advertiser picks are explicit (no upstream fallback) and run as
|
|
368
|
+
* their Page urls, which is why the route never sees "advertiser".
|
|
369
|
+
*/
|
|
370
|
+
export function metaAdsScrapeWireSources(data: MetaAdsNodeSourceFields, upstream?: unknown): MetaAdsWireSources {
|
|
371
|
+
const upstreamText = typeof upstream === "string" ? upstream : undefined
|
|
372
|
+
switch (metaAdsNodeMode(data.mode)) {
|
|
373
|
+
case "pages": {
|
|
374
|
+
const own = splitMetaAdsPageUrls(data.pageUrls)
|
|
375
|
+
return { mode: "pages", pageUrls: own.length > 0 ? own : splitMetaAdsPageUrls(upstreamText) }
|
|
376
|
+
}
|
|
377
|
+
case "advertiser": {
|
|
378
|
+
// Explicit picks run as their Page urls. With no picks but upstream
|
|
379
|
+
// text, the `in` value is advertiser NAME(s) to resolve at run time
|
|
380
|
+
// (the route looks each up and picks the verified/first Page).
|
|
381
|
+
const picks = metaAdsAdvertisersFrom(data.advertisers)
|
|
382
|
+
if (picks.length > 0) return { mode: "pages", pageUrls: picks.map((a) => a.url) }
|
|
383
|
+
const names = splitMetaAdsAdvertiserNames(upstreamText)
|
|
384
|
+
return { mode: "pages", pageUrls: [], advertiserNames: names }
|
|
385
|
+
}
|
|
386
|
+
default: {
|
|
387
|
+
const own = typeof data.query === "string" ? data.query : ""
|
|
388
|
+
return { mode: "search", query: own || upstreamText }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function isMetaAdsScrapeCount(value: unknown): value is number {
|
|
394
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= META_ADS_SCRAPE_MAX_COUNT
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Smallest tier that fits the requested total; clamps to the top tier. */
|
|
398
|
+
export function metaAdsScrapeTier(requestedTotal: number): MetaAdsScrapeTier {
|
|
399
|
+
for (const tier of META_ADS_SCRAPE_TIERS) {
|
|
400
|
+
if (requestedTotal <= tier) return tier
|
|
401
|
+
}
|
|
402
|
+
return META_ADS_SCRAPE_TIERS[META_ADS_SCRAPE_TIERS.length - 1]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface MetaAdsScrapeCreditInput {
|
|
406
|
+
count: number
|
|
407
|
+
/** Number of input URLs in pages mode; 1 for a keyword search. */
|
|
408
|
+
sources: number
|
|
409
|
+
/** The analysing model's tier when per-ad analysis is on; absent / null = scrape only. */
|
|
410
|
+
analysis?: MetaAdsAnalysisTier | null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function buildMetaAdsScrapeCreditId(input: MetaAdsScrapeCreditInput): string {
|
|
414
|
+
const sources = Math.min(Math.max(Math.trunc(input.sources) || 1, 1), META_ADS_SCRAPE_MAX_SOURCES)
|
|
415
|
+
const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), META_ADS_SCRAPE_MAX_COUNT)
|
|
416
|
+
const base = `${META_ADS_SCRAPE_NODE_TYPE}:${metaAdsScrapeTier(count * sources)}`
|
|
417
|
+
return input.analysis ? `${base}${analysisSuffix(input.analysis)}` : base
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The analysis tier a request / node asks for, or null when analysis is off. */
|
|
421
|
+
export function metaAdsAnalysisTierFrom(data: { readonly analyze?: unknown; readonly analysisModel?: unknown }): MetaAdsAnalysisTier | null {
|
|
422
|
+
return data.analyze === true ? metaAdsAnalysisTier(data.analysisModel) : null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Resolve the credit identifier from an UNVALIDATED request body (the
|
|
427
|
+
* creditGuard preHandler runs before Zod). It must land on the SAME tier the
|
|
428
|
+
* post-Zod reservation computes, so an OMITTED count is the route's default
|
|
429
|
+
* (Zod fills it in the same way); only a present-but-invalid body reserves
|
|
430
|
+
* the fixed mid tier, and the route then rejects it with a 400 and refunds.
|
|
431
|
+
*/
|
|
432
|
+
export function resolveMetaAdsScrapeCreditId(body: unknown): string {
|
|
433
|
+
const raw = body as { mode?: unknown; count?: unknown; pageUrls?: unknown; advertiserNames?: unknown; analyze?: unknown; analysisModel?: unknown } | null | undefined
|
|
434
|
+
if (!raw || typeof raw !== "object") return META_ADS_SCRAPE_FALLBACK_CREDIT_ID
|
|
435
|
+
const count = raw.count === undefined ? META_ADS_SCRAPE_DEFAULT_COUNT : raw.count
|
|
436
|
+
if (!isMetaAdsScrapeCount(count)) return META_ADS_SCRAPE_FALLBACK_CREDIT_ID
|
|
437
|
+
// Pages mode bills per source: the page urls PLUS any advertiser names the
|
|
438
|
+
// route will resolve to page urls at run time. Counting names may over-check
|
|
439
|
+
// when one doesn't resolve — the safe direction (the reservation trues down).
|
|
440
|
+
const sources = raw.mode === "pages"
|
|
441
|
+
? (Array.isArray(raw.pageUrls) ? raw.pageUrls.length : 0) + (Array.isArray(raw.advertiserNames) ? raw.advertiserNames.length : 0)
|
|
442
|
+
: 1
|
|
443
|
+
if (sources < 1 || sources > META_ADS_SCRAPE_MAX_SOURCES) return META_ADS_SCRAPE_FALLBACK_CREDIT_ID
|
|
444
|
+
return buildMetaAdsScrapeCreditId({ count, sources, analysis: metaAdsAnalysisTierFrom(raw) })
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The node-data fields a quote reads: what a run scrapes, how many, and whether it analyses. */
|
|
448
|
+
export interface MetaAdsNodeQuoteFields extends MetaAdsNodeSourceFields {
|
|
449
|
+
readonly count?: unknown
|
|
450
|
+
readonly analyze?: unknown
|
|
451
|
+
readonly analysisModel?: unknown
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The ONE credit identifier for a node's current settings — the card badge,
|
|
456
|
+
* the run total, the pre-run estimator and the backend quote all read this,
|
|
457
|
+
* and it is the same builder the route's guard + reservation use on the wire
|
|
458
|
+
* body, so no surface can quote a different SKU than the one reserved.
|
|
459
|
+
*/
|
|
460
|
+
export function metaAdsScrapeCreditIdFromNode(data: MetaAdsNodeQuoteFields): string {
|
|
461
|
+
const count = typeof data.count === "number" ? data.count : META_ADS_SCRAPE_DEFAULT_COUNT
|
|
462
|
+
return buildMetaAdsScrapeCreditId({ count, sources: metaAdsScrapeSources(data), analysis: metaAdsAnalysisTierFrom(data) })
|
|
463
|
+
}
|
package/src/model-catalog.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { isFlux2Model } from "./flux2-pricing.js"
|
|
32
|
+
import { isAutoVideoDuration } from "./video-duration-auto.js"
|
|
32
33
|
|
|
33
34
|
export type ModelKind = "image" | "video" | "audio"
|
|
34
35
|
|
|
@@ -152,6 +153,14 @@ export interface ModelCatalogEntry {
|
|
|
152
153
|
unlistedResolutionRendersAs?: string
|
|
153
154
|
qualities?: readonly string[]
|
|
154
155
|
durations?: readonly number[]
|
|
156
|
+
/**
|
|
157
|
+
* The model accepts an AUTO duration (`duration: -1`, see
|
|
158
|
+
* `VIDEO_DURATION_AUTO`): it picks the clip length itself — the source clip's
|
|
159
|
+
* length on a video edit, a length within `durations` otherwise. A capability,
|
|
160
|
+
* not a member of `durations` (which stay real seconds). Runs are reserved at
|
|
161
|
+
* the longest clip and settled on the delivered one.
|
|
162
|
+
*/
|
|
163
|
+
autoDuration?: boolean
|
|
155
164
|
pricing: readonly PriceVariant[]
|
|
156
165
|
/** Editorial highlight — "best in tier". Surfaces in MCP output as a ⭐. */
|
|
157
166
|
featured?: boolean
|
|
@@ -209,7 +218,7 @@ export const MODEL_RECOMMENDATIONS: readonly ModelRecommendation[] = [
|
|
|
209
218
|
{ intent: "music / song generation", modelIds: ["suno-v6", "suno-v6_wild", "suno-v6_mini", "suno-v5_5"], note: "V6 is the default flagship; V6 Wild for bolder, less predictable results; V6 Mini when speed matters; v5.5 / v5 / v4 keep their own character. Same price." },
|
|
210
219
|
{ intent: "voice over / narration", modelIds: ["elevenlabs-v3", "elevenlabs-turbo"], note: "v3 supports [audio tags] for emotion; Turbo is cheaper for plain narration." },
|
|
211
220
|
{ intent: "lip-sync a portrait to audio", modelIds: ["kling-avatar-pro", "kling-avatar", "infinitalk"], note: "Pro for best mouth shape; InfiniTalk for resolution control." },
|
|
212
|
-
{ intent: "transcription / captions", modelIds: ["elevenlabs-stt"], note: "
|
|
221
|
+
{ intent: "transcription / captions", modelIds: ["elevenlabs-stt", "incredibly-fast-whisper", "whisper"], note: "Captions need WORD timestamps: ElevenLabs STT (always) or Incredibly Fast Whisper. Plain Whisper returns phrase segments only." },
|
|
213
222
|
{ intent: "motion transfer (drive a subject by another video)", modelIds: ["motion-transfer", "kling-3.0-motion"], note: "Kling 2.6 base is cheap; Kling 3.0 is premium." },
|
|
214
223
|
] as const
|
|
215
224
|
|
|
@@ -1528,6 +1537,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1528
1537
|
features: ["end-frame", "audio", "reference-image", "video-reference"],
|
|
1529
1538
|
aspectRatios: VIDEO_RATIOS_SEEDANCE_2,
|
|
1530
1539
|
durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1540
|
+
autoDuration: true,
|
|
1531
1541
|
resolutions: ["480p", "720p", "1080p", "4k"],
|
|
1532
1542
|
pricing: [
|
|
1533
1543
|
{ identifier: "seedance-2", credits: 380, note: "default — see :NsR variants for exact" },
|
|
@@ -1553,6 +1563,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1553
1563
|
features: ["end-frame", "audio", "reference-image", "video-reference"],
|
|
1554
1564
|
aspectRatios: VIDEO_RATIOS_SEEDANCE_2,
|
|
1555
1565
|
durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1566
|
+
autoDuration: true,
|
|
1556
1567
|
resolutions: ["480p", "720p"],
|
|
1557
1568
|
pricing: [
|
|
1558
1569
|
{ identifier: "seedance-2-fast", credits: 310, note: "default — see :NsR variants" },
|
|
@@ -1574,6 +1585,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1574
1585
|
features: ["end-frame", "audio", "reference-image", "video-reference"],
|
|
1575
1586
|
aspectRatios: VIDEO_RATIOS_SEEDANCE_2,
|
|
1576
1587
|
durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
1588
|
+
autoDuration: true,
|
|
1577
1589
|
resolutions: ["480p", "720p"],
|
|
1578
1590
|
pricing: [
|
|
1579
1591
|
{ identifier: "seedance-2-mini", credits: 190, note: "default — see :NsR variants" },
|
|
@@ -1601,6 +1613,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1601
1613
|
features: ["end-frame", "audio", "reference-image", "video-reference"],
|
|
1602
1614
|
aspectRatios: VIDEO_RATIOS_SEEDANCE_2,
|
|
1603
1615
|
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],
|
|
1616
|
+
autoDuration: true,
|
|
1604
1617
|
resolutions: ["480p", "720p", "1080p"],
|
|
1605
1618
|
pricing: [
|
|
1606
1619
|
{ identifier: "seedance-2-5", credits: 1260, note: "default 8s 720p — see :Ns:res variants for exact" },
|
|
@@ -2491,10 +2504,34 @@ const AUDIO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
2491
2504
|
family: "ElevenLabs",
|
|
2492
2505
|
label: "ElevenLabs STT",
|
|
2493
2506
|
series: "ElevenLabs",
|
|
2494
|
-
description: "Speech-to-text
|
|
2495
|
-
useCases: ["transcription", "stt"],
|
|
2507
|
+
description: "Speech-to-text with WORD-level timestamps (always on), speaker diarization and audio-event tags. The engine to use when the transcript feeds captions.",
|
|
2508
|
+
useCases: ["transcription", "stt", "captions"],
|
|
2509
|
+
features: ["word-timestamps", "diarization", "audio-events"],
|
|
2496
2510
|
pricing: [{ identifier: "elevenlabs-stt", credits: 22 }],
|
|
2497
2511
|
},
|
|
2512
|
+
"incredibly-fast-whisper": {
|
|
2513
|
+
id: "incredibly-fast-whisper",
|
|
2514
|
+
kind: "audio",
|
|
2515
|
+
modes: ["stt"] as const,
|
|
2516
|
+
family: "OpenAI",
|
|
2517
|
+
label: "Incredibly Fast Whisper",
|
|
2518
|
+
series: "Whisper",
|
|
2519
|
+
description: "Fast Whisper speech-to-text. Returns WORD-level timestamps when asked, so its transcript can feed captions.",
|
|
2520
|
+
useCases: ["transcription", "stt", "captions"],
|
|
2521
|
+
features: ["word-timestamps"],
|
|
2522
|
+
pricing: [{ identifier: "incredibly-fast-whisper", credits: 40 }],
|
|
2523
|
+
},
|
|
2524
|
+
"whisper": {
|
|
2525
|
+
id: "whisper",
|
|
2526
|
+
kind: "audio",
|
|
2527
|
+
modes: ["stt"] as const,
|
|
2528
|
+
family: "OpenAI",
|
|
2529
|
+
label: "Whisper",
|
|
2530
|
+
series: "Whisper",
|
|
2531
|
+
description: "Whisper speech-to-text — PHRASE-level segments only, NO word timestamps. Fine for a transcript or a static subtitle; not for word-timed (kinetic) captions.",
|
|
2532
|
+
useCases: ["transcription", "stt"],
|
|
2533
|
+
pricing: [{ identifier: "whisper", credits: 40 }],
|
|
2534
|
+
},
|
|
2498
2535
|
"elevenlabs-isolation": {
|
|
2499
2536
|
id: "elevenlabs-isolation",
|
|
2500
2537
|
kind: "audio",
|
|
@@ -2802,7 +2839,10 @@ export function validateModelInput(
|
|
|
2802
2839
|
allowed: null,
|
|
2803
2840
|
}
|
|
2804
2841
|
}
|
|
2805
|
-
|
|
2842
|
+
// Auto (-1) is a legal value for the providers that accept it — it is a
|
|
2843
|
+
// capability of the model, not a member of its seconds list.
|
|
2844
|
+
const isAuto = m.autoDuration === true && isAutoVideoDuration(input.duration)
|
|
2845
|
+
if (!isAuto && !m.durations.includes(input.duration)) {
|
|
2806
2846
|
return {
|
|
2807
2847
|
field: "duration",
|
|
2808
2848
|
message: `Model "${modelId}" does not support duration ${input.duration}s. Supported: ${m.durations.join(", ")}s.`,
|
|
@@ -2945,7 +2985,10 @@ export function normalizeModelInput(
|
|
|
2945
2985
|
defaultResolutionFor(modelId),
|
|
2946
2986
|
)
|
|
2947
2987
|
out.quality = snap("quality", input.quality, m.qualities)
|
|
2948
|
-
out.duration =
|
|
2988
|
+
out.duration =
|
|
2989
|
+
m.autoDuration === true && isAutoVideoDuration(input.duration)
|
|
2990
|
+
? input.duration
|
|
2991
|
+
: snap("duration", input.duration, m.durations)
|
|
2949
2992
|
|
|
2950
2993
|
// Cross-field constraints — a pair that is individually valid but jointly
|
|
2951
2994
|
// rejected upstream. GPT Image 2 (per docs.kie.ai): `auto` requires 1K, and
|