@ai-sdk/black-forest-labs 2.0.20 → 2.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +16 -2
- package/dist/index.d.ts +27 -4
- package/dist/index.js +745 -80
- package/dist/index.js.map +1 -1
- package/docs/12-black-forest-labs.mdx +250 -3
- package/package.json +2 -2
- package/src/black-forest-labs-api.ts +58 -0
- package/src/black-forest-labs-image-model.ts +4 -51
- package/src/black-forest-labs-provider.ts +29 -2
- package/src/black-forest-labs-video-model-options.ts +92 -0
- package/src/black-forest-labs-video-model.ts +778 -0
- package/src/black-forest-labs-video-settings.ts +22 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AISDKError,
|
|
3
|
+
InvalidArgumentError,
|
|
4
|
+
type Experimental_VideoModelV4 as VideoModelV4,
|
|
5
|
+
type Experimental_VideoModelV4CallOptions as VideoModelV4CallOptions,
|
|
6
|
+
type Experimental_VideoModelV4File as VideoModelV4File,
|
|
7
|
+
type Experimental_VideoModelV4OperationStartResult as VideoModelV4OperationStartResult,
|
|
8
|
+
type Experimental_VideoModelV4OperationStatusResult as VideoModelV4OperationStatusResult,
|
|
9
|
+
type Experimental_VideoModelV4Result as VideoModelV4Result,
|
|
10
|
+
type SharedV4Warning,
|
|
11
|
+
} from '@ai-sdk/provider';
|
|
12
|
+
import {
|
|
13
|
+
combineHeaders,
|
|
14
|
+
createJsonResponseHandler,
|
|
15
|
+
delay,
|
|
16
|
+
getFromApi,
|
|
17
|
+
getTopLevelMediaType,
|
|
18
|
+
parseProviderOptions,
|
|
19
|
+
postJsonToApi,
|
|
20
|
+
resolve,
|
|
21
|
+
serializeModelOptions,
|
|
22
|
+
WORKFLOW_DESERIALIZE,
|
|
23
|
+
WORKFLOW_SERIALIZE,
|
|
24
|
+
type FetchFunction,
|
|
25
|
+
type Resolvable,
|
|
26
|
+
} from '@ai-sdk/provider-utils';
|
|
27
|
+
import { z } from 'zod/v4';
|
|
28
|
+
import {
|
|
29
|
+
bflFailedResponseHandler,
|
|
30
|
+
isTrustedUrl,
|
|
31
|
+
} from './black-forest-labs-api';
|
|
32
|
+
import {
|
|
33
|
+
blackForestLabsVideoModelOptionsSchema,
|
|
34
|
+
type BlackForestLabsVideoModelOptions,
|
|
35
|
+
} from './black-forest-labs-video-model-options';
|
|
36
|
+
import {
|
|
37
|
+
blackForestLabsVideoAspectRatios,
|
|
38
|
+
blackForestLabsVideoResolutions,
|
|
39
|
+
type BlackForestLabsVideoModelId,
|
|
40
|
+
} from './black-forest-labs-video-settings';
|
|
41
|
+
|
|
42
|
+
const DEFAULT_POLL_INTERVAL_MILLIS = 2_000;
|
|
43
|
+
const DEFAULT_POLL_TIMEOUT_MILLIS = 600_000;
|
|
44
|
+
const MIN_DURATION_SECONDS = 5;
|
|
45
|
+
const MAX_DURATION_SECONDS = 20;
|
|
46
|
+
/** Keyframe counts above this need an explicit duration when untimed. */
|
|
47
|
+
const UNTIMED_KEYFRAMES_NEEDING_DURATION = 3;
|
|
48
|
+
|
|
49
|
+
const allowedAspectRatios = new Set<string>(blackForestLabsVideoAspectRatios);
|
|
50
|
+
const allowedResolutions = new Set<string>(blackForestLabsVideoResolutions);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Statuses that end the poll without a video.
|
|
54
|
+
*/
|
|
55
|
+
const TERMINAL_FAILURE_STATUSES = new Set([
|
|
56
|
+
'Error',
|
|
57
|
+
'Failed',
|
|
58
|
+
'Request Moderated',
|
|
59
|
+
'Content Moderated',
|
|
60
|
+
'Task not found',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
type BlackForestLabsVideoKeyframe = string | [number, string];
|
|
64
|
+
|
|
65
|
+
interface BlackForestLabsVideoModelConfig {
|
|
66
|
+
provider: string;
|
|
67
|
+
baseURL: string;
|
|
68
|
+
headers?: Resolvable<Record<string, string | undefined>>;
|
|
69
|
+
fetch?: FetchFunction;
|
|
70
|
+
pollIntervalMillis?: number;
|
|
71
|
+
pollTimeoutMillis?: number;
|
|
72
|
+
_internal?: {
|
|
73
|
+
currentDate?: () => Date;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type BlackForestLabsVideoOperation = {
|
|
78
|
+
requestId: string;
|
|
79
|
+
pollingUrl: string;
|
|
80
|
+
cost?: number;
|
|
81
|
+
inputMegapixels?: number;
|
|
82
|
+
outputMegapixels?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type BlackForestLabsVideoDoGenerateOptions = VideoModelV4CallOptions;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The API takes a named tier (`hd`/`fhd`) while the top-level `resolution` is
|
|
89
|
+
* `{width}x{height}`, so a caller may reasonably pass either.
|
|
90
|
+
*/
|
|
91
|
+
function resolveTopLevelResolution(
|
|
92
|
+
resolution: string,
|
|
93
|
+
): { tier: string; derived: boolean } | undefined {
|
|
94
|
+
const named = resolution.toLowerCase();
|
|
95
|
+
if (allowedResolutions.has(named)) {
|
|
96
|
+
return { tier: named, derived: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const match = resolution.match(/^(\d+)x(\d+)$/);
|
|
100
|
+
if (match == null) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const shorterSide = Math.min(Number(match[1]), Number(match[2]));
|
|
105
|
+
return {
|
|
106
|
+
tier: shorterSide <= 720 ? 'hd' : 'fhd',
|
|
107
|
+
derived: shorterSide !== 720 && shorterSide !== 1080,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nonImageFrameMediaType(file: VideoModelV4File): string | undefined {
|
|
112
|
+
if (file.mediaType == null) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const topLevelMediaType = getTopLevelMediaType(file.mediaType);
|
|
117
|
+
return topLevelMediaType === 'image' ? undefined : topLevelMediaType;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Draft-enhance replays an encrypted bundle from a prior `draft` generation at
|
|
122
|
+
* full quality. The bundle pins the original mode, prompt, seed, and
|
|
123
|
+
* conditioning media, and the API accepts nothing alongside it but
|
|
124
|
+
* `safety_tolerance` — so this is a separate body rather than one more field,
|
|
125
|
+
* and everything else the caller set is reported as dropped.
|
|
126
|
+
*/
|
|
127
|
+
function getDraftEnhanceArgs(
|
|
128
|
+
options: BlackForestLabsVideoDoGenerateOptions,
|
|
129
|
+
bflOptions: BlackForestLabsVideoModelOptions,
|
|
130
|
+
) {
|
|
131
|
+
const warnings: Array<SharedV4Warning> = [];
|
|
132
|
+
|
|
133
|
+
const pinnedByBundle: Array<[feature: string, isSet: boolean]> = [
|
|
134
|
+
['prompt', (options.prompt?.trim().length ?? 0) > 0],
|
|
135
|
+
[
|
|
136
|
+
'aspectRatio',
|
|
137
|
+
options.aspectRatio != null || bflOptions.aspectRatio != null,
|
|
138
|
+
],
|
|
139
|
+
['resolution', options.resolution != null || bflOptions.resolution != null],
|
|
140
|
+
['duration', options.duration != null],
|
|
141
|
+
['fps', options.fps != null],
|
|
142
|
+
['seed', options.seed != null],
|
|
143
|
+
['generateAudio', options.generateAudio != null],
|
|
144
|
+
['image', options.image != null],
|
|
145
|
+
['frameImages', (options.frameImages?.length ?? 0) > 0],
|
|
146
|
+
['inputReferences', (options.inputReferences?.length ?? 0) > 0],
|
|
147
|
+
['keyframes', (bflOptions.keyframes?.length ?? 0) > 0],
|
|
148
|
+
['version', bflOptions.version != null],
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
for (const [feature, isSet] of pinnedByBundle) {
|
|
152
|
+
if (isSet) {
|
|
153
|
+
warnings.push({
|
|
154
|
+
type: 'unsupported',
|
|
155
|
+
feature,
|
|
156
|
+
details:
|
|
157
|
+
`FLUX 3 draft enhance replays the draft bundle as it was generated, so "${feature}" ` +
|
|
158
|
+
'was ignored. Set it on the original draft request instead.',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (bflOptions.draft != null) {
|
|
164
|
+
warnings.push({
|
|
165
|
+
type: 'unsupported',
|
|
166
|
+
feature: 'draft',
|
|
167
|
+
details:
|
|
168
|
+
'FLUX 3 draft enhance always renders at full quality. The draft option was ignored.',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (options.n != null && options.n > 1) {
|
|
173
|
+
warnings.push({
|
|
174
|
+
type: 'unsupported',
|
|
175
|
+
feature: 'n',
|
|
176
|
+
details:
|
|
177
|
+
'FLUX 3 video generates a single video per call. Only 1 video will be generated.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
body: {
|
|
183
|
+
mode: 'draft_enhance',
|
|
184
|
+
draft_cache: bflOptions.draftCache,
|
|
185
|
+
safety_tolerance: bflOptions.safetyTolerance,
|
|
186
|
+
} as Record<string, unknown>,
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A failed poll carries a `details` payload that is a plain string for
|
|
193
|
+
* moderation refusals and an object for everything else.
|
|
194
|
+
*/
|
|
195
|
+
function describePollDetails(details: unknown): string | undefined {
|
|
196
|
+
if (details == null) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof details === 'string') {
|
|
201
|
+
return details;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return JSON.stringify(details);
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The API takes conditioning media as an http(s) URL or a bare base64 string —
|
|
213
|
+
* not a data URI.
|
|
214
|
+
*/
|
|
215
|
+
function toBlackForestLabsFile(file: VideoModelV4File): string {
|
|
216
|
+
if (file.type === 'url') {
|
|
217
|
+
return file.url;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (typeof file.data === 'string') {
|
|
221
|
+
return file.data;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return Buffer.from(file.data).toString('base64');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class BlackForestLabsVideoModel implements VideoModelV4 {
|
|
228
|
+
readonly specificationVersion = 'v4';
|
|
229
|
+
readonly maxVideosPerCall = 1;
|
|
230
|
+
|
|
231
|
+
get provider(): string {
|
|
232
|
+
return this.config.provider;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
static [WORKFLOW_SERIALIZE](model: BlackForestLabsVideoModel) {
|
|
236
|
+
return serializeModelOptions({
|
|
237
|
+
modelId: model.modelId,
|
|
238
|
+
config: model.config,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
static [WORKFLOW_DESERIALIZE](options: {
|
|
243
|
+
modelId: BlackForestLabsVideoModelId;
|
|
244
|
+
config: BlackForestLabsVideoModelConfig;
|
|
245
|
+
}) {
|
|
246
|
+
return new BlackForestLabsVideoModel(options.modelId, options.config);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
constructor(
|
|
250
|
+
readonly modelId: BlackForestLabsVideoModelId,
|
|
251
|
+
private readonly config: BlackForestLabsVideoModelConfig,
|
|
252
|
+
) {}
|
|
253
|
+
|
|
254
|
+
private async getArgs(options: BlackForestLabsVideoDoGenerateOptions) {
|
|
255
|
+
const warnings: Array<SharedV4Warning> = [];
|
|
256
|
+
|
|
257
|
+
const bflOptions = (await parseProviderOptions({
|
|
258
|
+
provider: 'blackForestLabs',
|
|
259
|
+
providerOptions: options.providerOptions,
|
|
260
|
+
schema: blackForestLabsVideoModelOptionsSchema,
|
|
261
|
+
})) as BlackForestLabsVideoModelOptions | undefined;
|
|
262
|
+
|
|
263
|
+
if (bflOptions?.draftCache != null) {
|
|
264
|
+
return getDraftEnhanceArgs(options, bflOptions);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (options.fps != null) {
|
|
268
|
+
warnings.push({
|
|
269
|
+
type: 'unsupported',
|
|
270
|
+
feature: 'fps',
|
|
271
|
+
details: 'FLUX 3 video does not support a custom frame rate.',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (options.seed != null) {
|
|
276
|
+
warnings.push({
|
|
277
|
+
type: 'unsupported',
|
|
278
|
+
feature: 'seed',
|
|
279
|
+
details: 'FLUX 3 video does not accept a seed.',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (options.n != null && options.n > 1) {
|
|
284
|
+
warnings.push({
|
|
285
|
+
type: 'unsupported',
|
|
286
|
+
feature: 'n',
|
|
287
|
+
details:
|
|
288
|
+
'FLUX 3 video generates a single video per call. Only 1 video will be generated.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Resolution: an explicit provider option is already a tier, so it wins and
|
|
293
|
+
// a top-level value only has to be resolved when it is the sole source.
|
|
294
|
+
let resolution: string | undefined = bflOptions?.resolution;
|
|
295
|
+
if (options.resolution != null) {
|
|
296
|
+
const resolved = resolveTopLevelResolution(options.resolution);
|
|
297
|
+
if (resolution != null) {
|
|
298
|
+
if (resolved == null) {
|
|
299
|
+
warnings.push({
|
|
300
|
+
type: 'unsupported',
|
|
301
|
+
feature: 'resolution',
|
|
302
|
+
details:
|
|
303
|
+
`Unrecognized resolution "${options.resolution}". FLUX 3 video supports "hd" and "fhd", ` +
|
|
304
|
+
`so providerOptions.blackForestLabs.resolution ("${resolution}") was used instead.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} else if (resolved == null) {
|
|
308
|
+
warnings.push({
|
|
309
|
+
type: 'unsupported',
|
|
310
|
+
feature: 'resolution',
|
|
311
|
+
details:
|
|
312
|
+
`Unrecognized resolution "${options.resolution}". FLUX 3 video supports ` +
|
|
313
|
+
'"hd" and "fhd", or a {width}x{height} value to map onto one.',
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
resolution = resolved.tier;
|
|
317
|
+
if (resolved.derived) {
|
|
318
|
+
warnings.push({
|
|
319
|
+
type: 'compatibility',
|
|
320
|
+
feature: 'resolution',
|
|
321
|
+
details:
|
|
322
|
+
`FLUX 3 video renders at "hd" or "fhd"; the requested resolution ` +
|
|
323
|
+
`"${options.resolution}" was mapped to "${resolved.tier}".`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Aspect ratio: the provider option can also express `auto`, so it wins.
|
|
330
|
+
let aspectRatio: string | undefined = bflOptions?.aspectRatio;
|
|
331
|
+
if (aspectRatio == null && options.aspectRatio != null) {
|
|
332
|
+
if (allowedAspectRatios.has(options.aspectRatio)) {
|
|
333
|
+
aspectRatio = options.aspectRatio;
|
|
334
|
+
} else {
|
|
335
|
+
warnings.push({
|
|
336
|
+
type: 'unsupported',
|
|
337
|
+
feature: 'aspectRatio',
|
|
338
|
+
details:
|
|
339
|
+
`FLUX 3 video does not support the aspect ratio "${options.aspectRatio}". ` +
|
|
340
|
+
'Using the provider default (auto).',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Resolve first/last frame inputs. A standalone `image` is treated as the
|
|
346
|
+
// first frame (image-to-video). The core sets `image` to the `first_frame`
|
|
347
|
+
// frame image when there is one, so the frame-image entry is tracked
|
|
348
|
+
// separately to report which input a warning came from.
|
|
349
|
+
const firstFrameImage = options.frameImages?.find(
|
|
350
|
+
frame => frame.frameType === 'first_frame',
|
|
351
|
+
)?.image;
|
|
352
|
+
let firstFrame = firstFrameImage ?? options.image;
|
|
353
|
+
let lastFrame = options.frameImages?.find(
|
|
354
|
+
frame => frame.frameType === 'last_frame',
|
|
355
|
+
)?.image;
|
|
356
|
+
|
|
357
|
+
const firstFrameMediaType =
|
|
358
|
+
firstFrame != null ? nonImageFrameMediaType(firstFrame) : undefined;
|
|
359
|
+
if (firstFrame != null && firstFrameMediaType != null) {
|
|
360
|
+
warnings.push({
|
|
361
|
+
type: 'unsupported',
|
|
362
|
+
feature: firstFrameImage != null ? 'frameImages' : 'image',
|
|
363
|
+
details:
|
|
364
|
+
firstFrameMediaType === 'video'
|
|
365
|
+
? 'FLUX 3 video does not accept a video as a keyframe. Pass it as an inputReference to continue from it instead.'
|
|
366
|
+
: `FLUX 3 video only accepts an image as a keyframe; the "${firstFrame.mediaType}" file was ignored.`,
|
|
367
|
+
});
|
|
368
|
+
firstFrame = undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (lastFrame != null) {
|
|
372
|
+
if (firstFrame == null) {
|
|
373
|
+
// Keyframes are positional: the first entry opens the clip, so there is
|
|
374
|
+
// no way to send a closing frame on its own.
|
|
375
|
+
warnings.push({
|
|
376
|
+
type: 'unsupported',
|
|
377
|
+
feature: 'frameImages',
|
|
378
|
+
details:
|
|
379
|
+
'FLUX 3 video requires a first_frame when a last_frame is provided. The last_frame was ignored.',
|
|
380
|
+
});
|
|
381
|
+
lastFrame = undefined;
|
|
382
|
+
} else {
|
|
383
|
+
const lastFrameMediaType = nonImageFrameMediaType(lastFrame);
|
|
384
|
+
if (lastFrameMediaType != null) {
|
|
385
|
+
warnings.push({
|
|
386
|
+
type: 'unsupported',
|
|
387
|
+
feature: 'frameImages',
|
|
388
|
+
details:
|
|
389
|
+
lastFrameMediaType === 'video'
|
|
390
|
+
? 'FLUX 3 video does not accept a video as a keyframe. The last_frame video was ignored.'
|
|
391
|
+
: `FLUX 3 video only accepts an image as a keyframe; the "${lastFrame.mediaType}" last_frame was ignored.`,
|
|
392
|
+
});
|
|
393
|
+
lastFrame = undefined;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Keyframes: the provider option covers the shapes the top-level fields
|
|
399
|
+
// cannot express (3+ images, images pinned to a second), so it wins.
|
|
400
|
+
let keyframes: BlackForestLabsVideoKeyframe[] | undefined =
|
|
401
|
+
bflOptions?.keyframes != null && bflOptions.keyframes.length > 0
|
|
402
|
+
? [...bflOptions.keyframes]
|
|
403
|
+
: undefined;
|
|
404
|
+
|
|
405
|
+
if (keyframes != null) {
|
|
406
|
+
if (firstFrame != null || lastFrame != null) {
|
|
407
|
+
warnings.push({
|
|
408
|
+
type: 'unsupported',
|
|
409
|
+
feature: options.frameImages != null ? 'frameImages' : 'image',
|
|
410
|
+
details:
|
|
411
|
+
'FLUX 3 video takes a single keyframe list. providerOptions.blackForestLabs.keyframes ' +
|
|
412
|
+
'was used and the top-level frame images were ignored.',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
} else if (firstFrame != null) {
|
|
416
|
+
keyframes = [toBlackForestLabsFile(firstFrame)];
|
|
417
|
+
if (lastFrame != null) {
|
|
418
|
+
keyframes.push(toBlackForestLabsFile(lastFrame));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Video continuation. FLUX 3 takes a single `start_video` and has no
|
|
423
|
+
// reference-image concept, so image references cannot be honored.
|
|
424
|
+
let startVideo: string | undefined;
|
|
425
|
+
const referenceFiles = options.inputReferences ?? [];
|
|
426
|
+
const referenceVideos: VideoModelV4File[] = [];
|
|
427
|
+
|
|
428
|
+
for (const file of referenceFiles) {
|
|
429
|
+
const topLevelMediaType =
|
|
430
|
+
file.mediaType != null
|
|
431
|
+
? getTopLevelMediaType(file.mediaType)
|
|
432
|
+
: undefined;
|
|
433
|
+
|
|
434
|
+
if (topLevelMediaType === 'video') {
|
|
435
|
+
referenceVideos.push(file);
|
|
436
|
+
} else if (topLevelMediaType == null) {
|
|
437
|
+
warnings.push({
|
|
438
|
+
type: 'compatibility',
|
|
439
|
+
feature: 'inputReferences',
|
|
440
|
+
details:
|
|
441
|
+
'FLUX 3 video only accepts a video reference, so the reference with no mediaType ' +
|
|
442
|
+
'was treated as the video to continue from. Pass { url, mediaType: "video/mp4" } to be explicit.',
|
|
443
|
+
});
|
|
444
|
+
referenceVideos.push(file);
|
|
445
|
+
} else if (topLevelMediaType === 'image') {
|
|
446
|
+
warnings.push({
|
|
447
|
+
type: 'unsupported',
|
|
448
|
+
feature: 'inputReferences',
|
|
449
|
+
details:
|
|
450
|
+
'FLUX 3 video has no reference-image input. Pass images as `image`, `frameImages`, or ' +
|
|
451
|
+
'providerOptions.blackForestLabs.keyframes instead. The reference was ignored.',
|
|
452
|
+
});
|
|
453
|
+
} else {
|
|
454
|
+
warnings.push({
|
|
455
|
+
type: 'unsupported',
|
|
456
|
+
feature: 'inputReferences',
|
|
457
|
+
details: `FLUX 3 video only accepts a video reference; the "${file.mediaType}" reference was ignored.`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (referenceVideos.length > 0) {
|
|
463
|
+
if (keyframes != null) {
|
|
464
|
+
// Continuation and keyframes are mutually exclusive modes.
|
|
465
|
+
warnings.push({
|
|
466
|
+
type: 'unsupported',
|
|
467
|
+
feature: 'inputReferences',
|
|
468
|
+
details:
|
|
469
|
+
'FLUX 3 video cannot combine keyframes with a video to continue from. The video reference was ignored.',
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
startVideo = toBlackForestLabsFile(referenceVideos[0]);
|
|
473
|
+
if (referenceVideos.length > 1) {
|
|
474
|
+
warnings.push({
|
|
475
|
+
type: 'unsupported',
|
|
476
|
+
feature: 'inputReferences',
|
|
477
|
+
details:
|
|
478
|
+
'FLUX 3 video continues from a single video. Only the first video reference was used.',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Duration: whole seconds between 5 and 20, or omitted to let the API pick
|
|
485
|
+
// one that matches the content.
|
|
486
|
+
let duration = options.duration;
|
|
487
|
+
if (duration != null) {
|
|
488
|
+
if (!Number.isInteger(duration)) {
|
|
489
|
+
const rounded = Math.round(duration);
|
|
490
|
+
warnings.push({
|
|
491
|
+
type: 'unsupported',
|
|
492
|
+
feature: 'duration',
|
|
493
|
+
details: `FLUX 3 video requires a whole number of seconds. The requested duration of ${duration} was rounded to ${rounded}.`,
|
|
494
|
+
});
|
|
495
|
+
duration = rounded;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (duration > MAX_DURATION_SECONDS) {
|
|
499
|
+
warnings.push({
|
|
500
|
+
type: 'unsupported',
|
|
501
|
+
feature: 'duration',
|
|
502
|
+
details: `FLUX 3 video supports at most ${MAX_DURATION_SECONDS} seconds. The requested duration of ${options.duration} was clamped to ${MAX_DURATION_SECONDS}.`,
|
|
503
|
+
});
|
|
504
|
+
duration = MAX_DURATION_SECONDS;
|
|
505
|
+
} else if (duration < MIN_DURATION_SECONDS) {
|
|
506
|
+
warnings.push({
|
|
507
|
+
type: 'unsupported',
|
|
508
|
+
feature: 'duration',
|
|
509
|
+
details: `FLUX 3 video requires at least ${MIN_DURATION_SECONDS} seconds. The requested duration of ${options.duration} was clamped to ${MIN_DURATION_SECONDS}.`,
|
|
510
|
+
});
|
|
511
|
+
duration = MIN_DURATION_SECONDS;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const untimedKeyframeCount =
|
|
516
|
+
keyframes?.filter(keyframe => typeof keyframe === 'string').length ?? 0;
|
|
517
|
+
if (
|
|
518
|
+
duration == null &&
|
|
519
|
+
untimedKeyframeCount >= UNTIMED_KEYFRAMES_NEEDING_DURATION
|
|
520
|
+
) {
|
|
521
|
+
throw new InvalidArgumentError({
|
|
522
|
+
argument: 'duration',
|
|
523
|
+
message:
|
|
524
|
+
`FLUX 3 video requires an explicit duration when ${UNTIMED_KEYFRAMES_NEEDING_DURATION} or more ` +
|
|
525
|
+
'keyframes are sent without a timestamp.',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const mode = keyframes != null ? 'i2v' : startVideo != null ? 'v2v' : 't2v';
|
|
530
|
+
|
|
531
|
+
const body: Record<string, unknown> = {
|
|
532
|
+
mode,
|
|
533
|
+
prompt: options.prompt ?? '',
|
|
534
|
+
aspect_ratio: aspectRatio,
|
|
535
|
+
duration,
|
|
536
|
+
resolution,
|
|
537
|
+
version: bflOptions?.version,
|
|
538
|
+
generate_audio: options.generateAudio,
|
|
539
|
+
safety_tolerance: bflOptions?.safetyTolerance,
|
|
540
|
+
draft: bflOptions?.draft,
|
|
541
|
+
...(mode === 'i2v' && { keyframes }),
|
|
542
|
+
...(mode === 'v2v' && { start_video: startVideo }),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
return { body, warnings };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async doStart(
|
|
549
|
+
options: Parameters<NonNullable<VideoModelV4['doStart']>>[0],
|
|
550
|
+
): Promise<VideoModelV4OperationStartResult> {
|
|
551
|
+
const { body, warnings } = await this.getArgs(options);
|
|
552
|
+
|
|
553
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
554
|
+
const combinedHeaders = combineHeaders(
|
|
555
|
+
await resolve(this.config.headers),
|
|
556
|
+
options.headers,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const { value: submit, responseHeaders } = await postJsonToApi({
|
|
560
|
+
url: `${this.config.baseURL}/${this.modelId}`,
|
|
561
|
+
headers: combinedHeaders,
|
|
562
|
+
body,
|
|
563
|
+
failedResponseHandler: bflFailedResponseHandler,
|
|
564
|
+
successfulResponseHandler:
|
|
565
|
+
createJsonResponseHandler(bflVideoSubmitSchema),
|
|
566
|
+
abortSignal: options.abortSignal,
|
|
567
|
+
fetch: this.config.fetch,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
operation: {
|
|
572
|
+
requestId: submit.id,
|
|
573
|
+
pollingUrl: submit.polling_url,
|
|
574
|
+
...(submit.cost != null && { cost: submit.cost }),
|
|
575
|
+
...(submit.input_mp != null && {
|
|
576
|
+
inputMegapixels: submit.input_mp,
|
|
577
|
+
}),
|
|
578
|
+
...(submit.output_mp != null && {
|
|
579
|
+
outputMegapixels: submit.output_mp,
|
|
580
|
+
}),
|
|
581
|
+
} satisfies BlackForestLabsVideoOperation,
|
|
582
|
+
warnings,
|
|
583
|
+
response: {
|
|
584
|
+
modelId: this.modelId,
|
|
585
|
+
timestamp: currentDate,
|
|
586
|
+
headers: responseHeaders,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async doStatus(
|
|
592
|
+
options: Parameters<NonNullable<VideoModelV4['doStatus']>>[0],
|
|
593
|
+
): Promise<VideoModelV4OperationStatusResult> {
|
|
594
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
595
|
+
const operation = options.operation as BlackForestLabsVideoOperation;
|
|
596
|
+
const url = new URL(operation.pollingUrl);
|
|
597
|
+
if (!url.searchParams.has('id')) {
|
|
598
|
+
url.searchParams.set('id', operation.requestId);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const combinedHeaders = combineHeaders(
|
|
602
|
+
await resolve(this.config.headers),
|
|
603
|
+
options.headers,
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const { value, responseHeaders } = await getFromApi({
|
|
607
|
+
url: url.toString(),
|
|
608
|
+
// The polling URL comes from the provider response; validate it.
|
|
609
|
+
validateUrl: true,
|
|
610
|
+
trustedOrigin: this.config.baseURL,
|
|
611
|
+
// Only send credentials when it stays on a trusted provider host.
|
|
612
|
+
headers: isTrustedUrl(url.toString(), this.config.baseURL)
|
|
613
|
+
? combinedHeaders
|
|
614
|
+
: undefined,
|
|
615
|
+
failedResponseHandler: bflFailedResponseHandler,
|
|
616
|
+
successfulResponseHandler: createJsonResponseHandler(bflVideoPollSchema),
|
|
617
|
+
abortSignal: options.abortSignal,
|
|
618
|
+
fetch: this.config.fetch,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const response = {
|
|
622
|
+
modelId: this.modelId,
|
|
623
|
+
timestamp: currentDate,
|
|
624
|
+
headers: responseHeaders,
|
|
625
|
+
};
|
|
626
|
+
const { status, result } = value;
|
|
627
|
+
|
|
628
|
+
if (status === 'Ready') {
|
|
629
|
+
const parsed = bflVideoResultSchema.safeParse(result);
|
|
630
|
+
if (!parsed.success) {
|
|
631
|
+
throw new AISDKError({
|
|
632
|
+
name: 'BLACK_FOREST_LABS_VIDEO_GENERATION_ERROR',
|
|
633
|
+
message:
|
|
634
|
+
'Black Forest Labs reported the video as Ready but returned no result.sample URL. ' +
|
|
635
|
+
`Request id: ${operation.requestId}`,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
status: 'completed',
|
|
641
|
+
videos: [
|
|
642
|
+
{
|
|
643
|
+
type: 'url',
|
|
644
|
+
url: parsed.data.sample,
|
|
645
|
+
mediaType: 'video/mp4',
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
warnings: [],
|
|
649
|
+
providerMetadata: {
|
|
650
|
+
blackForestLabs: {
|
|
651
|
+
videos: [
|
|
652
|
+
{
|
|
653
|
+
id: operation.requestId,
|
|
654
|
+
videoUrl: parsed.data.sample,
|
|
655
|
+
...(parsed.data.seed != null && { seed: parsed.data.seed }),
|
|
656
|
+
...(parsed.data.start_time != null && {
|
|
657
|
+
start_time: parsed.data.start_time,
|
|
658
|
+
}),
|
|
659
|
+
...(parsed.data.end_time != null && {
|
|
660
|
+
end_time: parsed.data.end_time,
|
|
661
|
+
}),
|
|
662
|
+
...(parsed.data.duration != null && {
|
|
663
|
+
duration: parsed.data.duration,
|
|
664
|
+
}),
|
|
665
|
+
...(parsed.data.draft_cache != null && {
|
|
666
|
+
draftCache: parsed.data.draft_cache,
|
|
667
|
+
}),
|
|
668
|
+
...(operation.cost != null && { cost: operation.cost }),
|
|
669
|
+
...(operation.inputMegapixels != null && {
|
|
670
|
+
inputMegapixels: operation.inputMegapixels,
|
|
671
|
+
}),
|
|
672
|
+
...(operation.outputMegapixels != null && {
|
|
673
|
+
outputMegapixels: operation.outputMegapixels,
|
|
674
|
+
}),
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
response,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (TERMINAL_FAILURE_STATUSES.has(status)) {
|
|
684
|
+
const detail = describePollDetails(value.details);
|
|
685
|
+
return {
|
|
686
|
+
status: 'error',
|
|
687
|
+
error:
|
|
688
|
+
`Black Forest Labs video generation failed with status "${status}"` +
|
|
689
|
+
`${detail != null ? `: ${detail}` : ''}. Request id: ${operation.requestId}`,
|
|
690
|
+
response,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return { status: 'pending', response };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async doGenerate(
|
|
698
|
+
options: BlackForestLabsVideoDoGenerateOptions,
|
|
699
|
+
): Promise<VideoModelV4Result> {
|
|
700
|
+
const startResult = await this.doStart(options);
|
|
701
|
+
const operation = startResult.operation as BlackForestLabsVideoOperation;
|
|
702
|
+
const pollIntervalMillis =
|
|
703
|
+
this.config.pollIntervalMillis ?? DEFAULT_POLL_INTERVAL_MILLIS;
|
|
704
|
+
const pollTimeoutMillis =
|
|
705
|
+
this.config.pollTimeoutMillis ?? DEFAULT_POLL_TIMEOUT_MILLIS;
|
|
706
|
+
const startTime = Date.now();
|
|
707
|
+
|
|
708
|
+
while (true) {
|
|
709
|
+
await delay(pollIntervalMillis, { abortSignal: options.abortSignal });
|
|
710
|
+
|
|
711
|
+
if (Date.now() - startTime > pollTimeoutMillis) {
|
|
712
|
+
throw new AISDKError({
|
|
713
|
+
name: 'BLACK_FOREST_LABS_VIDEO_GENERATION_TIMEOUT',
|
|
714
|
+
message:
|
|
715
|
+
`Black Forest Labs video generation timed out after ${pollTimeoutMillis}ms. ` +
|
|
716
|
+
`Request id: ${operation.requestId}`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const statusResult = await this.doStatus({
|
|
721
|
+
operation,
|
|
722
|
+
headers: options.headers,
|
|
723
|
+
abortSignal: options.abortSignal,
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (statusResult.status === 'pending') {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (statusResult.status === 'error') {
|
|
731
|
+
throw new AISDKError({
|
|
732
|
+
name: 'BLACK_FOREST_LABS_VIDEO_GENERATION_FAILED',
|
|
733
|
+
message: statusResult.error,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
videos: statusResult.videos,
|
|
739
|
+
warnings: [...startResult.warnings, ...statusResult.warnings],
|
|
740
|
+
providerMetadata: statusResult.providerMetadata,
|
|
741
|
+
response: statusResult.response,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const bflVideoSubmitSchema = z.object({
|
|
748
|
+
id: z.string(),
|
|
749
|
+
polling_url: z.url(),
|
|
750
|
+
cost: z.number().nullish(),
|
|
751
|
+
input_mp: z.number().nullish(),
|
|
752
|
+
output_mp: z.number().nullish(),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const bflVideoResultSchema = z.object({
|
|
756
|
+
sample: z.url(),
|
|
757
|
+
seed: z.number().nullish(),
|
|
758
|
+
start_time: z.number().nullish(),
|
|
759
|
+
end_time: z.number().nullish(),
|
|
760
|
+
duration: z.number().nullish(),
|
|
761
|
+
draft_cache: z.string().nullish(),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const bflVideoPollSchema = z
|
|
765
|
+
.object({
|
|
766
|
+
status: z.string().optional(),
|
|
767
|
+
state: z.string().optional(),
|
|
768
|
+
details: z.unknown().optional(),
|
|
769
|
+
result: z.unknown().optional(),
|
|
770
|
+
})
|
|
771
|
+
.refine(v => v.status != null || v.state != null, {
|
|
772
|
+
message: 'Missing status in Black Forest Labs poll response',
|
|
773
|
+
})
|
|
774
|
+
.transform(v => ({
|
|
775
|
+
status: (v.status ?? v.state)!,
|
|
776
|
+
details: v.details,
|
|
777
|
+
result: v.result,
|
|
778
|
+
}));
|