@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.
Files changed (91) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/i18n/action-fx.he.ts +8 -8
  35. package/src/i18n/aesthetic.he.ts +8 -8
  36. package/src/i18n/animals.he.ts +34 -34
  37. package/src/i18n/atmosphere.he.ts +6 -6
  38. package/src/i18n/backdrop.he.ts +7 -7
  39. package/src/i18n/camera-format.he.ts +4 -4
  40. package/src/i18n/camera-motions.he.ts +20 -20
  41. package/src/i18n/character-fx.he.ts +16 -16
  42. package/src/i18n/character-motion.he.ts +184 -184
  43. package/src/i18n/color-look.he.ts +4 -4
  44. package/src/i18n/composition-effects.he.ts +3 -3
  45. package/src/i18n/era.he.ts +9 -9
  46. package/src/i18n/exposure-settings.he.ts +2 -2
  47. package/src/i18n/framing.he.ts +13 -13
  48. package/src/i18n/furniture.he.ts +52 -52
  49. package/src/i18n/held-prop.he.ts +10 -10
  50. package/src/i18n/instrumentation.he.ts +26 -26
  51. package/src/i18n/lens.he.ts +6 -6
  52. package/src/i18n/lighting.he.ts +17 -17
  53. package/src/i18n/loop-subject.he.ts +4 -4
  54. package/src/i18n/materials.he.ts +8 -8
  55. package/src/i18n/mood.he.ts +12 -12
  56. package/src/i18n/music-genre.he.ts +9 -9
  57. package/src/i18n/music-mood.he.ts +7 -7
  58. package/src/i18n/person.he.ts +94 -94
  59. package/src/i18n/photo-genre.he.ts +24 -24
  60. package/src/i18n/photographer.he.ts +21 -21
  61. package/src/i18n/pose.he.ts +14 -14
  62. package/src/i18n/post-process-effects.he.ts +4 -4
  63. package/src/i18n/render-quality.he.ts +7 -7
  64. package/src/i18n/setting.he.ts +14 -14
  65. package/src/i18n/style.he.ts +9 -9
  66. package/src/i18n/styling.he.ts +126 -126
  67. package/src/i18n/temporal.he.ts +8 -8
  68. package/src/i18n/transitions.he.ts +9 -9
  69. package/src/i18n/vehicles.he.ts +25 -25
  70. package/src/i18n/voice-character.he.ts +14 -14
  71. package/src/i18n/voice-delivery.he.ts +11 -11
  72. package/src/i18n/weapons.he.ts +23 -23
  73. package/src/index.ts +206 -3
  74. package/src/instagram-scrape.ts +204 -0
  75. package/src/llm-models.ts +80 -3
  76. package/src/meta-ads-scrape.ts +463 -0
  77. package/src/model-catalog.ts +48 -5
  78. package/src/model-constants.ts +148 -5
  79. package/src/node-mappable-fields.ts +2 -0
  80. package/src/node-runtime-keys.ts +28 -0
  81. package/src/presentation-utils.ts +49 -0
  82. package/src/producer-types.ts +20 -0
  83. package/src/schedule-rules.ts +484 -0
  84. package/src/speaker-layouts.ts +220 -0
  85. package/src/transcribe-preflight.ts +101 -0
  86. package/src/trigger-feeds.ts +59 -0
  87. package/src/trigger-node-types.ts +20 -0
  88. package/src/video-duration-auto.ts +18 -0
  89. package/src/video-duration.ts +32 -0
  90. package/src/video-link.ts +167 -0
  91. 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
+ }
@@ -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: "Word-level timestamps available." },
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 — transcribe audio with timestamps.",
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
- if (!m.durations.includes(input.duration)) {
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 = snap("duration", input.duration, m.durations)
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