@mevdragon/vidfarm-devcli 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +3 -3
  2. package/demo/README.md +28 -0
  3. package/demo/dist/app.css +1 -0
  4. package/demo/dist/app.js +1184 -0
  5. package/demo/dist/chunks/chunk-DXB73IDG.js +1 -0
  6. package/demo/dist/chunks/chunk-S7OWAJDS.js +36 -0
  7. package/demo/dist/chunks/chunk-VTIBZ6AN.js +1 -0
  8. package/demo/dist/chunks/dist-ADSJKBVE.js +332 -0
  9. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +1 -0
  10. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +425 -0
  11. package/demo/dist/chunks/lib-XAQ37YOE.js +1 -0
  12. package/demo/dist/chunks/src-TJ2QYA4U.js +207 -0
  13. package/demo/dist/favicon.ico +0 -0
  14. package/demo/dist/icons/timeline/audio.svg +7 -0
  15. package/demo/dist/icons/timeline/captions.svg +5 -0
  16. package/demo/dist/icons/timeline/composition.svg +12 -0
  17. package/demo/dist/icons/timeline/image.svg +18 -0
  18. package/demo/dist/icons/timeline/music.svg +10 -0
  19. package/demo/dist/icons/timeline/text.svg +3 -0
  20. package/demo/dist/index.html +15 -0
  21. package/dist/src/account-pages-legacy.js +9396 -0
  22. package/dist/src/account-pages.js +61 -0
  23. package/dist/src/app.js +14378 -0
  24. package/dist/src/cli.js +1 -1
  25. package/dist/src/composition-runtime.js +613 -0
  26. package/dist/src/config.js +166 -0
  27. package/dist/src/context.js +447 -0
  28. package/dist/src/dev-app-legacy.js +739 -0
  29. package/dist/src/dev-app.js +6 -0
  30. package/dist/src/domain.js +2 -0
  31. package/dist/src/editor-chat-history.js +82 -0
  32. package/dist/src/editor-chat.js +449 -0
  33. package/dist/src/editor-dark-theme.js +1128 -0
  34. package/dist/src/frontend/debug.js +71 -0
  35. package/dist/src/frontend/flockposter-cache-store.js +124 -0
  36. package/dist/src/frontend/homepage-client.js +182 -0
  37. package/dist/src/frontend/homepage-shared.js +4 -0
  38. package/dist/src/frontend/homepage-store.js +28 -0
  39. package/dist/src/frontend/homepage-view.js +547 -0
  40. package/dist/src/frontend/page-runtime-client.js +132 -0
  41. package/dist/src/frontend/page-runtime-store.js +9 -0
  42. package/dist/src/frontend/sentry.js +42 -0
  43. package/dist/src/frontend/template-editor-chat.js +3960 -0
  44. package/dist/src/help-page.js +346 -0
  45. package/dist/src/homepage.js +1235 -0
  46. package/dist/src/hyperframes/composition.js +180 -0
  47. package/dist/src/index.js +16 -0
  48. package/dist/src/instrument.js +30 -0
  49. package/dist/src/lib/crypto.js +45 -0
  50. package/dist/src/lib/dev-log.js +54 -0
  51. package/dist/src/lib/display-name.js +11 -0
  52. package/dist/src/lib/ids.js +24 -0
  53. package/dist/src/lib/images.js +19 -0
  54. package/dist/src/lib/json.js +15 -0
  55. package/dist/src/lib/package-root.js +47 -0
  56. package/dist/src/lib/template-paths.js +28 -0
  57. package/dist/src/lib/time.js +7 -0
  58. package/dist/src/lib/url-clean.js +85 -0
  59. package/dist/src/page-runtime.js +2 -0
  60. package/dist/src/page-shell.js +1381 -0
  61. package/dist/src/primitive-context.js +357 -0
  62. package/dist/src/primitive-registry.js +2436 -0
  63. package/dist/src/primitive-sdk.js +4 -0
  64. package/dist/src/primitives/hyperframes-media.js +108 -0
  65. package/dist/src/react-page-shell.js +35 -0
  66. package/dist/src/ready-post-schedule-component.js +1540 -0
  67. package/dist/src/registry.js +296 -0
  68. package/dist/src/runtime.js +35 -0
  69. package/dist/src/services/api-call-history.js +249 -0
  70. package/dist/src/services/auth.js +152 -0
  71. package/dist/src/services/billing-pricing.js +39 -0
  72. package/dist/src/services/billing.js +228 -0
  73. package/dist/src/services/cast.js +127 -0
  74. package/dist/src/services/chat-threads.js +92 -0
  75. package/dist/src/services/composition-sanitize.js +124 -0
  76. package/dist/src/services/composition-watch.js +79 -0
  77. package/dist/src/services/fork-access.js +93 -0
  78. package/dist/src/services/fork-manifest.js +42 -0
  79. package/dist/src/services/ghostcut.js +179 -0
  80. package/dist/src/services/hyperframes.js +2307 -0
  81. package/dist/src/services/job-capacity.js +14 -0
  82. package/dist/src/services/job-logs.js +197 -0
  83. package/dist/src/services/jobs.js +136 -0
  84. package/dist/src/services/local-dynamo.js +0 -0
  85. package/dist/src/services/media-processing.js +766 -0
  86. package/dist/src/services/primitive-media-lambda.js +280 -0
  87. package/dist/src/services/providers.js +2926 -0
  88. package/dist/src/services/rate-limits.js +262 -0
  89. package/dist/src/services/serverless-auth.js +382 -0
  90. package/dist/src/services/serverless-jobs.js +1082 -0
  91. package/dist/src/services/serverless-provider-keys.js +409 -0
  92. package/dist/src/services/serverless-records.js +1385 -0
  93. package/dist/src/services/serverless-template-configs.js +75 -0
  94. package/dist/src/services/storage.js +383 -0
  95. package/dist/src/services/template-certification.js +413 -0
  96. package/dist/src/services/template-loader.js +99 -0
  97. package/dist/src/services/template-runtime-bundles.js +217 -0
  98. package/dist/src/services/template-sources.js +1017 -0
  99. package/dist/src/services/video-normalization.js +2 -0
  100. package/dist/src/services/webhooks.js +62 -0
  101. package/dist/src/template-editor-pages.js +2576 -0
  102. package/dist/src/template-editor-shell.js +2840 -0
  103. package/dist/src/template-sdk.js +4 -0
  104. package/dist/src/worker.js +17 -0
  105. package/package.json +6 -4
  106. package/public/assets/homepage-app.js +54 -0
  107. package/public/assets/homepage-client-app.js +80 -0
  108. package/public/assets/page-runtime-client-app.js +94 -0
  109. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  110. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
  111. package/src/assets/SELLING_WITH_VSLS.md +606 -0
  112. package/src/assets/favicon.ico +0 -0
  113. package/src/assets/logo-vidfarm.png +0 -0
@@ -0,0 +1,2926 @@
1
+ import { config } from "../config.js";
2
+ import { parseJson } from "../lib/json.js";
3
+ import { decryptString } from "../lib/crypto.js";
4
+ import { createId } from "../lib/ids.js";
5
+ import { addSeconds } from "../lib/time.js";
6
+ import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
+ import sharp from "sharp";
8
+ export class ProviderKeyUnavailableError extends Error {
9
+ }
10
+ export class ProviderAuthError extends Error {
11
+ }
12
+ export class ProviderQuotaExceededError extends Error {
13
+ }
14
+ export class ProviderWaitExceededError extends Error {
15
+ }
16
+ export class ProviderLeaseTimeoutError extends Error {
17
+ retryAfterSeconds;
18
+ constructor(message, retryAfterSeconds = 5) {
19
+ super(message);
20
+ this.retryAfterSeconds = retryAfterSeconds;
21
+ }
22
+ }
23
+ export class ProviderRateLimitError extends Error {
24
+ retryAfterSeconds;
25
+ constructor(message, retryAfterSeconds = 60) {
26
+ super(message);
27
+ this.retryAfterSeconds = retryAfterSeconds;
28
+ }
29
+ }
30
+ export class ProviderTransientError extends Error {
31
+ retryAfterSeconds;
32
+ constructor(message, retryAfterSeconds = 5) {
33
+ super(message);
34
+ this.retryAfterSeconds = retryAfterSeconds;
35
+ }
36
+ }
37
+ const SUPPORTED_TEXT_MODELS = {
38
+ openai: new Set(["gpt-5.4"]),
39
+ gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
40
+ openrouter: new Set(["qwen/qwen3.6-plus"]),
41
+ perplexity: new Set(["sonar"])
42
+ };
43
+ const SUPPORTED_IMAGE_MODELS = {
44
+ openai: new Set(["gpt-image-2", "gpt-image-1"]),
45
+ gemini: new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"]),
46
+ openrouter: new Set([
47
+ "google/gemini-3.1-flash-image-preview",
48
+ "google/gemini-2.5-flash-image-preview",
49
+ "bytedance/seedance-2.0",
50
+ "bytedance-seed/seedream-4.5"
51
+ ])
52
+ };
53
+ const SUPPORTED_VIDEO_MODELS = {
54
+ openai: new Set(["sora-2", "sora-2-pro"]),
55
+ gemini: new Set(["veo-3.0-generate-001", "veo-3.0-fast-generate-001"]),
56
+ openrouter: new Set([
57
+ "bytedance/seedance-2.0-fast",
58
+ "bytedance/seedance-2.0",
59
+ "google/veo-3.1-lite",
60
+ "google/veo-3.1-fast",
61
+ "google/veo-3.1",
62
+ "kwaivgi/kling-v3.0-std",
63
+ "kwaivgi/kling-v3.0-pro",
64
+ "openai/sora-2-pro"
65
+ ])
66
+ };
67
+ // Video renders poll in-process; the job-runner Lambda times out at 15 minutes, so the
68
+ // in-process poll budget must stay safely below that (24 attempts x 30s = 12 minutes).
69
+ const VIDEO_POLL_MAX_ATTEMPTS = 24;
70
+ const VIDEO_POLL_INTERVAL_MS = 30_000;
71
+ const DEFAULT_TEXT_MODELS = {
72
+ openai: "gpt-5.4",
73
+ gemini: "gemini-3.1-flash-lite",
74
+ openrouter: "qwen/qwen3.6-plus",
75
+ perplexity: "sonar"
76
+ };
77
+ const DEFAULT_IMAGE_MODELS = {
78
+ openai: "gpt-image-2",
79
+ gemini: "gemini-3-pro-image-preview",
80
+ openrouter: "google/gemini-3.1-flash-image-preview"
81
+ };
82
+ const DEFAULT_VIDEO_MODELS = {
83
+ openai: "sora-2",
84
+ gemini: "veo-3.0-generate-001",
85
+ openrouter: "bytedance/seedance-2.0"
86
+ };
87
+ const PROVIDER_FALLBACK_PRIORITY = ["openai", "gemini", "openrouter", "perplexity"];
88
+ const OPENAI_IMAGE_SIZES = new Set([
89
+ "1024x1024",
90
+ "1536x1024",
91
+ "1024x1536",
92
+ "2048x2048",
93
+ "2048x1152",
94
+ "3840x2160",
95
+ "2160x3840",
96
+ "auto"
97
+ ]);
98
+ const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
99
+ const SUPPORTED_TTS_MODELS = {
100
+ openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
101
+ gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
102
+ openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
103
+ };
104
+ const SUPPORTED_STT_MODELS = {
105
+ openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
106
+ gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
107
+ openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
108
+ };
109
+ const PROVIDER_KEY_VALIDATION_MODELS = {
110
+ openai: "gpt-4.1-mini",
111
+ gemini: "gemini-3.1-flash-lite-preview",
112
+ openrouter: "google/gemini-3.1-flash-lite-preview",
113
+ perplexity: "sonar"
114
+ };
115
+ function readStoredProviderSecret(value) {
116
+ try {
117
+ return decryptString(value, config.ENCRYPTION_SECRET);
118
+ }
119
+ catch {
120
+ return value;
121
+ }
122
+ }
123
+ function sleep(ms) {
124
+ return new Promise((resolve) => setTimeout(resolve, ms));
125
+ }
126
+ function formatDurationSeconds(totalSeconds) {
127
+ if (totalSeconds >= 3600) {
128
+ const hours = Math.round(totalSeconds / 3600);
129
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
130
+ }
131
+ if (totalSeconds >= 60) {
132
+ const minutes = Math.round(totalSeconds / 60);
133
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
134
+ }
135
+ return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
136
+ }
137
+ function parseRetryAfterSeconds(value) {
138
+ if (!value) {
139
+ return 60;
140
+ }
141
+ const numeric = Number(value);
142
+ if (Number.isFinite(numeric) && numeric > 0) {
143
+ return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
144
+ }
145
+ const parsedDate = Date.parse(value);
146
+ if (Number.isFinite(parsedDate)) {
147
+ return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
148
+ }
149
+ return 60;
150
+ }
151
+ function isHardQuotaError(details) {
152
+ const normalized = details.toLowerCase();
153
+ return [
154
+ "insufficient_quota",
155
+ "quota_exceeded",
156
+ "exceeded your current quota",
157
+ "billing",
158
+ "insufficient credits",
159
+ "not enough credits",
160
+ "credit balance",
161
+ "prepaid credits",
162
+ "resource_exhausted"
163
+ ].some((pattern) => normalized.includes(pattern));
164
+ }
165
+ function conciseProviderDetails(details) {
166
+ const compact = details.replace(/\s+/g, " ").trim();
167
+ return compact ? `: ${compact.slice(0, 500)}` : "";
168
+ }
169
+ function conciseProviderResponse(operation, response, details) {
170
+ return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
171
+ }
172
+ async function rateLimitOrQuotaError(provider, response) {
173
+ const details = await response.text();
174
+ if (isHardQuotaError(details)) {
175
+ return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
176
+ }
177
+ return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
178
+ }
179
+ function isTransientProviderStatus(status) {
180
+ return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
181
+ }
182
+ function isTransientRemoteMediaStatus(status) {
183
+ return status === 408 || status === 425 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
184
+ }
185
+ function remoteMediaRetryDelayMs(response, attempt) {
186
+ const retryAfter = response?.headers.get("retry-after") ?? null;
187
+ if (retryAfter) {
188
+ return Math.min(parseRetryAfterSeconds(retryAfter) * 1000, 5_000);
189
+ }
190
+ return Math.min(250 * (2 ** attempt), 2_000);
191
+ }
192
+ async function transientProviderError(provider, operation, response) {
193
+ const details = await response.text().catch(() => "");
194
+ return new ProviderTransientError(`${provider} ${operation} temporarily failed with ${response.status}${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
195
+ }
196
+ async function waitForRetryableProviderRateLimit(error, deadlineMs) {
197
+ const waitMs = error.retryAfterSeconds * 1000;
198
+ if (Date.now() + waitMs > deadlineMs) {
199
+ throw new ProviderWaitExceededError(`Provider remained rate limited for ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
200
+ }
201
+ await sleep(waitMs);
202
+ }
203
+ async function waitForRetryableProviderError(error, deadlineMs) {
204
+ if (error instanceof ProviderQuotaExceededError || error instanceof ProviderAuthError) {
205
+ return false;
206
+ }
207
+ if (error instanceof ProviderRateLimitError) {
208
+ await waitForRetryableProviderRateLimit(error, deadlineMs);
209
+ return true;
210
+ }
211
+ if (error instanceof ProviderLeaseTimeoutError) {
212
+ const waitMs = error.retryAfterSeconds * 1000;
213
+ if (Date.now() + waitMs > deadlineMs) {
214
+ throw new ProviderWaitExceededError(`No eligible provider key became available within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
215
+ }
216
+ await sleep(waitMs);
217
+ return true;
218
+ }
219
+ if (error instanceof ProviderTransientError) {
220
+ const waitMs = error.retryAfterSeconds * 1000;
221
+ if (Date.now() + waitMs > deadlineMs) {
222
+ throw new ProviderWaitExceededError(`Provider remained temporarily unavailable within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
223
+ }
224
+ await sleep(waitMs);
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+ function isRecoverableProviderAttemptError(error) {
230
+ return error instanceof ProviderLeaseTimeoutError ||
231
+ error instanceof ProviderKeyUnavailableError ||
232
+ error instanceof ProviderRateLimitError ||
233
+ error instanceof ProviderQuotaExceededError ||
234
+ error instanceof ProviderAuthError ||
235
+ error instanceof ProviderTransientError;
236
+ }
237
+ function zeroUsageCost(result) {
238
+ return {
239
+ ...result,
240
+ usage: {
241
+ ...result.usage,
242
+ costUsd: 0
243
+ }
244
+ };
245
+ }
246
+ function providerSupportsModel(kind, provider, model) {
247
+ const supported = kind === "text" ? SUPPORTED_TEXT_MODELS : kind === "image" ? SUPPORTED_IMAGE_MODELS : SUPPORTED_VIDEO_MODELS;
248
+ return providerSupportsModelFrom(supported, provider, model);
249
+ }
250
+ function defaultModelFor(kind, provider) {
251
+ return kind === "text" ? DEFAULT_TEXT_MODELS[provider] : kind === "image" ? DEFAULT_IMAGE_MODELS[provider] : DEFAULT_VIDEO_MODELS[provider];
252
+ }
253
+ function providerSupportsModelFrom(supported, provider, model) {
254
+ return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
255
+ }
256
+ function defaultSpeechModelFor(kind, provider) {
257
+ return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
258
+ }
259
+ function uniqueProviders(requested, supported) {
260
+ return [
261
+ requested,
262
+ ...PROVIDER_FALLBACK_PRIORITY.filter((provider) => provider !== requested)
263
+ ].filter((provider) => supported[provider]?.size);
264
+ }
265
+ async function availableProviderCounts(providerKeyService, customerId) {
266
+ return new Map((await providerKeyService.listAvailableProviderKeyCounts(customerId))
267
+ .map((entry) => [entry.provider, entry.availableCount]));
268
+ }
269
+ async function configuredProviderCounts(providerKeyService, customerId) {
270
+ const counts = new Map();
271
+ for (const entry of await providerKeyService.listProviderKeysWithSecrets(customerId)) {
272
+ const provider = String(entry.provider);
273
+ const status = String(entry.status);
274
+ const secret = readStoredProviderSecret(String(entry.secret ?? ""));
275
+ if (status !== "active" || !secret.trim()) {
276
+ continue;
277
+ }
278
+ counts.set(provider, (counts.get(provider) ?? 0) + 1);
279
+ }
280
+ return counts;
281
+ }
282
+ async function providerAttempts(input) {
283
+ const keyCounts = await configuredProviderCounts(input.providerKeyService, input.customerId);
284
+ const availableCounts = await availableProviderCounts(input.providerKeyService, input.customerId);
285
+ const attempts = uniqueProviders(input.requestedProvider, input.supportedModels)
286
+ .map((provider, index) => ({ provider, index }))
287
+ .filter(({ provider }) => (keyCounts.get(provider) ?? 0) > 0)
288
+ .filter(({ provider }) => input.allowProvider?.(provider) ?? true)
289
+ .sort((a, b) => {
290
+ const aAvailable = (availableCounts.get(a.provider) ?? 0) > 0 ? 0 : 1;
291
+ const bAvailable = (availableCounts.get(b.provider) ?? 0) > 0 ? 0 : 1;
292
+ return aAvailable - bAvailable || a.index - b.index;
293
+ })
294
+ .map(({ provider }) => ({
295
+ provider,
296
+ model: provider === input.requestedProvider && providerSupportsModelFrom(input.supportedModels, provider, input.requestedModel)
297
+ ? normalizeProviderModel(input.requestedModel)
298
+ : input.defaultModelForProvider(provider)
299
+ }))
300
+ .filter((attempt) => Boolean(attempt.model));
301
+ if (!attempts.length) {
302
+ const supportedProviders = Object.entries(input.supportedModels)
303
+ .filter(([, models]) => models?.size)
304
+ .map(([provider]) => provider)
305
+ .join(", ");
306
+ const configuredProviders = [...keyCounts.entries()]
307
+ .filter(([, count]) => count > 0)
308
+ .map(([provider]) => provider)
309
+ .join(", ") || "none";
310
+ throw new ProviderKeyUnavailableError(`No active ${input.capability} provider key is configured for this customer. ` +
311
+ `Configured providers: ${configuredProviders}. Supported providers for this capability: ${supportedProviders || "none"}.`);
312
+ }
313
+ return attempts;
314
+ }
315
+ function normalizeOpenAIImageSize(size, aspectRatio) {
316
+ if (size && OPENAI_IMAGE_SIZES.has(size)) {
317
+ return size;
318
+ }
319
+ if (aspectRatio === "16:9") {
320
+ return "1536x1024";
321
+ }
322
+ if (aspectRatio === "9:16") {
323
+ return "1024x1536";
324
+ }
325
+ if (size && /^(\d+)x(\d+)$/i.test(size)) {
326
+ const [, widthRaw, heightRaw] = /^(\d+)x(\d+)$/i.exec(size);
327
+ const width = Number(widthRaw);
328
+ const height = Number(heightRaw);
329
+ if (width > height) {
330
+ return "1536x1024";
331
+ }
332
+ if (height > width) {
333
+ return "1024x1536";
334
+ }
335
+ }
336
+ return "auto";
337
+ }
338
+ function normalizeGeminiAspectRatio(aspectRatio) {
339
+ return aspectRatio && GEMINI_IMAGE_ASPECT_RATIOS.has(aspectRatio) ? aspectRatio : undefined;
340
+ }
341
+ const CHAT_PROVIDER_LEASE_PRIORITY = 0;
342
+ const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = config.PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS * 1000;
343
+ const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 15_000;
344
+ const DEFAULT_PROVIDER_LEASE_SECONDS = 90;
345
+ const CHAT_PROVIDER_LEASE_SECONDS = 120;
346
+ const CHAT_PROVIDER_LEASE_CONCURRENCY = 2;
347
+ const PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS = 5_000;
348
+ const PROVIDER_QUOTA_COOLDOWN_SECONDS = 60 * 60;
349
+ function providerLeaseRetryDeadlineMs() {
350
+ return Date.now() + Math.max(0, config.PROVIDER_LEASE_INLINE_WAIT_SECONDS) * 1000;
351
+ }
352
+ export class ProviderService {
353
+ serverlessProviderKeys = new ServerlessProviderKeyService();
354
+ async handleLeasedProviderError(input) {
355
+ if (input.error instanceof ProviderRateLimitError) {
356
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
357
+ id: createId("usage"),
358
+ keyId: input.lease.keyId,
359
+ jobId: input.jobId,
360
+ provider: input.provider,
361
+ model: input.model,
362
+ eventType: "rate_limit",
363
+ metadata: {
364
+ ...(input.metadata ?? {}),
365
+ retryAfterSeconds: input.error.retryAfterSeconds
366
+ }
367
+ });
368
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), input.error.retryAfterSeconds));
369
+ }
370
+ else if (input.error instanceof ProviderQuotaExceededError) {
371
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
372
+ id: createId("usage"),
373
+ keyId: input.lease.keyId,
374
+ jobId: input.jobId,
375
+ provider: input.provider,
376
+ model: input.model,
377
+ eventType: "quota_exceeded",
378
+ metadata: input.metadata ?? {}
379
+ });
380
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), PROVIDER_QUOTA_COOLDOWN_SECONDS));
381
+ }
382
+ else if (input.error instanceof ProviderAuthError) {
383
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
384
+ id: createId("usage"),
385
+ keyId: input.lease.keyId,
386
+ jobId: input.jobId,
387
+ provider: input.provider,
388
+ model: input.model,
389
+ eventType: "auth_error",
390
+ metadata: input.metadata ?? {}
391
+ });
392
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
393
+ }
394
+ throw input.error;
395
+ }
396
+ listAvailableProviders(_customerId) {
397
+ return PROVIDER_FALLBACK_PRIORITY;
398
+ }
399
+ async listAvailableProvidersAsync(customerId) {
400
+ const now = Date.now();
401
+ const providers = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
402
+ .map((entry) => ({
403
+ provider: String(entry.provider),
404
+ status: String(entry.status),
405
+ cooldownUntil: entry.cooldown_until ? String(entry.cooldown_until) : null,
406
+ secret: readStoredProviderSecret(String(entry.secret))
407
+ }))
408
+ .filter((entry) => entry.status === "active")
409
+ .filter((entry) => !entry.cooldownUntil || Date.parse(entry.cooldownUntil) <= now)
410
+ .filter((entry) => entry.secret.trim().length > 0)
411
+ .map((entry) => entry.provider);
412
+ return [...new Set(providers)];
413
+ }
414
+ async validateProviderKey(input) {
415
+ const model = PROVIDER_KEY_VALIDATION_MODELS[input.provider];
416
+ if (!model) {
417
+ throw new Error(`Provider ${input.provider} does not support validation.`);
418
+ }
419
+ if (config.mockProviders) {
420
+ return { provider: input.provider, model, mock: true };
421
+ }
422
+ try {
423
+ await this.callProvider({
424
+ provider: input.provider,
425
+ model,
426
+ prompt: "Reply with OK.",
427
+ temperature: 0,
428
+ apiKey: input.secret
429
+ });
430
+ return { provider: input.provider, model, mock: false };
431
+ }
432
+ catch (error) {
433
+ throw new Error(toProviderValidationMessage(input.provider, error));
434
+ }
435
+ }
436
+ async leaseCustomerKey(input) {
437
+ const leaseToken = createId("lease");
438
+ const leaseDurationSeconds = input.leaseDurationSeconds ?? DEFAULT_PROVIDER_LEASE_SECONDS;
439
+ const row = await this.serverlessProviderKeys.acquireProviderKeyLease({
440
+ customerId: input.customerId,
441
+ provider: input.provider,
442
+ leaseToken,
443
+ workerId: input.workerId,
444
+ jobId: input.jobId,
445
+ expiresAt: addSeconds(new Date(), leaseDurationSeconds)
446
+ });
447
+ if (!row) {
448
+ const message = `No eligible ${input.provider} key is immediately available.`;
449
+ if ((input.timeoutFailure ?? "retry") === "retry") {
450
+ throw new ProviderLeaseTimeoutError(message);
451
+ }
452
+ throw new ProviderWaitExceededError(message);
453
+ }
454
+ return {
455
+ keyId: row.keyId,
456
+ leaseToken,
457
+ provider: input.provider,
458
+ secret: readStoredProviderSecret(row.encryptedSecret),
459
+ jobId: input.jobId,
460
+ leaseDurationSeconds
461
+ };
462
+ }
463
+ providerLeaseTimeoutMessage(waitTimeoutMs, provider) {
464
+ if (waitTimeoutMs === null) {
465
+ return `No eligible ${provider} key became available.`;
466
+ }
467
+ return `No eligible ${provider} key became available within ${formatDurationSeconds(Math.max(1, Math.floor((waitTimeoutMs ?? DEFAULT_PROVIDER_LEASE_TIMEOUT_MS) / 1000)))}.`;
468
+ }
469
+ throwProviderLeaseTimeout(input) {
470
+ const message = this.providerLeaseTimeoutMessage(input.waitTimeoutMs, input.provider);
471
+ if ((input.timeoutFailure ?? "retry") === "retry") {
472
+ throw new ProviderLeaseTimeoutError(message);
473
+ }
474
+ throw new ProviderWaitExceededError(message);
475
+ }
476
+ async withLeaseRenewal(lease, run) {
477
+ const renewEveryMs = Math.max(PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS, Math.floor((lease.leaseDurationSeconds * 1000) / 2));
478
+ const timer = setInterval(() => {
479
+ const expiresAt = addSeconds(new Date(), lease.leaseDurationSeconds);
480
+ void this.serverlessProviderKeys.extendProviderKeyLease({
481
+ keyId: lease.keyId,
482
+ leaseToken: lease.leaseToken,
483
+ expiresAt
484
+ });
485
+ }, renewEveryMs);
486
+ timer.unref();
487
+ try {
488
+ return await run();
489
+ }
490
+ finally {
491
+ clearInterval(timer);
492
+ }
493
+ }
494
+ async releaseLease(lease) {
495
+ await this.serverlessProviderKeys.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
496
+ }
497
+ async leaseEditorChatKey(input) {
498
+ const now = new Date().toISOString();
499
+ const key = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(input.customerId))
500
+ .filter((entry) => entry.provider === input.provider)
501
+ .filter((entry) => entry.status === "active")
502
+ .filter((entry) => !entry.cooldown_until || entry.cooldown_until <= now)
503
+ .filter((entry) => readStoredProviderSecret(entry.secret).trim().length > 0)
504
+ .sort((a, b) => {
505
+ const aLast = a.last_used_at ?? "";
506
+ const bLast = b.last_used_at ?? "";
507
+ if (!aLast && bLast)
508
+ return -1;
509
+ if (aLast && !bLast)
510
+ return 1;
511
+ return aLast.localeCompare(bLast) || a.created_at.localeCompare(b.created_at);
512
+ })[0];
513
+ if (!key) {
514
+ throw new ProviderLeaseTimeoutError(`No eligible ${input.provider} key is currently available for editor chat.`);
515
+ }
516
+ return {
517
+ keyId: key.id,
518
+ leaseToken: createId("chatlease"),
519
+ provider: input.provider,
520
+ secret: readStoredProviderSecret(key.secret),
521
+ jobId: input.chatId,
522
+ leaseDurationSeconds: CHAT_PROVIDER_LEASE_SECONDS
523
+ };
524
+ }
525
+ async generateText(input) {
526
+ const attempts = await providerAttempts({
527
+ providerKeyService: this.serverlessProviderKeys,
528
+ customerId: input.customerId,
529
+ capability: "text",
530
+ requestedProvider: input.provider,
531
+ requestedModel: input.model,
532
+ supportedModels: SUPPORTED_TEXT_MODELS,
533
+ defaultModelForProvider: (provider) => defaultModelFor("text", provider)
534
+ });
535
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
536
+ for (;;) {
537
+ let lastError = null;
538
+ for (const attempt of attempts) {
539
+ try {
540
+ return await this.generateTextWithProvider({
541
+ ...input,
542
+ provider: attempt.provider,
543
+ model: attempt.model
544
+ });
545
+ }
546
+ catch (error) {
547
+ lastError = error;
548
+ if (!isRecoverableProviderAttemptError(error)) {
549
+ throw error;
550
+ }
551
+ }
552
+ }
553
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
554
+ continue;
555
+ }
556
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI provider key became available in time.");
557
+ }
558
+ }
559
+ async generateTextWithProvider(input) {
560
+ const lease = await this.leaseCustomerKey({
561
+ customerId: input.customerId,
562
+ provider: input.provider,
563
+ jobId: input.jobId,
564
+ workerId: input.workerId
565
+ });
566
+ try {
567
+ if (config.mockProviders) {
568
+ const text = [
569
+ `Hook: ${input.prompt.slice(0, 80)}`,
570
+ "Beat 1: Identify the pain point.",
571
+ "Beat 2: Show the turning point.",
572
+ "Beat 3: Close with a clear CTA."
573
+ ].join("\n");
574
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
575
+ id: createId("usage"),
576
+ keyId: lease.keyId,
577
+ jobId: input.jobId,
578
+ provider: input.provider,
579
+ model: input.model,
580
+ eventType: "success",
581
+ inputTokens: 180,
582
+ outputTokens: 120,
583
+ metadata: { mock: true }
584
+ });
585
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
586
+ return {
587
+ text,
588
+ usage: {
589
+ inputTokens: 180,
590
+ outputTokens: 120,
591
+ costUsd: 0
592
+ }
593
+ };
594
+ }
595
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => this.callProvider({ ...input, apiKey: lease.secret })));
596
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
597
+ id: createId("usage"),
598
+ keyId: lease.keyId,
599
+ jobId: input.jobId,
600
+ provider: input.provider,
601
+ model: input.model,
602
+ eventType: "success",
603
+ inputTokens: result.usage.inputTokens,
604
+ outputTokens: result.usage.outputTokens
605
+ });
606
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
607
+ return result;
608
+ }
609
+ catch (error) {
610
+ return await this.handleLeasedProviderError({
611
+ error,
612
+ lease,
613
+ jobId: input.jobId,
614
+ provider: input.provider,
615
+ model: input.model
616
+ });
617
+ }
618
+ finally {
619
+ await this.releaseLease(lease);
620
+ }
621
+ }
622
+ async generateSpeech(input) {
623
+ const attempts = await providerAttempts({
624
+ providerKeyService: this.serverlessProviderKeys,
625
+ customerId: input.customerId,
626
+ capability: "tts",
627
+ requestedProvider: input.provider,
628
+ requestedModel: input.model,
629
+ supportedModels: SUPPORTED_TTS_MODELS,
630
+ defaultModelForProvider: (provider) => defaultSpeechModelFor("tts", provider)
631
+ });
632
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
633
+ for (;;) {
634
+ let lastError = null;
635
+ for (const attempt of attempts) {
636
+ try {
637
+ return await this.generateSpeechWithProvider({
638
+ ...input,
639
+ provider: attempt.provider,
640
+ model: attempt.model
641
+ });
642
+ }
643
+ catch (error) {
644
+ lastError = error;
645
+ if (!isRecoverableProviderAttemptError(error)) {
646
+ throw error;
647
+ }
648
+ }
649
+ }
650
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
651
+ continue;
652
+ }
653
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech provider key became available in time.");
654
+ }
655
+ }
656
+ async generateSpeechWithProvider(input) {
657
+ assertSupportedSpeechModel("tts", input.provider, input.model);
658
+ const lease = await this.leaseCustomerKey({
659
+ customerId: input.customerId,
660
+ provider: input.provider,
661
+ jobId: input.jobId,
662
+ workerId: input.workerId
663
+ });
664
+ try {
665
+ if (config.mockProviders) {
666
+ const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
667
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
668
+ id: createId("usage"),
669
+ keyId: lease.keyId,
670
+ jobId: input.jobId,
671
+ provider: input.provider,
672
+ model: input.model,
673
+ eventType: "success",
674
+ inputTokens: 0,
675
+ outputTokens: 0,
676
+ metadata: { mock: true, type: "speech_generation" }
677
+ });
678
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
679
+ return {
680
+ bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
681
+ contentType,
682
+ usage: {
683
+ inputTokens: 0,
684
+ outputTokens: 0,
685
+ costUsd: 0
686
+ }
687
+ };
688
+ }
689
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
690
+ ? this.callGeminiSpeechGeneration({
691
+ model: input.model,
692
+ text: input.text,
693
+ voice: input.voice,
694
+ instructions: input.instructions,
695
+ apiKey: lease.secret
696
+ })
697
+ : this.callOpenAICompatibleSpeechGeneration({
698
+ provider: input.provider === "openrouter" ? "openrouter" : "openai",
699
+ model: input.model,
700
+ text: input.text,
701
+ voice: input.voice,
702
+ instructions: input.instructions,
703
+ responseFormat: input.responseFormat,
704
+ apiKey: lease.secret
705
+ })));
706
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
707
+ id: createId("usage"),
708
+ keyId: lease.keyId,
709
+ jobId: input.jobId,
710
+ provider: input.provider,
711
+ model: input.model,
712
+ eventType: "success",
713
+ inputTokens: result.usage.inputTokens,
714
+ outputTokens: result.usage.outputTokens,
715
+ metadata: { type: "speech_generation" }
716
+ });
717
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
718
+ return result;
719
+ }
720
+ catch (error) {
721
+ return await this.handleLeasedProviderError({
722
+ error,
723
+ lease,
724
+ jobId: input.jobId,
725
+ provider: input.provider,
726
+ model: input.model,
727
+ metadata: { type: "speech_generation" }
728
+ });
729
+ }
730
+ finally {
731
+ await this.releaseLease(lease);
732
+ }
733
+ }
734
+ async generateImage(input) {
735
+ const attempts = await providerAttempts({
736
+ providerKeyService: this.serverlessProviderKeys,
737
+ customerId: input.customerId,
738
+ capability: "image",
739
+ requestedProvider: input.provider,
740
+ requestedModel: input.model,
741
+ supportedModels: SUPPORTED_IMAGE_MODELS,
742
+ defaultModelForProvider: (provider) => defaultModelFor("image", provider),
743
+ allowProvider: (provider) => provider !== "perplexity"
744
+ });
745
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
746
+ for (;;) {
747
+ let lastError = null;
748
+ for (const attempt of attempts) {
749
+ try {
750
+ return await this.generateImageWithProvider({
751
+ ...input,
752
+ provider: attempt.provider,
753
+ model: attempt.model
754
+ });
755
+ }
756
+ catch (error) {
757
+ lastError = error;
758
+ if (!isRecoverableProviderAttemptError(error)) {
759
+ throw error;
760
+ }
761
+ }
762
+ }
763
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
764
+ continue;
765
+ }
766
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image provider key became available in time.");
767
+ }
768
+ }
769
+ async generateImageWithProvider(input) {
770
+ const lease = await this.leaseCustomerKey({
771
+ customerId: input.customerId,
772
+ provider: input.provider,
773
+ jobId: input.jobId,
774
+ workerId: input.workerId
775
+ });
776
+ try {
777
+ if (config.mockProviders) {
778
+ const svg = buildMockSlideSvg(input.prompt);
779
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
780
+ id: createId("usage"),
781
+ keyId: lease.keyId,
782
+ jobId: input.jobId,
783
+ provider: input.provider,
784
+ model: input.model,
785
+ eventType: "success",
786
+ inputTokens: 0,
787
+ outputTokens: 0,
788
+ metadata: { mock: true, type: "image_generation" }
789
+ });
790
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
791
+ return {
792
+ bytes: Buffer.from(svg, "utf8"),
793
+ contentType: "image/svg+xml",
794
+ revisedPrompt: input.prompt
795
+ };
796
+ }
797
+ const result = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
798
+ ? this.callGeminiImageGeneration({
799
+ model: input.model,
800
+ prompt: input.prompt,
801
+ promptAttachments: input.promptAttachments,
802
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
803
+ imageSize: input.imageSize,
804
+ apiKey: lease.secret
805
+ })
806
+ : input.provider === "openrouter"
807
+ ? this.callOpenRouterImageGeneration({
808
+ model: input.model,
809
+ prompt: input.prompt,
810
+ promptAttachments: input.promptAttachments,
811
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
812
+ imageSize: input.imageSize,
813
+ apiKey: lease.secret
814
+ })
815
+ : this.callOpenAIImageGeneration({
816
+ model: input.model,
817
+ prompt: input.prompt,
818
+ promptAttachments: input.promptAttachments,
819
+ size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
820
+ aspectRatio: input.aspectRatio,
821
+ apiKey: lease.secret
822
+ })));
823
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
824
+ id: createId("usage"),
825
+ keyId: lease.keyId,
826
+ jobId: input.jobId,
827
+ provider: input.provider,
828
+ model: input.model,
829
+ eventType: "success",
830
+ inputTokens: 0,
831
+ outputTokens: 0,
832
+ metadata: { type: "image_generation" }
833
+ });
834
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
835
+ return result;
836
+ }
837
+ catch (error) {
838
+ return await this.handleLeasedProviderError({
839
+ error,
840
+ lease,
841
+ jobId: input.jobId,
842
+ provider: input.provider,
843
+ model: input.model,
844
+ metadata: { type: "image_generation" }
845
+ });
846
+ }
847
+ finally {
848
+ await this.releaseLease(lease);
849
+ }
850
+ }
851
+ async editImage(input) {
852
+ const attempts = await providerAttempts({
853
+ providerKeyService: this.serverlessProviderKeys,
854
+ customerId: input.customerId,
855
+ capability: "image",
856
+ requestedProvider: input.provider,
857
+ requestedModel: input.model,
858
+ supportedModels: SUPPORTED_IMAGE_MODELS,
859
+ defaultModelForProvider: (provider) => defaultModelFor("image", provider),
860
+ allowProvider: (provider) => provider !== "perplexity"
861
+ });
862
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
863
+ for (;;) {
864
+ let lastError = null;
865
+ for (const attempt of attempts) {
866
+ try {
867
+ return await this.editImageWithProvider({
868
+ ...input,
869
+ provider: attempt.provider,
870
+ model: attempt.model
871
+ });
872
+ }
873
+ catch (error) {
874
+ lastError = error;
875
+ if (!isRecoverableProviderAttemptError(error)) {
876
+ throw error;
877
+ }
878
+ }
879
+ }
880
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
881
+ continue;
882
+ }
883
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image edit provider key became available in time.");
884
+ }
885
+ }
886
+ async editImageWithProvider(input) {
887
+ assertSupportedModel("image", input.provider, input.model, SUPPORTED_IMAGE_MODELS);
888
+ const lease = await this.leaseCustomerKey({
889
+ customerId: input.customerId,
890
+ provider: input.provider,
891
+ jobId: input.jobId,
892
+ workerId: input.workerId
893
+ });
894
+ try {
895
+ if (config.mockProviders) {
896
+ const svg = buildMockSlideSvg(`EDIT ${input.instruction}\n${input.sourceImageUrl}`);
897
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
898
+ id: createId("usage"),
899
+ keyId: lease.keyId,
900
+ jobId: input.jobId,
901
+ provider: input.provider,
902
+ model: input.model,
903
+ eventType: "success",
904
+ inputTokens: 0,
905
+ outputTokens: 0,
906
+ metadata: { mock: true, type: "image_edit" }
907
+ });
908
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
909
+ return {
910
+ bytes: Buffer.from(svg, "utf8"),
911
+ contentType: "image/svg+xml",
912
+ revisedPrompt: input.instruction
913
+ };
914
+ }
915
+ const result = await this.withLeaseRenewal(lease, () => (input.provider === "openai"
916
+ ? this.callOpenAIImageEdit({
917
+ model: input.model,
918
+ sourceImageUrl: input.sourceImageUrl,
919
+ instruction: input.instruction,
920
+ referenceAttachments: input.referenceAttachments,
921
+ maskUrl: input.maskUrl,
922
+ size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
923
+ apiKey: lease.secret
924
+ })
925
+ : input.provider === "perplexity"
926
+ ? (() => {
927
+ throw new Error("Perplexity image edit is not supported.");
928
+ })()
929
+ : this.callAttachmentDrivenImageEdit({
930
+ provider: input.provider,
931
+ model: input.model,
932
+ sourceImageUrl: input.sourceImageUrl,
933
+ instruction: input.instruction,
934
+ referenceAttachments: input.referenceAttachments,
935
+ maskUrl: input.maskUrl,
936
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
937
+ imageSize: input.imageSize,
938
+ apiKey: lease.secret
939
+ })));
940
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
941
+ id: createId("usage"),
942
+ keyId: lease.keyId,
943
+ jobId: input.jobId,
944
+ provider: input.provider,
945
+ model: input.model,
946
+ eventType: "success",
947
+ inputTokens: 0,
948
+ outputTokens: 0,
949
+ metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
950
+ });
951
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
952
+ return result;
953
+ }
954
+ catch (error) {
955
+ return await this.handleLeasedProviderError({
956
+ error,
957
+ lease,
958
+ jobId: input.jobId,
959
+ provider: input.provider,
960
+ model: input.model,
961
+ metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
962
+ });
963
+ }
964
+ finally {
965
+ await this.releaseLease(lease);
966
+ }
967
+ }
968
+ async inpaintImage(input) {
969
+ return this.editImage({
970
+ ...input,
971
+ maskUrl: input.maskUrl
972
+ });
973
+ }
974
+ async generateVideo(input) {
975
+ const attempts = await providerAttempts({
976
+ providerKeyService: this.serverlessProviderKeys,
977
+ customerId: input.customerId,
978
+ capability: "video",
979
+ requestedProvider: input.provider,
980
+ requestedModel: input.model,
981
+ supportedModels: SUPPORTED_VIDEO_MODELS,
982
+ defaultModelForProvider: (provider) => defaultModelFor("video", provider),
983
+ allowProvider: (provider) => provider !== "perplexity"
984
+ });
985
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
986
+ for (;;) {
987
+ let lastError = null;
988
+ for (const attempt of attempts) {
989
+ try {
990
+ return await this.generateVideoWithProvider({
991
+ ...input,
992
+ provider: attempt.provider,
993
+ model: attempt.model
994
+ });
995
+ }
996
+ catch (error) {
997
+ lastError = error;
998
+ if (!isRecoverableProviderAttemptError(error)) {
999
+ throw error;
1000
+ }
1001
+ }
1002
+ }
1003
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1004
+ continue;
1005
+ }
1006
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI video provider key became available in time.");
1007
+ }
1008
+ }
1009
+ async generateVideoWithProvider(input) {
1010
+ assertSupportedModel("video", input.provider, input.model, SUPPORTED_VIDEO_MODELS);
1011
+ const lease = await this.leaseCustomerKey({
1012
+ customerId: input.customerId,
1013
+ provider: input.provider,
1014
+ jobId: input.jobId,
1015
+ workerId: input.workerId,
1016
+ leaseDurationSeconds: Math.max(900, DEFAULT_PROVIDER_LEASE_SECONDS)
1017
+ });
1018
+ // Video providers run async render jobs server-side, so the exclusive key lease is only
1019
+ // needed while submitting the job. Release it before polling so other jobs for this
1020
+ // customer can use the key while the render completes. releaseProviderKeyLease is
1021
+ // lease-token-guarded (double-release is a no-op), but the flag avoids redundant writes.
1022
+ let leaseReleased = false;
1023
+ const releaseLeaseOnce = async () => {
1024
+ if (leaseReleased) {
1025
+ return;
1026
+ }
1027
+ await this.releaseLease(lease);
1028
+ leaseReleased = true;
1029
+ };
1030
+ try {
1031
+ if (config.mockProviders) {
1032
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1033
+ id: createId("usage"),
1034
+ keyId: lease.keyId,
1035
+ jobId: input.jobId,
1036
+ provider: input.provider,
1037
+ model: input.model,
1038
+ eventType: "success",
1039
+ inputTokens: 0,
1040
+ outputTokens: 0,
1041
+ metadata: { mock: true, type: "video_generation" }
1042
+ });
1043
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1044
+ return {
1045
+ bytes: Buffer.from(`mock video:${input.prompt.slice(0, 120)}`, "utf8"),
1046
+ contentType: "video/mp4",
1047
+ providerJob: { mock: true, model: input.model },
1048
+ usageCostUsd: 0
1049
+ };
1050
+ }
1051
+ // Submit phase: the exclusive lease (plus renewal heartbeat) is held only until the
1052
+ // provider accepts the render job. withLeaseRenewal clears the heartbeat timer before
1053
+ // this await resolves, so no renewal can re-extend the lease after release below.
1054
+ const submission = await this.withLeaseRenewal(lease, () => {
1055
+ const request = {
1056
+ model: input.model,
1057
+ prompt: input.prompt,
1058
+ duration: input.duration,
1059
+ resolution: input.resolution,
1060
+ aspectRatio: input.aspectRatio,
1061
+ size: input.size,
1062
+ generateAudio: input.generateAudio,
1063
+ frameImages: input.frameImages,
1064
+ inputReferences: input.inputReferences,
1065
+ seed: input.seed,
1066
+ providerOptions: input.providerOptions,
1067
+ apiKey: lease.secret
1068
+ };
1069
+ if (input.provider === "openai") {
1070
+ return this.submitOpenAIVideoGeneration(request);
1071
+ }
1072
+ if (input.provider === "gemini") {
1073
+ return this.submitGeminiVideoGeneration(request);
1074
+ }
1075
+ if (input.provider === "openrouter") {
1076
+ return this.submitOpenRouterVideoGeneration(request);
1077
+ }
1078
+ throw new ProviderKeyUnavailableError(`AI video generation does not support ${input.provider} provider keys.`);
1079
+ });
1080
+ // The render job now runs server-side at the provider; free the key for other jobs.
1081
+ await releaseLeaseOnce();
1082
+ // Poll phase: runs without the lease. Errors still flow through the catch below —
1083
+ // handleLeasedProviderError only writes usage/cooldown rows, which are not
1084
+ // lease-token-conditioned, so rate-limit/quota/auth cooldowns keep working here.
1085
+ const result = await submission.poll();
1086
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1087
+ id: createId("usage"),
1088
+ keyId: lease.keyId,
1089
+ jobId: input.jobId,
1090
+ provider: input.provider,
1091
+ model: input.model,
1092
+ eventType: "success",
1093
+ inputTokens: 0,
1094
+ outputTokens: 0,
1095
+ metadata: { type: "video_generation", usageCostUsd: result.usageCostUsd, providerJobId: result.providerJob.id ?? null }
1096
+ });
1097
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1098
+ return result;
1099
+ }
1100
+ catch (error) {
1101
+ return await this.handleLeasedProviderError({
1102
+ error,
1103
+ lease,
1104
+ jobId: input.jobId,
1105
+ provider: input.provider,
1106
+ model: input.model,
1107
+ metadata: { type: "video_generation" }
1108
+ });
1109
+ }
1110
+ finally {
1111
+ await releaseLeaseOnce();
1112
+ }
1113
+ }
1114
+ async submitOpenAIVideoGeneration(input) {
1115
+ const form = new FormData();
1116
+ form.set("model", input.model);
1117
+ form.set("prompt", input.prompt);
1118
+ const size = normalizeOpenAIVideoSize(input.model, input.size, input.aspectRatio, input.resolution);
1119
+ if (size) {
1120
+ form.set("size", size);
1121
+ }
1122
+ const seconds = openAIVideoSeconds(input.duration);
1123
+ if (seconds) {
1124
+ form.set("seconds", seconds);
1125
+ }
1126
+ const referenceUrls = [
1127
+ ...(input.frameImages ?? []).map((frame) => frame.imageUrl),
1128
+ ...(input.inputReferences ?? [])
1129
+ ].slice(0, 1);
1130
+ for (const [index, url] of referenceUrls.entries()) {
1131
+ const image = await remoteImageToOpenAIVideoBlob(url, size);
1132
+ form.append("input_reference", image, openAIImageFilename(index, image.type));
1133
+ }
1134
+ const response = await fetch("https://api.openai.com/v1/videos", {
1135
+ method: "POST",
1136
+ headers: {
1137
+ Authorization: `Bearer ${input.apiKey}`
1138
+ },
1139
+ body: form
1140
+ });
1141
+ if (response.status === 401 || response.status === 403) {
1142
+ throw new ProviderAuthError("openai authentication failed");
1143
+ }
1144
+ if (response.status === 402 || response.status === 429) {
1145
+ throw await rateLimitOrQuotaError("openai", response);
1146
+ }
1147
+ if (isTransientProviderStatus(response.status)) {
1148
+ throw await transientProviderError("openai", "video generation", response);
1149
+ }
1150
+ if (!response.ok) {
1151
+ const details = await response.text();
1152
+ throw new Error(conciseProviderResponse("openai video generation", response, details));
1153
+ }
1154
+ const submitted = await response.json();
1155
+ const videoId = readStringField(submitted, "id");
1156
+ if (!videoId) {
1157
+ throw new Error("openai video generation returned no video id");
1158
+ }
1159
+ return {
1160
+ poll: () => this.pollOpenAIVideoGeneration({ videoId, submitted, apiKey: input.apiKey })
1161
+ };
1162
+ }
1163
+ async pollOpenAIVideoGeneration(input) {
1164
+ let latestStatus = input.submitted;
1165
+ for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1166
+ if (attempt > 0) {
1167
+ await sleep(VIDEO_POLL_INTERVAL_MS);
1168
+ }
1169
+ const statusResponse = await fetch(`https://api.openai.com/v1/videos/${encodeURIComponent(input.videoId)}`, {
1170
+ headers: {
1171
+ Authorization: `Bearer ${input.apiKey}`
1172
+ }
1173
+ });
1174
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1175
+ throw new ProviderAuthError("openai authentication failed");
1176
+ }
1177
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1178
+ throw await rateLimitOrQuotaError("openai", statusResponse);
1179
+ }
1180
+ if (isTransientProviderStatus(statusResponse.status)) {
1181
+ const transient = await transientProviderError("openai", "video polling", statusResponse);
1182
+ await sleep(transient.retryAfterSeconds * 1000);
1183
+ continue;
1184
+ }
1185
+ if (!statusResponse.ok) {
1186
+ const details = await statusResponse.text();
1187
+ throw new Error(conciseProviderResponse("openai video polling", statusResponse, details));
1188
+ }
1189
+ latestStatus = await statusResponse.json();
1190
+ const status = String(latestStatus.status ?? "").toLowerCase();
1191
+ if (status === "failed" || status === "cancelled" || status === "canceled" || status === "expired") {
1192
+ const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1193
+ throw new Error(`openai video generation ${status}${details}`);
1194
+ }
1195
+ if (status === "completed") {
1196
+ const video = await downloadAuthenticatedVideo({
1197
+ url: `https://api.openai.com/v1/videos/${encodeURIComponent(input.videoId)}/content`,
1198
+ apiKey: input.apiKey,
1199
+ provider: "openai"
1200
+ });
1201
+ return {
1202
+ ...video,
1203
+ providerJob: latestStatus,
1204
+ usageCostUsd: readOpenAIUsageCost(latestStatus)
1205
+ };
1206
+ }
1207
+ }
1208
+ throw new ProviderWaitExceededError("OpenAI video render exceeded the 12-minute in-process budget.");
1209
+ }
1210
+ async submitGeminiVideoGeneration(input) {
1211
+ const instance = {
1212
+ prompt: input.prompt
1213
+ };
1214
+ const firstFrameUrl = input.frameImages?.find((frame) => frame.frameType === "first_frame")?.imageUrl ??
1215
+ input.inputReferences?.[0];
1216
+ if (firstFrameUrl) {
1217
+ const { bytes, mimeType } = await fetchRemoteBytes({ url: firstFrameUrl, fallbackMimeType: "image/png" });
1218
+ instance.image = {
1219
+ bytesBase64Encoded: bytes.toString("base64"),
1220
+ mimeType
1221
+ };
1222
+ }
1223
+ const parameters = {
1224
+ sampleCount: 1
1225
+ };
1226
+ const aspectRatio = geminiVideoAspectRatio(input.aspectRatio);
1227
+ if (aspectRatio) {
1228
+ parameters.aspectRatio = aspectRatio;
1229
+ }
1230
+ const resolution = geminiVideoResolution(input.resolution);
1231
+ if (resolution) {
1232
+ parameters.resolution = resolution;
1233
+ }
1234
+ const durationSeconds = geminiVideoDurationSeconds(input.duration);
1235
+ if (durationSeconds) {
1236
+ parameters.durationSeconds = durationSeconds;
1237
+ }
1238
+ if (input.generateAudio !== undefined) {
1239
+ parameters.generateAudio = input.generateAudio;
1240
+ }
1241
+ if (input.seed !== undefined) {
1242
+ parameters.seed = input.seed;
1243
+ }
1244
+ if (input.providerOptions) {
1245
+ Object.assign(parameters, input.providerOptions);
1246
+ }
1247
+ const baseUrl = "https://generativelanguage.googleapis.com/v1beta";
1248
+ const response = await fetch(`${baseUrl}/models/${encodeURIComponent(input.model)}:predictLongRunning`, {
1249
+ method: "POST",
1250
+ headers: {
1251
+ "Content-Type": "application/json",
1252
+ "x-goog-api-key": input.apiKey
1253
+ },
1254
+ body: JSON.stringify({
1255
+ instances: [instance],
1256
+ parameters
1257
+ })
1258
+ });
1259
+ if (response.status === 401 || response.status === 403) {
1260
+ throw new ProviderAuthError("gemini authentication failed");
1261
+ }
1262
+ if (response.status === 402 || response.status === 429) {
1263
+ throw await rateLimitOrQuotaError("gemini", response);
1264
+ }
1265
+ if (!response.ok) {
1266
+ const details = await response.text();
1267
+ throw new Error(`gemini video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1268
+ }
1269
+ const submitted = await response.json();
1270
+ const operationName = readStringField(submitted, "name");
1271
+ if (!operationName) {
1272
+ throw new Error("gemini video generation returned no operation name");
1273
+ }
1274
+ return {
1275
+ poll: () => this.pollGeminiVideoGeneration({ baseUrl, operationName, submitted, apiKey: input.apiKey })
1276
+ };
1277
+ }
1278
+ async pollGeminiVideoGeneration(input) {
1279
+ let latestStatus = input.submitted;
1280
+ for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1281
+ if (attempt > 0) {
1282
+ await sleep(VIDEO_POLL_INTERVAL_MS);
1283
+ }
1284
+ const statusResponse = await fetch(`${input.baseUrl}/${input.operationName}`, {
1285
+ headers: {
1286
+ "x-goog-api-key": input.apiKey
1287
+ }
1288
+ });
1289
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1290
+ throw new ProviderAuthError("gemini authentication failed");
1291
+ }
1292
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1293
+ throw await rateLimitOrQuotaError("gemini", statusResponse);
1294
+ }
1295
+ if (!statusResponse.ok) {
1296
+ const details = await statusResponse.text();
1297
+ throw new Error(`gemini video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1298
+ }
1299
+ latestStatus = await statusResponse.json();
1300
+ if (latestStatus.error) {
1301
+ throw new Error(`gemini video generation failed: ${JSON.stringify(latestStatus.error).slice(0, 500)}`);
1302
+ }
1303
+ if (latestStatus.done === true) {
1304
+ const videoUrl = findGeminiVideoUri(latestStatus);
1305
+ if (videoUrl) {
1306
+ const video = await downloadGeminiVideo(videoUrl, input.apiKey);
1307
+ return {
1308
+ ...video,
1309
+ providerJob: latestStatus,
1310
+ usageCostUsd: 0
1311
+ };
1312
+ }
1313
+ const inline = findGeminiInlineVideo(latestStatus);
1314
+ if (inline) {
1315
+ return {
1316
+ bytes: inline.bytes,
1317
+ contentType: inline.contentType,
1318
+ providerJob: latestStatus,
1319
+ usageCostUsd: 0
1320
+ };
1321
+ }
1322
+ throw new Error("gemini video generation completed without a video URI or inline video bytes");
1323
+ }
1324
+ }
1325
+ throw new ProviderWaitExceededError("Gemini video render exceeded the 12-minute in-process budget.");
1326
+ }
1327
+ async submitOpenRouterVideoGeneration(input) {
1328
+ const requestBody = {
1329
+ model: input.model,
1330
+ prompt: input.prompt
1331
+ };
1332
+ if (input.aspectRatio) {
1333
+ requestBody.aspect_ratio = input.aspectRatio;
1334
+ }
1335
+ if (input.duration !== undefined) {
1336
+ requestBody.duration = input.duration;
1337
+ }
1338
+ if (input.resolution) {
1339
+ requestBody.resolution = input.resolution;
1340
+ }
1341
+ if (input.size) {
1342
+ requestBody.size = input.size;
1343
+ }
1344
+ if (input.generateAudio !== undefined) {
1345
+ requestBody.generate_audio = input.generateAudio;
1346
+ }
1347
+ if (input.frameImages?.length) {
1348
+ requestBody.frame_images = input.frameImages.map((frame) => ({
1349
+ image_url: frame.imageUrl,
1350
+ frame_type: frame.frameType
1351
+ }));
1352
+ }
1353
+ if (input.inputReferences?.length) {
1354
+ requestBody.input_references = input.inputReferences;
1355
+ }
1356
+ if (input.seed !== undefined) {
1357
+ requestBody.seed = input.seed;
1358
+ }
1359
+ if (input.providerOptions && Object.keys(input.providerOptions).length > 0) {
1360
+ requestBody.provider = input.providerOptions;
1361
+ }
1362
+ const response = await fetch("https://openrouter.ai/api/v1/videos", {
1363
+ method: "POST",
1364
+ headers: {
1365
+ "Content-Type": "application/json",
1366
+ Authorization: `Bearer ${input.apiKey}`
1367
+ },
1368
+ body: JSON.stringify(requestBody)
1369
+ });
1370
+ if (response.status === 401 || response.status === 403) {
1371
+ throw new ProviderAuthError("openrouter authentication failed");
1372
+ }
1373
+ if (response.status === 402 || response.status === 429) {
1374
+ throw await rateLimitOrQuotaError("openrouter", response);
1375
+ }
1376
+ if (!response.ok) {
1377
+ const details = await response.text();
1378
+ throw new Error(`openrouter video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1379
+ }
1380
+ const submitted = await response.json();
1381
+ const immediateVideoUrl = findOpenRouterVideoUrl(submitted);
1382
+ if (immediateVideoUrl) {
1383
+ return {
1384
+ poll: async () => {
1385
+ const video = await downloadOpenRouterVideo(immediateVideoUrl, input.apiKey);
1386
+ return {
1387
+ ...video,
1388
+ providerJob: submitted,
1389
+ usageCostUsd: readOpenRouterUsageCost(submitted)
1390
+ };
1391
+ }
1392
+ };
1393
+ }
1394
+ const statusUrl = normalizeOpenRouterUrl(readStringField(submitted, "polling_url") ?? readStringField(submitted, "status_url")) ??
1395
+ openRouterVideoStatusUrl(readStringField(submitted, "id"));
1396
+ if (!statusUrl) {
1397
+ throw new Error("openrouter video generation returned no polling URL or job id");
1398
+ }
1399
+ return {
1400
+ poll: () => this.pollOpenRouterVideoGeneration({ statusUrl, submitted, apiKey: input.apiKey })
1401
+ };
1402
+ }
1403
+ async pollOpenRouterVideoGeneration(input) {
1404
+ let latestStatus = input.submitted;
1405
+ for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1406
+ if (attempt > 0) {
1407
+ await sleep(VIDEO_POLL_INTERVAL_MS);
1408
+ }
1409
+ const statusResponse = await fetch(input.statusUrl, {
1410
+ headers: {
1411
+ Authorization: `Bearer ${input.apiKey}`
1412
+ }
1413
+ });
1414
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1415
+ throw new ProviderAuthError("openrouter authentication failed");
1416
+ }
1417
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1418
+ throw await rateLimitOrQuotaError("openrouter", statusResponse);
1419
+ }
1420
+ if (!statusResponse.ok) {
1421
+ const details = await statusResponse.text();
1422
+ throw new Error(`openrouter video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1423
+ }
1424
+ latestStatus = await statusResponse.json();
1425
+ const status = String(latestStatus.status ?? "").toLowerCase();
1426
+ if (status === "failed" || status === "cancelled" || status === "canceled") {
1427
+ const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1428
+ throw new Error(`openrouter video generation ${status}${details}`);
1429
+ }
1430
+ const videoUrl = findOpenRouterVideoUrl(latestStatus);
1431
+ if (status === "completed" && videoUrl) {
1432
+ const video = await downloadOpenRouterVideo(videoUrl, input.apiKey);
1433
+ return {
1434
+ ...video,
1435
+ providerJob: latestStatus,
1436
+ usageCostUsd: readOpenRouterUsageCost(latestStatus)
1437
+ };
1438
+ }
1439
+ }
1440
+ throw new ProviderWaitExceededError("OpenRouter video render exceeded the 12-minute in-process budget.");
1441
+ }
1442
+ async analyzeImageLayout(input) {
1443
+ const attempts = await providerAttempts({
1444
+ providerKeyService: this.serverlessProviderKeys,
1445
+ customerId: input.customerId,
1446
+ capability: "layout_analysis",
1447
+ requestedProvider: input.provider,
1448
+ requestedModel: input.model,
1449
+ supportedModels: SUPPORTED_TEXT_MODELS,
1450
+ defaultModelForProvider: (provider) => defaultModelFor("text", provider),
1451
+ allowProvider: (provider) => provider !== "perplexity"
1452
+ });
1453
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1454
+ for (;;) {
1455
+ let lastError = null;
1456
+ for (const attempt of attempts) {
1457
+ try {
1458
+ return await this.analyzeImageLayoutWithProvider({
1459
+ ...input,
1460
+ provider: attempt.provider,
1461
+ model: attempt.model
1462
+ });
1463
+ }
1464
+ catch (error) {
1465
+ lastError = error;
1466
+ if (!isRecoverableProviderAttemptError(error)) {
1467
+ throw error;
1468
+ }
1469
+ }
1470
+ }
1471
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1472
+ continue;
1473
+ }
1474
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI layout-analysis provider key became available in time.");
1475
+ }
1476
+ }
1477
+ async analyzeImageLayoutWithProvider(input) {
1478
+ assertSupportedModel("layout_analysis", input.provider, input.model, SUPPORTED_TEXT_MODELS);
1479
+ const lease = await this.leaseCustomerKey({
1480
+ customerId: input.customerId,
1481
+ provider: input.provider,
1482
+ jobId: input.jobId,
1483
+ workerId: input.workerId
1484
+ });
1485
+ try {
1486
+ if (config.mockProviders) {
1487
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1488
+ id: createId("usage"),
1489
+ keyId: lease.keyId,
1490
+ jobId: input.jobId,
1491
+ provider: input.provider,
1492
+ model: input.model,
1493
+ eventType: "success",
1494
+ inputTokens: 0,
1495
+ outputTokens: 0,
1496
+ metadata: { mock: true, type: "layout_analysis" }
1497
+ });
1498
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1499
+ return {
1500
+ zone: "bottom",
1501
+ align: "center",
1502
+ maxWidthPercent: 82,
1503
+ justification: "Mock mode defaults to bottom-center for readable slideshow captions."
1504
+ };
1505
+ }
1506
+ const raw = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
1507
+ ? this.callGeminiLayoutAnalysis({
1508
+ model: input.model,
1509
+ imageUrl: input.imageUrl,
1510
+ overlayText: input.overlayText,
1511
+ apiKey: lease.secret
1512
+ })
1513
+ : this.callOpenAICompatibleLayoutAnalysis({
1514
+ provider: input.provider === "openrouter" ? "openrouter" : "openai",
1515
+ model: input.model,
1516
+ imageUrl: input.imageUrl,
1517
+ overlayText: input.overlayText,
1518
+ apiKey: lease.secret
1519
+ })));
1520
+ const parsed = parseJson(raw, null);
1521
+ if (!parsed?.zone || !parsed?.align) {
1522
+ throw new Error(`${input.provider} layout analysis returned invalid JSON`);
1523
+ }
1524
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1525
+ id: createId("usage"),
1526
+ keyId: lease.keyId,
1527
+ jobId: input.jobId,
1528
+ provider: input.provider,
1529
+ model: input.model,
1530
+ eventType: "success",
1531
+ inputTokens: 0,
1532
+ outputTokens: 0,
1533
+ metadata: { type: "layout_analysis" }
1534
+ });
1535
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1536
+ return {
1537
+ zone: parsed.zone,
1538
+ align: parsed.align,
1539
+ maxWidthPercent: clamp(Number(parsed.maxWidthPercent ?? 76), 52, 88),
1540
+ justification: parsed.justification ?? "Selected to preserve legibility and subject framing."
1541
+ };
1542
+ }
1543
+ catch (error) {
1544
+ return await this.handleLeasedProviderError({
1545
+ error,
1546
+ lease,
1547
+ jobId: input.jobId,
1548
+ provider: input.provider,
1549
+ model: input.model,
1550
+ metadata: { type: "layout_analysis" }
1551
+ });
1552
+ }
1553
+ finally {
1554
+ await this.releaseLease(lease);
1555
+ }
1556
+ }
1557
+ async transcribeSpeech(input) {
1558
+ const attempts = await providerAttempts({
1559
+ providerKeyService: this.serverlessProviderKeys,
1560
+ customerId: input.customerId,
1561
+ capability: "stt",
1562
+ requestedProvider: input.provider,
1563
+ requestedModel: input.model,
1564
+ supportedModels: SUPPORTED_STT_MODELS,
1565
+ defaultModelForProvider: (provider) => defaultSpeechModelFor("stt", provider)
1566
+ });
1567
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1568
+ for (;;) {
1569
+ let lastError = null;
1570
+ for (const attempt of attempts) {
1571
+ try {
1572
+ return await this.transcribeSpeechWithProvider({
1573
+ ...input,
1574
+ provider: attempt.provider,
1575
+ model: attempt.model
1576
+ });
1577
+ }
1578
+ catch (error) {
1579
+ lastError = error;
1580
+ if (!isRecoverableProviderAttemptError(error)) {
1581
+ throw error;
1582
+ }
1583
+ }
1584
+ }
1585
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1586
+ continue;
1587
+ }
1588
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech-transcription provider key became available in time.");
1589
+ }
1590
+ }
1591
+ async transcribeSpeechWithProvider(input) {
1592
+ assertSupportedSpeechModel("stt", input.provider, input.model);
1593
+ const lease = await this.leaseCustomerKey({
1594
+ customerId: input.customerId,
1595
+ provider: input.provider,
1596
+ jobId: input.jobId,
1597
+ workerId: input.workerId
1598
+ });
1599
+ try {
1600
+ if (config.mockProviders) {
1601
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1602
+ id: createId("usage"),
1603
+ keyId: lease.keyId,
1604
+ jobId: input.jobId,
1605
+ provider: input.provider,
1606
+ model: input.model,
1607
+ eventType: "success",
1608
+ inputTokens: 0,
1609
+ outputTokens: 0,
1610
+ metadata: { mock: true, type: "speech_transcription" }
1611
+ });
1612
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1613
+ return {
1614
+ text: "Mock transcription output.",
1615
+ usage: {
1616
+ inputTokens: 0,
1617
+ outputTokens: 0,
1618
+ costUsd: 0
1619
+ }
1620
+ };
1621
+ }
1622
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
1623
+ ? this.callGeminiSpeechTranscription({
1624
+ model: input.model,
1625
+ audio: input.audio,
1626
+ contentType: input.contentType,
1627
+ prompt: input.prompt,
1628
+ language: input.language,
1629
+ apiKey: lease.secret
1630
+ })
1631
+ : input.provider === "openrouter"
1632
+ ? this.callOpenRouterSpeechTranscription({
1633
+ model: input.model,
1634
+ audio: input.audio,
1635
+ contentType: input.contentType,
1636
+ prompt: input.prompt,
1637
+ language: input.language,
1638
+ apiKey: lease.secret
1639
+ })
1640
+ : this.callOpenAISpeechTranscription({
1641
+ model: input.model,
1642
+ audio: input.audio,
1643
+ contentType: input.contentType,
1644
+ prompt: input.prompt,
1645
+ language: input.language,
1646
+ apiKey: lease.secret
1647
+ })));
1648
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1649
+ id: createId("usage"),
1650
+ keyId: lease.keyId,
1651
+ jobId: input.jobId,
1652
+ provider: input.provider,
1653
+ model: input.model,
1654
+ eventType: "success",
1655
+ inputTokens: result.usage.inputTokens,
1656
+ outputTokens: result.usage.outputTokens,
1657
+ metadata: { type: "speech_transcription" }
1658
+ });
1659
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1660
+ return result;
1661
+ }
1662
+ catch (error) {
1663
+ return await this.handleLeasedProviderError({
1664
+ error,
1665
+ lease,
1666
+ jobId: input.jobId,
1667
+ provider: input.provider,
1668
+ model: input.model,
1669
+ metadata: { type: "speech_transcription" }
1670
+ });
1671
+ }
1672
+ finally {
1673
+ await this.releaseLease(lease);
1674
+ }
1675
+ }
1676
+ async callOpenAIImageGeneration(input) {
1677
+ const prompt = input.aspectRatio === "9:16"
1678
+ ? [
1679
+ input.prompt,
1680
+ "Return a native 9:16 vertical composition with no letterboxing, no inset landscape window, and no empty top or bottom padding."
1681
+ ].join("\n")
1682
+ : input.prompt;
1683
+ if (input.promptAttachments?.length) {
1684
+ return this.callOpenAIImageEditRequest({
1685
+ model: input.model,
1686
+ prompt,
1687
+ imageUrls: input.promptAttachments,
1688
+ size: input.size ?? "1024x1536",
1689
+ apiKey: input.apiKey
1690
+ });
1691
+ }
1692
+ const response = await fetch("https://api.openai.com/v1/images/generations", {
1693
+ method: "POST",
1694
+ headers: {
1695
+ "Content-Type": "application/json",
1696
+ Authorization: `Bearer ${input.apiKey}`
1697
+ },
1698
+ body: JSON.stringify({
1699
+ model: input.model,
1700
+ prompt,
1701
+ size: input.size ?? "1024x1536"
1702
+ })
1703
+ });
1704
+ if (response.status === 401) {
1705
+ throw new ProviderAuthError("openai authentication failed");
1706
+ }
1707
+ if (response.status === 429) {
1708
+ throw await rateLimitOrQuotaError("openai", response);
1709
+ }
1710
+ if (!response.ok) {
1711
+ const details = await response.text();
1712
+ throw new Error(`openai image generation returned ${response.status}${details ? `: ${details}` : ""}`);
1713
+ }
1714
+ const data = await response.json();
1715
+ const encoded = data.data?.[0]?.b64_json;
1716
+ if (!encoded) {
1717
+ throw new Error("openai image generation returned no image payload");
1718
+ }
1719
+ return {
1720
+ bytes: Buffer.from(encoded, "base64"),
1721
+ contentType: "image/png",
1722
+ revisedPrompt: data.data?.[0]?.revised_prompt ?? null
1723
+ };
1724
+ }
1725
+ async callOpenAIImageEdit(input) {
1726
+ return this.callOpenAIImageEditRequest({
1727
+ model: input.model,
1728
+ prompt: input.instruction,
1729
+ imageUrls: [input.sourceImageUrl, ...(input.referenceAttachments ?? [])],
1730
+ maskUrl: input.maskUrl,
1731
+ size: input.size ?? "auto",
1732
+ apiKey: input.apiKey
1733
+ });
1734
+ }
1735
+ async callOpenAIImageEditRequest(input) {
1736
+ if (input.imageUrls.length > 16) {
1737
+ throw new Error("OpenAI image edits support at most 16 input images.");
1738
+ }
1739
+ const form = new FormData();
1740
+ form.set("model", input.model);
1741
+ form.set("prompt", input.prompt);
1742
+ form.set("size", input.size);
1743
+ const imageFieldName = input.imageUrls.length > 1 ? "image[]" : "image";
1744
+ for (let index = 0; index < input.imageUrls.length; index += 1) {
1745
+ const image = await remoteImageToBlob(input.imageUrls[index]);
1746
+ form.append(imageFieldName, image, openAIImageFilename(index, image.type));
1747
+ }
1748
+ if (input.maskUrl) {
1749
+ form.set("mask", await remoteImageToBlob(input.maskUrl));
1750
+ }
1751
+ const response = await fetch("https://api.openai.com/v1/images/edits", {
1752
+ method: "POST",
1753
+ headers: {
1754
+ Authorization: `Bearer ${input.apiKey}`
1755
+ },
1756
+ body: form
1757
+ });
1758
+ if (response.status === 401) {
1759
+ throw new ProviderAuthError("openai authentication failed");
1760
+ }
1761
+ if (response.status === 429) {
1762
+ throw await rateLimitOrQuotaError("openai", response);
1763
+ }
1764
+ if (!response.ok) {
1765
+ const details = await response.text();
1766
+ throw new Error(`openai image edit returned ${response.status}${details ? `: ${details}` : ""}`);
1767
+ }
1768
+ const data = await response.json();
1769
+ const encoded = data.data?.[0]?.b64_json;
1770
+ if (!encoded) {
1771
+ throw new Error("openai image edit returned no image payload");
1772
+ }
1773
+ return {
1774
+ bytes: Buffer.from(encoded, "base64"),
1775
+ contentType: "image/png",
1776
+ revisedPrompt: data.data?.[0]?.revised_prompt ?? null
1777
+ };
1778
+ }
1779
+ async callAttachmentDrivenImageEdit(input) {
1780
+ const promptAttachments = [
1781
+ input.sourceImageUrl,
1782
+ ...(input.maskUrl ? [input.maskUrl] : []),
1783
+ ...(input.referenceAttachments ?? [])
1784
+ ];
1785
+ const prompt = [
1786
+ input.maskUrl
1787
+ ? "Edit this image using the provided source image and mask."
1788
+ : "Edit this image using the provided source image as the base reference.",
1789
+ input.maskUrl
1790
+ ? "Only change the masked region. Preserve all unmasked regions exactly."
1791
+ : "Preserve the overall composition unless the instruction explicitly changes it.",
1792
+ `Instruction: ${input.instruction}`,
1793
+ "Return one final edited image only."
1794
+ ].join("\n");
1795
+ return input.provider === "gemini"
1796
+ ? this.callGeminiImageGeneration({
1797
+ model: input.model,
1798
+ prompt,
1799
+ promptAttachments,
1800
+ aspectRatio: input.aspectRatio,
1801
+ imageSize: input.imageSize,
1802
+ apiKey: input.apiKey
1803
+ })
1804
+ : this.callOpenRouterImageGeneration({
1805
+ model: input.model,
1806
+ prompt,
1807
+ promptAttachments,
1808
+ aspectRatio: input.aspectRatio,
1809
+ imageSize: input.imageSize,
1810
+ apiKey: input.apiKey
1811
+ });
1812
+ }
1813
+ async callGeminiImageGeneration(input) {
1814
+ const imageConfig = {};
1815
+ if (input.aspectRatio) {
1816
+ imageConfig.aspectRatio = input.aspectRatio;
1817
+ }
1818
+ if (input.imageSize && supportsGeminiImageSize(input.model)) {
1819
+ imageConfig.imageSize = input.imageSize;
1820
+ }
1821
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
1822
+ method: "POST",
1823
+ headers: {
1824
+ "Content-Type": "application/json"
1825
+ },
1826
+ body: JSON.stringify({
1827
+ contents: [{ parts: await buildGeminiPromptParts(input.prompt, input.promptAttachments ?? []) }],
1828
+ generationConfig: {
1829
+ responseModalities: ["Image"],
1830
+ ...(Object.keys(imageConfig).length
1831
+ ? {
1832
+ imageConfig
1833
+ }
1834
+ : {})
1835
+ }
1836
+ })
1837
+ });
1838
+ if (response.status === 401 || response.status === 403) {
1839
+ throw new ProviderAuthError("gemini authentication failed");
1840
+ }
1841
+ if (response.status === 429) {
1842
+ throw await rateLimitOrQuotaError("gemini", response);
1843
+ }
1844
+ if (!response.ok) {
1845
+ const details = await response.text();
1846
+ throw new Error(`gemini image generation returned ${response.status}${details ? `: ${details}` : ""}`);
1847
+ }
1848
+ const data = await response.json();
1849
+ const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data);
1850
+ const encoded = part?.inlineData?.data;
1851
+ if (!encoded) {
1852
+ throw new Error("gemini image generation returned no image payload");
1853
+ }
1854
+ return {
1855
+ bytes: Buffer.from(encoded, "base64"),
1856
+ contentType: part.inlineData?.mimeType ?? "image/png",
1857
+ revisedPrompt: null
1858
+ };
1859
+ }
1860
+ async callOpenRouterImageGeneration(input) {
1861
+ const requestBody = {
1862
+ model: input.model,
1863
+ prompt: input.prompt
1864
+ };
1865
+ if (input.promptAttachments?.length) {
1866
+ requestBody.input_references = input.promptAttachments;
1867
+ }
1868
+ if (input.aspectRatio) {
1869
+ requestBody.aspect_ratio = input.aspectRatio;
1870
+ }
1871
+ if (input.imageSize) {
1872
+ requestBody.image_size = input.imageSize;
1873
+ }
1874
+ const response = await fetch("https://openrouter.ai/api/v1/images", {
1875
+ method: "POST",
1876
+ headers: {
1877
+ "Content-Type": "application/json",
1878
+ Authorization: `Bearer ${input.apiKey}`
1879
+ },
1880
+ body: JSON.stringify(requestBody)
1881
+ });
1882
+ if (response.status === 401 || response.status === 403) {
1883
+ throw new ProviderAuthError("openrouter authentication failed");
1884
+ }
1885
+ if (response.status === 429) {
1886
+ throw await rateLimitOrQuotaError("openrouter", response);
1887
+ }
1888
+ if (!response.ok) {
1889
+ const details = await response.text();
1890
+ throw new Error(`openrouter image generation returned ${response.status}${details ? `: ${details}` : ""}`);
1891
+ }
1892
+ const data = await response.json();
1893
+ const image = await parseOpenRouterImageGenerationPayload(data);
1894
+ if (!image) {
1895
+ const details = conciseOpenRouterImageResponse(data);
1896
+ throw new Error(`openrouter image generation returned no image payload${details ? `: ${details}` : ""}`);
1897
+ }
1898
+ return image;
1899
+ }
1900
+ async callOpenAICompatibleSpeechGeneration(input) {
1901
+ const requestedFormat = input.responseFormat ?? "mp3";
1902
+ const usePcmBridge = input.provider === "openrouter" &&
1903
+ /gemini/i.test(input.model) &&
1904
+ requestedFormat !== "mp3";
1905
+ const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
1906
+ const endpoint = input.provider === "openrouter"
1907
+ ? "https://openrouter.ai/api/v1/audio/speech"
1908
+ : "https://api.openai.com/v1/audio/speech";
1909
+ const response = await fetch(endpoint, {
1910
+ method: "POST",
1911
+ headers: {
1912
+ "Content-Type": "application/json",
1913
+ Authorization: `Bearer ${input.apiKey}`
1914
+ },
1915
+ body: JSON.stringify({
1916
+ model: input.model,
1917
+ input: input.text,
1918
+ voice: input.voice ?? "alloy",
1919
+ instructions: input.instructions,
1920
+ response_format: responseFormat
1921
+ })
1922
+ });
1923
+ if (response.status === 401 || response.status === 403) {
1924
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
1925
+ }
1926
+ if (response.status === 429) {
1927
+ throw await rateLimitOrQuotaError(input.provider, response);
1928
+ }
1929
+ if (!response.ok) {
1930
+ const details = await response.text();
1931
+ throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
1932
+ }
1933
+ const rawBytes = Buffer.from(await response.arrayBuffer());
1934
+ const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
1935
+ const contentType = usePcmBridge
1936
+ ? "audio/wav"
1937
+ : response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
1938
+ return {
1939
+ bytes,
1940
+ contentType,
1941
+ usage: {
1942
+ inputTokens: 0,
1943
+ outputTokens: 0,
1944
+ costUsd: 0
1945
+ }
1946
+ };
1947
+ }
1948
+ async callGeminiSpeechGeneration(input) {
1949
+ const prompt = input.instructions?.trim()
1950
+ ? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
1951
+ : input.text;
1952
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
1953
+ method: "POST",
1954
+ headers: {
1955
+ "Content-Type": "application/json"
1956
+ },
1957
+ body: JSON.stringify({
1958
+ contents: [{ parts: [{ text: prompt }] }],
1959
+ generationConfig: {
1960
+ responseModalities: ["AUDIO"],
1961
+ speechConfig: {
1962
+ voiceConfig: {
1963
+ prebuiltVoiceConfig: {
1964
+ voiceName: input.voice ?? "Kore"
1965
+ }
1966
+ }
1967
+ }
1968
+ }
1969
+ })
1970
+ });
1971
+ if (response.status === 401 || response.status === 403) {
1972
+ throw new ProviderAuthError("gemini authentication failed");
1973
+ }
1974
+ if (response.status === 429) {
1975
+ throw await rateLimitOrQuotaError("gemini", response);
1976
+ }
1977
+ if (!response.ok) {
1978
+ const details = await response.text();
1979
+ throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
1980
+ }
1981
+ const data = await response.json();
1982
+ const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
1983
+ const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
1984
+ if (!encoded) {
1985
+ throw new Error("gemini speech generation returned no audio payload");
1986
+ }
1987
+ const rawBytes = Buffer.from(encoded, "base64");
1988
+ const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
1989
+ const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
1990
+ const looksLikeWav = isWavBuffer(rawBytes);
1991
+ const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
1992
+ const shouldWrapPcm = Boolean(pcmRateMatch ||
1993
+ (!looksLikeWav && !looksLikeMp3));
1994
+ const bytes = shouldWrapPcm
1995
+ ? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
1996
+ : rawBytes;
1997
+ const contentType = shouldWrapPcm
1998
+ ? "audio/wav"
1999
+ : looksLikeWav
2000
+ ? "audio/wav"
2001
+ : "audio/mpeg";
2002
+ return {
2003
+ bytes,
2004
+ contentType,
2005
+ usage: {
2006
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2007
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2008
+ costUsd: 0
2009
+ }
2010
+ };
2011
+ }
2012
+ async callOpenAICompatibleLayoutAnalysis(input) {
2013
+ const endpoint = input.provider === "openrouter"
2014
+ ? "https://openrouter.ai/api/v1/chat/completions"
2015
+ : "https://api.openai.com/v1/chat/completions";
2016
+ const response = await fetch(endpoint, {
2017
+ method: "POST",
2018
+ headers: {
2019
+ "Content-Type": "application/json",
2020
+ Authorization: `Bearer ${input.apiKey}`
2021
+ },
2022
+ body: JSON.stringify({
2023
+ model: input.model,
2024
+ temperature: 0.1,
2025
+ response_format: { type: "json_object" },
2026
+ messages: [
2027
+ {
2028
+ role: "system",
2029
+ content: "Analyze a 9:16 slideshow image and return JSON with zone, align, maxWidthPercent, justification. Prefer centered text placement in the middle third of the frame unless that would cover the subject. Avoid covering the subject, avoid fake caption panels, and keep the caption inside a TikTok-safe region that avoids the top tabs, right-side action rail, and bottom caption/audio UI."
2030
+ },
2031
+ {
2032
+ role: "user",
2033
+ content: [
2034
+ {
2035
+ type: "text",
2036
+ text: `The exact overlay text is: ${input.overlayText}\nChoose zone from top, center, bottom. Choose align from left, center, right. Prefer center when viable. maxWidthPercent must be between 46 and 62.\nTreat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.\nThe visual goal is native TikTok caption placement, not poster typography.`
2037
+ },
2038
+ {
2039
+ type: "image_url",
2040
+ image_url: {
2041
+ url: input.imageUrl
2042
+ }
2043
+ }
2044
+ ]
2045
+ }
2046
+ ]
2047
+ })
2048
+ });
2049
+ if (response.status === 401) {
2050
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
2051
+ }
2052
+ if (response.status === 429) {
2053
+ throw await rateLimitOrQuotaError(input.provider, response);
2054
+ }
2055
+ if (!response.ok) {
2056
+ throw new Error(`${input.provider} layout analysis returned ${response.status}`);
2057
+ }
2058
+ const data = await response.json();
2059
+ return String(data.choices?.[0]?.message?.content ?? "");
2060
+ }
2061
+ async callGeminiLayoutAnalysis(input) {
2062
+ const image = await fetch(input.imageUrl);
2063
+ if (!image.ok) {
2064
+ throw new Error(`could not fetch image for gemini layout analysis: ${image.status}`);
2065
+ }
2066
+ const bytes = Buffer.from(await image.arrayBuffer());
2067
+ const mimeType = image.headers.get("content-type") || "image/png";
2068
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2069
+ method: "POST",
2070
+ headers: {
2071
+ "Content-Type": "application/json"
2072
+ },
2073
+ body: JSON.stringify({
2074
+ contents: [{
2075
+ parts: [
2076
+ {
2077
+ text: [
2078
+ "Analyze this 9:16 slideshow image and return JSON with keys zone, align, maxWidthPercent, justification.",
2079
+ "zone must be one of top, center, bottom.",
2080
+ "align must be one of left, center, right.",
2081
+ "Prefer centered text placement in the middle third unless that would cover the subject.",
2082
+ "maxWidthPercent must be between 46 and 62.",
2083
+ "Prefer negative space and avoid covering the subject.",
2084
+ "Treat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.",
2085
+ "The visual goal is native TikTok caption placement, not poster typography or large title cards.",
2086
+ `Exact overlay text: ${input.overlayText}`
2087
+ ].join("\n")
2088
+ },
2089
+ {
2090
+ inline_data: {
2091
+ mime_type: mimeType,
2092
+ data: bytes.toString("base64")
2093
+ }
2094
+ }
2095
+ ]
2096
+ }],
2097
+ generationConfig: {
2098
+ responseMimeType: "application/json"
2099
+ }
2100
+ })
2101
+ });
2102
+ if (response.status === 401 || response.status === 403) {
2103
+ throw new ProviderAuthError("gemini authentication failed");
2104
+ }
2105
+ if (response.status === 429) {
2106
+ throw await rateLimitOrQuotaError("gemini", response);
2107
+ }
2108
+ if (!response.ok) {
2109
+ throw new Error(`gemini layout analysis returned ${response.status}`);
2110
+ }
2111
+ const data = await response.json();
2112
+ const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
2113
+ return String(text);
2114
+ }
2115
+ async callOpenAISpeechTranscription(input) {
2116
+ const contentType = input.contentType ?? "audio/mpeg";
2117
+ const form = new FormData();
2118
+ form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
2119
+ form.set("model", input.model);
2120
+ form.set("response_format", "json");
2121
+ if (input.prompt) {
2122
+ form.set("prompt", input.prompt);
2123
+ }
2124
+ if (input.language) {
2125
+ form.set("language", input.language);
2126
+ }
2127
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
2128
+ method: "POST",
2129
+ headers: {
2130
+ Authorization: `Bearer ${input.apiKey}`
2131
+ },
2132
+ body: form
2133
+ });
2134
+ if (response.status === 401 || response.status === 403) {
2135
+ throw new ProviderAuthError("openai authentication failed");
2136
+ }
2137
+ if (response.status === 429) {
2138
+ throw await rateLimitOrQuotaError("openai", response);
2139
+ }
2140
+ if (!response.ok) {
2141
+ const details = await response.text();
2142
+ throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2143
+ }
2144
+ const data = await response.json();
2145
+ return {
2146
+ text: String(data.text ?? ""),
2147
+ usage: {
2148
+ inputTokens: Number(data.usage?.input_tokens ?? 0),
2149
+ outputTokens: Number(data.usage?.output_tokens ?? 0),
2150
+ costUsd: Number(data.usage?.cost ?? 0)
2151
+ }
2152
+ };
2153
+ }
2154
+ async callOpenRouterSpeechTranscription(input) {
2155
+ const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
2156
+ method: "POST",
2157
+ headers: {
2158
+ "Content-Type": "application/json",
2159
+ Authorization: `Bearer ${input.apiKey}`
2160
+ },
2161
+ body: JSON.stringify({
2162
+ model: input.model,
2163
+ input_audio: {
2164
+ data: Buffer.from(input.audio).toString("base64"),
2165
+ format: audioFormatFromContentType(input.contentType)
2166
+ },
2167
+ ...(input.prompt ? { prompt: input.prompt } : {}),
2168
+ ...(input.language ? { language: input.language } : {})
2169
+ })
2170
+ });
2171
+ if (response.status === 401 || response.status === 403) {
2172
+ throw new ProviderAuthError("openrouter authentication failed");
2173
+ }
2174
+ if (response.status === 429) {
2175
+ throw await rateLimitOrQuotaError("openrouter", response);
2176
+ }
2177
+ if (!response.ok) {
2178
+ const details = await response.text();
2179
+ throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2180
+ }
2181
+ const data = await response.json();
2182
+ return {
2183
+ text: String(data.text ?? ""),
2184
+ usage: {
2185
+ inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
2186
+ outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
2187
+ costUsd: Number(data.usage?.cost ?? 0)
2188
+ }
2189
+ };
2190
+ }
2191
+ async callGeminiSpeechTranscription(input) {
2192
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2193
+ method: "POST",
2194
+ headers: {
2195
+ "Content-Type": "application/json"
2196
+ },
2197
+ body: JSON.stringify({
2198
+ contents: [{
2199
+ parts: [
2200
+ {
2201
+ inline_data: {
2202
+ mime_type: input.contentType ?? "audio/mpeg",
2203
+ data: Buffer.from(input.audio).toString("base64")
2204
+ }
2205
+ },
2206
+ {
2207
+ text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
2208
+ }
2209
+ ]
2210
+ }],
2211
+ generationConfig: {
2212
+ temperature: 0,
2213
+ responseMimeType: "text/plain"
2214
+ }
2215
+ })
2216
+ });
2217
+ if (response.status === 401 || response.status === 403) {
2218
+ throw new ProviderAuthError("gemini authentication failed");
2219
+ }
2220
+ if (response.status === 429) {
2221
+ throw await rateLimitOrQuotaError("gemini", response);
2222
+ }
2223
+ if (!response.ok) {
2224
+ const details = await response.text();
2225
+ throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2226
+ }
2227
+ const data = await response.json();
2228
+ return {
2229
+ text: extractGeminiText(data),
2230
+ usage: {
2231
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2232
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2233
+ costUsd: 0
2234
+ }
2235
+ };
2236
+ }
2237
+ async callProvider(input) {
2238
+ if (input.provider === "gemini") {
2239
+ return this.callGemini({
2240
+ provider: "gemini",
2241
+ model: input.model,
2242
+ prompt: input.prompt,
2243
+ temperature: input.temperature,
2244
+ responseFormat: input.responseFormat,
2245
+ attachments: input.attachments,
2246
+ apiKey: input.apiKey
2247
+ });
2248
+ }
2249
+ return this.callOpenAICompatible(input);
2250
+ }
2251
+ async callOpenAICompatible(input) {
2252
+ const endpoint = input.provider === "openrouter"
2253
+ ? "https://openrouter.ai/api/v1/chat/completions"
2254
+ : input.provider === "perplexity"
2255
+ ? "https://api.perplexity.ai/chat/completions"
2256
+ : "https://api.openai.com/v1/chat/completions";
2257
+ const response = await fetch(endpoint, {
2258
+ method: "POST",
2259
+ headers: {
2260
+ "Content-Type": "application/json",
2261
+ Authorization: `Bearer ${input.apiKey}`
2262
+ },
2263
+ body: JSON.stringify({
2264
+ model: input.model,
2265
+ temperature: input.temperature ?? 0.7,
2266
+ ...(input.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}),
2267
+ messages: [{
2268
+ role: "user",
2269
+ content: input.attachments && input.attachments.length
2270
+ ? [
2271
+ { type: "text", text: input.prompt },
2272
+ ...input.attachments.map((url) => ({ type: "image_url", image_url: { url } }))
2273
+ ]
2274
+ : input.prompt
2275
+ }]
2276
+ })
2277
+ });
2278
+ if (response.status === 401) {
2279
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
2280
+ }
2281
+ if (response.status === 429) {
2282
+ throw await rateLimitOrQuotaError(input.provider, response);
2283
+ }
2284
+ if (!response.ok) {
2285
+ throw new Error(`${input.provider} returned ${response.status}`);
2286
+ }
2287
+ const data = await response.json();
2288
+ return {
2289
+ text: data.choices?.[0]?.message?.content ?? "",
2290
+ usage: {
2291
+ inputTokens: Number(data.usage?.prompt_tokens ?? 0),
2292
+ outputTokens: Number(data.usage?.completion_tokens ?? 0),
2293
+ costUsd: 0
2294
+ }
2295
+ };
2296
+ }
2297
+ async callGemini(input) {
2298
+ const parts = input.attachments && input.attachments.length
2299
+ ? await buildGeminiPromptParts(input.prompt, input.attachments)
2300
+ : [{ text: input.prompt }];
2301
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2302
+ method: "POST",
2303
+ headers: {
2304
+ "Content-Type": "application/json"
2305
+ },
2306
+ body: JSON.stringify({
2307
+ contents: [{ parts }],
2308
+ generationConfig: {
2309
+ temperature: input.temperature ?? 0.7,
2310
+ ...(input.responseFormat === "json" ? { responseMimeType: "application/json" } : {})
2311
+ }
2312
+ })
2313
+ });
2314
+ if (response.status === 401 || response.status === 403) {
2315
+ throw new ProviderAuthError("Gemini authentication failed");
2316
+ }
2317
+ if (response.status === 429) {
2318
+ throw await rateLimitOrQuotaError("gemini", response);
2319
+ }
2320
+ if (!response.ok) {
2321
+ throw new Error(`Gemini returned ${response.status}`);
2322
+ }
2323
+ const data = await response.json();
2324
+ return {
2325
+ text: data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "",
2326
+ usage: {
2327
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2328
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2329
+ costUsd: 0
2330
+ }
2331
+ };
2332
+ }
2333
+ }
2334
+ function clamp(value, min, max) {
2335
+ return Math.max(min, Math.min(max, value));
2336
+ }
2337
+ function summarizeProviderValidationError(error) {
2338
+ const rawMessage = error instanceof Error ? error.message : "Unknown provider error";
2339
+ return rawMessage
2340
+ .replace(/\s+/g, " ")
2341
+ .trim()
2342
+ .slice(0, 280);
2343
+ }
2344
+ function toProviderValidationMessage(provider, error) {
2345
+ const providerLabel = formatProviderLabel(provider);
2346
+ const summary = summarizeProviderValidationError(error);
2347
+ if (!summary) {
2348
+ return `Unable to verify ${providerLabel} API key.`;
2349
+ }
2350
+ if (/401|403|invalid api key|api key not valid|incorrect api key|unauthorized|permission denied|authentication failed/i.test(summary)) {
2351
+ return `${providerLabel} API key was rejected by the provider. ${summary}`;
2352
+ }
2353
+ return `Unable to verify ${providerLabel} API key. ${summary}`;
2354
+ }
2355
+ function formatProviderLabel(provider) {
2356
+ switch (provider) {
2357
+ case "openai":
2358
+ return "OpenAI";
2359
+ case "gemini":
2360
+ return "Gemini";
2361
+ case "openrouter":
2362
+ return "OpenRouter";
2363
+ case "perplexity":
2364
+ return "Perplexity";
2365
+ }
2366
+ }
2367
+ function readStringField(value, field) {
2368
+ const raw = value[field];
2369
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
2370
+ }
2371
+ function normalizeOpenRouterUrl(value) {
2372
+ if (!value) {
2373
+ return null;
2374
+ }
2375
+ if (/^https?:\/\//i.test(value)) {
2376
+ return value;
2377
+ }
2378
+ if (value.startsWith("/")) {
2379
+ return `https://openrouter.ai${value}`;
2380
+ }
2381
+ return `https://openrouter.ai/${value}`;
2382
+ }
2383
+ function openRouterVideoStatusUrl(jobId) {
2384
+ return jobId ? `https://openrouter.ai/api/v1/videos/${encodeURIComponent(jobId)}` : null;
2385
+ }
2386
+ function findOpenRouterVideoUrl(value) {
2387
+ if (!value || typeof value !== "object") {
2388
+ return null;
2389
+ }
2390
+ if (Array.isArray(value)) {
2391
+ for (const item of value) {
2392
+ const found = typeof item === "string" ? item : findOpenRouterVideoUrl(item);
2393
+ if (found && isOpenRouterVideoContentUrl(found)) {
2394
+ return normalizeOpenRouterUrl(found);
2395
+ }
2396
+ }
2397
+ return null;
2398
+ }
2399
+ const record = value;
2400
+ const unsignedUrls = record.unsigned_urls;
2401
+ if (Array.isArray(unsignedUrls)) {
2402
+ const found = unsignedUrls.find((item) => typeof item === "string" && item.trim().length > 0);
2403
+ if (found) {
2404
+ return normalizeOpenRouterUrl(found);
2405
+ }
2406
+ }
2407
+ for (const key of ["video_url", "output_url", "download_url", "file_url", "content_url"]) {
2408
+ const raw = record[key];
2409
+ if (typeof raw === "string" && raw.trim()) {
2410
+ return normalizeOpenRouterUrl(raw.trim());
2411
+ }
2412
+ }
2413
+ return null;
2414
+ }
2415
+ function isOpenRouterVideoContentUrl(value) {
2416
+ return /^https?:\/\//i.test(value) || value.startsWith("/");
2417
+ }
2418
+ function readOpenRouterUsageCost(value) {
2419
+ const usage = value.usage;
2420
+ if (usage && typeof usage === "object") {
2421
+ const cost = Number(usage.cost ?? usage.total_cost);
2422
+ if (Number.isFinite(cost) && cost > 0) {
2423
+ return cost;
2424
+ }
2425
+ }
2426
+ const cost = Number(value.cost ?? value.total_cost);
2427
+ return Number.isFinite(cost) && cost > 0 ? cost : 0;
2428
+ }
2429
+ function readOpenAIUsageCost(value) {
2430
+ const usage = value.usage;
2431
+ if (usage && typeof usage === "object") {
2432
+ const cost = Number(usage.cost ?? usage.total_cost);
2433
+ if (Number.isFinite(cost) && cost > 0) {
2434
+ return cost;
2435
+ }
2436
+ }
2437
+ return 0;
2438
+ }
2439
+ function normalizeOpenAIVideoSize(model, size, aspectRatio, resolution) {
2440
+ const requested = size ?? openAIVideoSizeForAspect(aspectRatio, resolution);
2441
+ if (!requested) {
2442
+ return undefined;
2443
+ }
2444
+ const parsed = parsePixelSize(requested);
2445
+ const requestedAspect = parsed
2446
+ ? (parsed.width >= parsed.height ? "16:9" : "9:16")
2447
+ : aspectRatio;
2448
+ const isPortrait = requestedAspect !== "16:9";
2449
+ if (model === "sora-2-pro") {
2450
+ if (requested === "1024x1792" || requested === "1792x1024" || requested === "720x1280" || requested === "1280x720") {
2451
+ return requested;
2452
+ }
2453
+ return resolution === "1080p"
2454
+ ? isPortrait ? "1024x1792" : "1792x1024"
2455
+ : isPortrait ? "720x1280" : "1280x720";
2456
+ }
2457
+ if (model === "sora-2") {
2458
+ return isPortrait ? "720x1280" : "1280x720";
2459
+ }
2460
+ return requested;
2461
+ }
2462
+ function openAIVideoSizeForAspect(aspectRatio, resolution) {
2463
+ if (resolution === "1080p") {
2464
+ if (aspectRatio === "9:16")
2465
+ return "1024x1792";
2466
+ if (aspectRatio === "16:9")
2467
+ return "1792x1024";
2468
+ }
2469
+ if (aspectRatio === "9:16") {
2470
+ return "720x1280";
2471
+ }
2472
+ if (aspectRatio === "16:9") {
2473
+ return "1280x720";
2474
+ }
2475
+ return undefined;
2476
+ }
2477
+ function openAIVideoSeconds(duration) {
2478
+ if (!duration) {
2479
+ return undefined;
2480
+ }
2481
+ if (duration >= 10) {
2482
+ return "12";
2483
+ }
2484
+ if (duration >= 6) {
2485
+ return "8";
2486
+ }
2487
+ return "4";
2488
+ }
2489
+ function geminiVideoAspectRatio(aspectRatio) {
2490
+ return aspectRatio === "9:16" ? "9:16" : aspectRatio === "16:9" ? "16:9" : undefined;
2491
+ }
2492
+ function geminiVideoResolution(resolution) {
2493
+ return resolution === "1080p" ? "1080p" : resolution === "720p" ? "720p" : undefined;
2494
+ }
2495
+ function geminiVideoDurationSeconds(duration) {
2496
+ if (!duration) {
2497
+ return undefined;
2498
+ }
2499
+ if (duration >= 7) {
2500
+ return 8;
2501
+ }
2502
+ if (duration >= 5) {
2503
+ return 6;
2504
+ }
2505
+ return 4;
2506
+ }
2507
+ function findGeminiVideoUri(value) {
2508
+ if (!value || typeof value !== "object") {
2509
+ return null;
2510
+ }
2511
+ if (Array.isArray(value)) {
2512
+ for (const item of value) {
2513
+ const found = findGeminiVideoUri(item);
2514
+ if (found) {
2515
+ return found;
2516
+ }
2517
+ }
2518
+ return null;
2519
+ }
2520
+ const record = value;
2521
+ for (const key of ["uri", "videoUri", "fileUri", "downloadUri"]) {
2522
+ const raw = record[key];
2523
+ if (typeof raw === "string" && /^https?:\/\//i.test(raw)) {
2524
+ return raw;
2525
+ }
2526
+ }
2527
+ for (const nested of Object.values(record)) {
2528
+ const found = findGeminiVideoUri(nested);
2529
+ if (found) {
2530
+ return found;
2531
+ }
2532
+ }
2533
+ return null;
2534
+ }
2535
+ function findGeminiInlineVideo(value) {
2536
+ if (!value || typeof value !== "object") {
2537
+ return null;
2538
+ }
2539
+ if (Array.isArray(value)) {
2540
+ for (const item of value) {
2541
+ const found = findGeminiInlineVideo(item);
2542
+ if (found) {
2543
+ return found;
2544
+ }
2545
+ }
2546
+ return null;
2547
+ }
2548
+ const record = value;
2549
+ const encoded = typeof record.videoBytes === "string" ? record.videoBytes :
2550
+ typeof record.bytesBase64Encoded === "string" ? record.bytesBase64Encoded :
2551
+ typeof record.data === "string" ? record.data :
2552
+ null;
2553
+ if (encoded) {
2554
+ return {
2555
+ bytes: Buffer.from(encoded, "base64"),
2556
+ contentType: typeof record.mimeType === "string" ? record.mimeType : "video/mp4"
2557
+ };
2558
+ }
2559
+ for (const nested of Object.values(record)) {
2560
+ const found = findGeminiInlineVideo(nested);
2561
+ if (found) {
2562
+ return found;
2563
+ }
2564
+ }
2565
+ return null;
2566
+ }
2567
+ async function downloadAuthenticatedVideo(input) {
2568
+ const headers = input.provider === "openai"
2569
+ ? { Authorization: `Bearer ${input.apiKey}` }
2570
+ : { "x-goog-api-key": input.apiKey };
2571
+ const response = await fetch(input.url, { headers });
2572
+ if (response.status === 401 || response.status === 403) {
2573
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
2574
+ }
2575
+ if (response.status === 402 || response.status === 429) {
2576
+ throw await rateLimitOrQuotaError(input.provider, response);
2577
+ }
2578
+ if (!response.ok) {
2579
+ const details = await response.text();
2580
+ throw new Error(`${input.provider} video download returned ${response.status}${details ? `: ${details}` : ""}`);
2581
+ }
2582
+ return {
2583
+ bytes: Buffer.from(await response.arrayBuffer()),
2584
+ contentType: response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(input.url)
2585
+ };
2586
+ }
2587
+ async function downloadGeminiVideo(url, apiKey) {
2588
+ return downloadAuthenticatedVideo({
2589
+ url,
2590
+ apiKey,
2591
+ provider: "gemini"
2592
+ });
2593
+ }
2594
+ async function downloadOpenRouterVideo(url, apiKey) {
2595
+ const includeAuth = /^https:\/\/openrouter\.ai\//i.test(url);
2596
+ const response = await fetch(url, includeAuth
2597
+ ? {
2598
+ headers: {
2599
+ Authorization: `Bearer ${apiKey}`
2600
+ }
2601
+ }
2602
+ : {});
2603
+ if (response.status === 401 || response.status === 403) {
2604
+ throw new ProviderAuthError("openrouter authentication failed");
2605
+ }
2606
+ if (response.status === 402 || response.status === 429) {
2607
+ throw await rateLimitOrQuotaError("openrouter", response);
2608
+ }
2609
+ if (!response.ok) {
2610
+ const details = await response.text();
2611
+ throw new Error(`openrouter video download returned ${response.status}${details ? `: ${details}` : ""}`);
2612
+ }
2613
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(url);
2614
+ return {
2615
+ bytes: Buffer.from(await response.arrayBuffer()),
2616
+ contentType
2617
+ };
2618
+ }
2619
+ function inferVideoContentType(url) {
2620
+ if (/\.webm(\?|#|$)/i.test(url)) {
2621
+ return "video/webm";
2622
+ }
2623
+ if (/\.mov(\?|#|$)/i.test(url)) {
2624
+ return "video/quicktime";
2625
+ }
2626
+ return "video/mp4";
2627
+ }
2628
+ function assertSupportedModel(kind, provider, model, supportedModels) {
2629
+ const normalizedModel = normalizeProviderModel(model);
2630
+ if (!normalizedModel) {
2631
+ throw new Error(`Missing ${kind} model for provider ${provider}.`);
2632
+ }
2633
+ const allowedModels = supportedModels[provider];
2634
+ if (!allowedModels?.has(normalizedModel)) {
2635
+ const allowed = allowedModels ? [...allowedModels].join(", ") : "none";
2636
+ throw new Error(`Unsupported ${kind} model "${normalizedModel}" for provider ${provider}. ` +
2637
+ `Templates must use Vidfarm-approved models only. Allowed models: ${allowed}.`);
2638
+ }
2639
+ }
2640
+ function assertSupportedSpeechModel(kind, provider, model) {
2641
+ assertSupportedModel(kind, provider, model, kind === "tts" ? SUPPORTED_TTS_MODELS : SUPPORTED_STT_MODELS);
2642
+ }
2643
+ function normalizeProviderModel(model) {
2644
+ return model.trim().replace(/^models\//, "");
2645
+ }
2646
+ function inferSpeechContentType(format) {
2647
+ return format === "wav" ? "audio/wav" : "audio/mpeg";
2648
+ }
2649
+ function pcm16MonoToWav(pcmBuffer, sampleRate) {
2650
+ const header = Buffer.alloc(44);
2651
+ header.write("RIFF", 0, 4, "ascii");
2652
+ header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
2653
+ header.write("WAVE", 8, 4, "ascii");
2654
+ header.write("fmt ", 12, 4, "ascii");
2655
+ header.writeUInt32LE(16, 16);
2656
+ header.writeUInt16LE(1, 20);
2657
+ header.writeUInt16LE(1, 22);
2658
+ header.writeUInt32LE(sampleRate, 24);
2659
+ header.writeUInt32LE(sampleRate * 2, 28);
2660
+ header.writeUInt16LE(2, 32);
2661
+ header.writeUInt16LE(16, 34);
2662
+ header.write("data", 36, 4, "ascii");
2663
+ header.writeUInt32LE(pcmBuffer.byteLength, 40);
2664
+ return Buffer.concat([header, pcmBuffer]);
2665
+ }
2666
+ function isWavBuffer(buffer) {
2667
+ return buffer.byteLength >= 12
2668
+ && buffer.subarray(0, 4).toString("ascii") === "RIFF"
2669
+ && buffer.subarray(8, 12).toString("ascii") === "WAVE";
2670
+ }
2671
+ function isLikelyMp3Buffer(buffer) {
2672
+ if (buffer.byteLength < 3) {
2673
+ return false;
2674
+ }
2675
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
2676
+ return true;
2677
+ }
2678
+ return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
2679
+ }
2680
+ function audioFormatFromContentType(contentType) {
2681
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
2682
+ switch (normalized) {
2683
+ case "audio/wav":
2684
+ case "audio/x-wav":
2685
+ return "wav";
2686
+ case "audio/mp4":
2687
+ case "audio/m4a":
2688
+ return "mp4";
2689
+ case "audio/webm":
2690
+ return "webm";
2691
+ case "audio/ogg":
2692
+ return "ogg";
2693
+ default:
2694
+ return "mp3";
2695
+ }
2696
+ }
2697
+ function audioExtensionFromContentType(contentType) {
2698
+ switch (audioFormatFromContentType(contentType)) {
2699
+ case "wav":
2700
+ return "wav";
2701
+ case "mp4":
2702
+ return "m4a";
2703
+ case "webm":
2704
+ return "webm";
2705
+ case "ogg":
2706
+ return "ogg";
2707
+ default:
2708
+ return "mp3";
2709
+ }
2710
+ }
2711
+ function buildGeminiTranscriptionPrompt(prompt, language) {
2712
+ const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
2713
+ return language?.trim()
2714
+ ? `${basePrompt}\n\nExpected language: ${language.trim()}.`
2715
+ : basePrompt;
2716
+ }
2717
+ function extractGeminiText(data) {
2718
+ return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
2719
+ }
2720
+ function supportsGeminiImageSize(model) {
2721
+ return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
2722
+ }
2723
+ async function fetchRemoteBytes(input) {
2724
+ const maxAttempts = isVidfarmTemplateMediaUrl(input.url) ? 6 : 4;
2725
+ let lastFailure = null;
2726
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2727
+ let response;
2728
+ try {
2729
+ response = await fetch(input.url);
2730
+ }
2731
+ catch (error) {
2732
+ lastFailure = error instanceof Error ? error.message : String(error);
2733
+ if (attempt < maxAttempts - 1) {
2734
+ await sleep(remoteMediaRetryDelayMs(null, attempt));
2735
+ continue;
2736
+ }
2737
+ throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure}`, 5);
2738
+ }
2739
+ if (response.ok) {
2740
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || input.fallbackMimeType || "application/octet-stream";
2741
+ const bytes = Buffer.from(await response.arrayBuffer());
2742
+ return { bytes, mimeType };
2743
+ }
2744
+ lastFailure = String(response.status);
2745
+ if (!isRetryableRemoteMediaStatus(response.status, input.url)) {
2746
+ throw new Error(`Could not fetch remote media ${input.url}: ${response.status}`);
2747
+ }
2748
+ if (attempt < maxAttempts - 1) {
2749
+ await sleep(remoteMediaRetryDelayMs(response, attempt));
2750
+ continue;
2751
+ }
2752
+ }
2753
+ throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure ?? "unknown error"}`, 5);
2754
+ }
2755
+ function isRetryableRemoteMediaStatus(status, url) {
2756
+ return isTransientRemoteMediaStatus(status)
2757
+ || (isVidfarmTemplateMediaUrl(url) && (status === 403 || status === 404));
2758
+ }
2759
+ function isVidfarmTemplateMediaUrl(value) {
2760
+ try {
2761
+ const url = new URL(value);
2762
+ return url.pathname === "/template-media" && Boolean(url.searchParams.get("key"));
2763
+ }
2764
+ catch {
2765
+ return false;
2766
+ }
2767
+ }
2768
+ async function remoteImageToBlob(url) {
2769
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2770
+ return new Blob([bytes], { type: mimeType });
2771
+ }
2772
+ async function remoteImageToOpenAIVideoBlob(url, size) {
2773
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2774
+ if (!mimeType.toLowerCase().startsWith("image/")) {
2775
+ throw new Error(`OpenAI video input references must be direct image URLs. Received ${mimeType || "unknown content"} from ${url}.`);
2776
+ }
2777
+ const parsedSize = parsePixelSize(size);
2778
+ if (!parsedSize) {
2779
+ return new Blob([bytes], { type: "image/png" });
2780
+ }
2781
+ const normalized = await sharp(bytes)
2782
+ .resize(parsedSize.width, parsedSize.height, {
2783
+ fit: "cover",
2784
+ position: "center"
2785
+ })
2786
+ .png()
2787
+ .toBuffer();
2788
+ return new Blob([new Uint8Array(normalized)], { type: "image/png" });
2789
+ }
2790
+ function parsePixelSize(size) {
2791
+ const match = /^(\d+)x(\d+)$/i.exec(size ?? "");
2792
+ if (!match) {
2793
+ return null;
2794
+ }
2795
+ const width = Number(match[1]);
2796
+ const height = Number(match[2]);
2797
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
2798
+ ? { width, height }
2799
+ : null;
2800
+ }
2801
+ function openAIImageFilename(index, contentType) {
2802
+ const extension = contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "png";
2803
+ return `reference-${index + 1}.${extension}`;
2804
+ }
2805
+ async function buildGeminiPromptParts(prompt, attachments) {
2806
+ const parts = [];
2807
+ for (const attachment of attachments) {
2808
+ if (!/^https?:\/\//i.test(attachment)) {
2809
+ throw new Error(`Gemini prompt attachments must be public URLs: ${attachment}`);
2810
+ }
2811
+ const { bytes, mimeType } = await fetchRemoteBytes({ url: attachment });
2812
+ parts.push({
2813
+ inlineData: {
2814
+ mimeType,
2815
+ data: bytes.toString("base64")
2816
+ }
2817
+ });
2818
+ }
2819
+ parts.push({ text: prompt });
2820
+ return parts;
2821
+ }
2822
+ function decodeInlineDataUrl(value) {
2823
+ const match = value.match(/^data:([^;,]+);base64,(.+)$/);
2824
+ if (!match) {
2825
+ return null;
2826
+ }
2827
+ return {
2828
+ contentType: match[1],
2829
+ bytes: Buffer.from(match[2], "base64")
2830
+ };
2831
+ }
2832
+ async function parseOpenRouterImageGenerationPayload(data) {
2833
+ const imageData = Array.isArray(data.data) ? data.data[0] : undefined;
2834
+ const b64Json = typeof imageData?.b64_json === "string" ? imageData.b64_json : null;
2835
+ if (b64Json) {
2836
+ return {
2837
+ bytes: Buffer.from(b64Json, "base64"),
2838
+ contentType: "image/png",
2839
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readStringField(data, "revised_prompt")
2840
+ };
2841
+ }
2842
+ const url = readOptionalStringField(imageData, "url") ??
2843
+ readOptionalStringField(imageData, "image_url") ??
2844
+ readLegacyOpenRouterChatImageUrl(data);
2845
+ if (!url) {
2846
+ return null;
2847
+ }
2848
+ const inline = decodeInlineDataUrl(url);
2849
+ if (inline) {
2850
+ return {
2851
+ bytes: inline.bytes,
2852
+ contentType: inline.contentType,
2853
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2854
+ };
2855
+ }
2856
+ if (/^https?:\/\//i.test(url)) {
2857
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2858
+ return {
2859
+ bytes,
2860
+ contentType: mimeType,
2861
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2862
+ };
2863
+ }
2864
+ return null;
2865
+ }
2866
+ function readOptionalStringField(value, field) {
2867
+ return value ? readStringField(value, field) : null;
2868
+ }
2869
+ function readLegacyOpenRouterChatImageUrl(data) {
2870
+ const choices = Array.isArray(data.choices) ? data.choices : [];
2871
+ const choice = choices[0];
2872
+ const message = choice?.message && typeof choice.message === "object"
2873
+ ? choice.message
2874
+ : null;
2875
+ const images = Array.isArray(message?.images) ? message.images : [];
2876
+ const firstImage = images[0];
2877
+ const snake = firstImage?.image_url && typeof firstImage.image_url === "object"
2878
+ ? firstImage.image_url
2879
+ : null;
2880
+ const camel = firstImage?.imageUrl && typeof firstImage.imageUrl === "object"
2881
+ ? firstImage.imageUrl
2882
+ : null;
2883
+ return (snake ? readStringField(snake, "url") : null) ?? (camel ? readStringField(camel, "url") : null);
2884
+ }
2885
+ function readLegacyOpenRouterChatContent(data) {
2886
+ const choices = Array.isArray(data.choices) ? data.choices : [];
2887
+ const choice = choices[0];
2888
+ const message = choice?.message && typeof choice.message === "object"
2889
+ ? choice.message
2890
+ : null;
2891
+ return message ? readStringField(message, "content") : null;
2892
+ }
2893
+ function conciseOpenRouterImageResponse(data) {
2894
+ const message = readLegacyOpenRouterChatContent(data);
2895
+ if (message) {
2896
+ return message.replace(/\s+/g, " ").trim().slice(0, 500);
2897
+ }
2898
+ return JSON.stringify(data).replace(/\s+/g, " ").slice(0, 500);
2899
+ }
2900
+ function buildMockSlideSvg(prompt) {
2901
+ const escaped = escapeXml(prompt.slice(0, 220));
2902
+ return `
2903
+ <svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920" viewBox="0 0 1080 1920">
2904
+ <defs>
2905
+ <linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
2906
+ <stop offset="0%" stop-color="#31241c" />
2907
+ <stop offset="52%" stop-color="#866245" />
2908
+ <stop offset="100%" stop-color="#d7c0a4" />
2909
+ </linearGradient>
2910
+ </defs>
2911
+ <rect width="1080" height="1920" fill="url(#bg)" />
2912
+ <rect x="94" y="1180" width="892" height="470" rx="44" fill="rgba(0,0,0,0.18)" stroke="rgba(255,255,255,0.25)" />
2913
+ <text x="108" y="170" fill="#f7f0e7" font-size="54" font-family="Times New Roman, serif">Mock AI image</text>
2914
+ <text x="108" y="240" fill="#f7f0e7" font-size="34" font-family="Times New Roman, serif">${escaped}</text>
2915
+ <text x="108" y="1280" fill="#f7f0e7" font-size="42" font-family="Times New Roman, serif">Reserved text-safe zone</text>
2916
+ </svg>`;
2917
+ }
2918
+ function escapeXml(value) {
2919
+ return value
2920
+ .replace(/&/g, "&amp;")
2921
+ .replace(/</g, "&lt;")
2922
+ .replace(/>/g, "&gt;")
2923
+ .replace(/"/g, "&quot;")
2924
+ .replace(/'/g, "&apos;");
2925
+ }
2926
+ //# sourceMappingURL=providers.js.map