@ai-sdk/topaz 0.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +13 -0
- package/README.md +67 -3
- package/dist/index.d.ts +244 -0
- package/dist/index.js +1348 -0
- package/dist/index.js.map +1 -0
- package/docs/210-topaz.mdx +248 -0
- package/package.json +64 -10
- package/src/index.ts +11 -0
- package/src/topaz-config.ts +11 -0
- package/src/topaz-error.ts +61 -0
- package/src/topaz-get-from-api.ts +53 -0
- package/src/topaz-image-model-options.ts +107 -0
- package/src/topaz-image-model.ts +449 -0
- package/src/topaz-image-settings.ts +22 -0
- package/src/topaz-provider.ts +136 -0
- package/src/topaz-video-model-options.ts +289 -0
- package/src/topaz-video-model.ts +851 -0
- package/src/topaz-video-settings.ts +27 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AISDKError,
|
|
3
|
+
APICallError,
|
|
4
|
+
InvalidArgumentError,
|
|
5
|
+
InvalidResponseDataError,
|
|
6
|
+
type Experimental_VideoModelV3 as VideoModelV3,
|
|
7
|
+
type Experimental_VideoModelV3File as VideoModelV3File,
|
|
8
|
+
type SharedV3Warning,
|
|
9
|
+
} from '@ai-sdk/provider';
|
|
10
|
+
import {
|
|
11
|
+
combineHeaders,
|
|
12
|
+
convertBase64ToUint8Array,
|
|
13
|
+
createJsonResponseHandler,
|
|
14
|
+
delay,
|
|
15
|
+
isSameOrigin,
|
|
16
|
+
parseProviderOptions,
|
|
17
|
+
postJsonToApi,
|
|
18
|
+
removeUndefinedEntries,
|
|
19
|
+
resolve,
|
|
20
|
+
validateDownloadUrl,
|
|
21
|
+
withUserAgentSuffix,
|
|
22
|
+
} from '@ai-sdk/provider-utils';
|
|
23
|
+
import { z } from 'zod/v4';
|
|
24
|
+
import type { TopazConfig } from './topaz-config';
|
|
25
|
+
import { topazFailedResponseHandler } from './topaz-error';
|
|
26
|
+
import { topazGetFromApi } from './topaz-get-from-api';
|
|
27
|
+
import {
|
|
28
|
+
TOPAZ_NON_FILTER_OPTION_KEYS,
|
|
29
|
+
type topazOutputContainers,
|
|
30
|
+
topazSourceContainers,
|
|
31
|
+
topazVideoModelOptionsSchema,
|
|
32
|
+
type TopazVideoModelOptions,
|
|
33
|
+
} from './topaz-video-model-options';
|
|
34
|
+
import {
|
|
35
|
+
resolveTopazVideoApiModelId,
|
|
36
|
+
type TopazVideoModelId,
|
|
37
|
+
} from './topaz-video-settings';
|
|
38
|
+
import { VERSION } from './version';
|
|
39
|
+
|
|
40
|
+
type VideoModelResult = Awaited<ReturnType<VideoModelV3['doGenerate']>>;
|
|
41
|
+
type TopazVideoOperation = { requestId: string; outputContainer: string };
|
|
42
|
+
|
|
43
|
+
const DEFAULT_POLL_INTERVAL_MILLIS = 2000;
|
|
44
|
+
const DEFAULT_POLL_TIMEOUT_MILLIS = 3_600_000;
|
|
45
|
+
|
|
46
|
+
type TopazSourceContainer = (typeof topazSourceContainers)[number];
|
|
47
|
+
type TopazOutputContainer = (typeof topazOutputContainers)[number];
|
|
48
|
+
|
|
49
|
+
const mediaTypeContainers: Record<string, TopazSourceContainer> = {
|
|
50
|
+
'video/mp4': 'mp4',
|
|
51
|
+
'video/quicktime': 'mov',
|
|
52
|
+
'video/mov': 'mov',
|
|
53
|
+
'video/x-matroska': 'mkv',
|
|
54
|
+
'video/matroska': 'mkv',
|
|
55
|
+
'video/webm': 'webm',
|
|
56
|
+
'video/x-msvideo': 'avi',
|
|
57
|
+
'video/avi': 'avi',
|
|
58
|
+
'video/mpeg': 'mpeg',
|
|
59
|
+
'video/mp2t': 'ts',
|
|
60
|
+
'video/x-ms-wmv': 'wmv',
|
|
61
|
+
'video/x-flv': 'flv',
|
|
62
|
+
'video/3gpp': '3gp',
|
|
63
|
+
'video/x-m4v': 'm4v',
|
|
64
|
+
'application/mxf': 'mxf',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const extensionContainers: Record<string, TopazSourceContainer> = {
|
|
68
|
+
...Object.fromEntries(topazSourceContainers.map(c => [c, c])),
|
|
69
|
+
qt: 'mov',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const containerMediaTypes: Record<string, string> = {
|
|
73
|
+
mp4: 'video/mp4',
|
|
74
|
+
m4v: 'video/mp4',
|
|
75
|
+
mov: 'video/quicktime',
|
|
76
|
+
mkv: 'video/x-matroska',
|
|
77
|
+
webm: 'video/webm',
|
|
78
|
+
avi: 'video/x-msvideo',
|
|
79
|
+
mpeg: 'video/mpeg',
|
|
80
|
+
mpg: 'video/mpeg',
|
|
81
|
+
ts: 'video/mp2t',
|
|
82
|
+
wmv: 'video/x-ms-wmv',
|
|
83
|
+
flv: 'video/x-flv',
|
|
84
|
+
'3gp': 'video/3gpp',
|
|
85
|
+
mxf: 'application/mxf',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Topaz video models enhance a video the caller supplies, passed through
|
|
90
|
+
* `inputReferences`. `doGenerate` creates an express request: Topaz fetches URL
|
|
91
|
+
* inputs itself, and file inputs are uploaded to the returned URL. Processing
|
|
92
|
+
* starts once Topaz has the video, and the provider polls until it completes.
|
|
93
|
+
*/
|
|
94
|
+
export class TopazVideoModel implements VideoModelV3 {
|
|
95
|
+
readonly specificationVersion = 'v3';
|
|
96
|
+
readonly maxVideosPerCall = 1;
|
|
97
|
+
|
|
98
|
+
get provider(): string {
|
|
99
|
+
return this.config.provider;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
readonly modelId: TopazVideoModelId,
|
|
104
|
+
private readonly config: TopazConfig,
|
|
105
|
+
) {}
|
|
106
|
+
|
|
107
|
+
async doGenerate(
|
|
108
|
+
options: Parameters<VideoModelV3['doGenerate']>[0],
|
|
109
|
+
): Promise<VideoModelResult> {
|
|
110
|
+
const topazOptions = await parseProviderOptions({
|
|
111
|
+
provider: 'topaz',
|
|
112
|
+
providerOptions: options.providerOptions,
|
|
113
|
+
schema: topazVideoModelOptionsSchema,
|
|
114
|
+
});
|
|
115
|
+
const started = await this.start(options);
|
|
116
|
+
const pollIntervalMillis =
|
|
117
|
+
topazOptions?.pollIntervalMillis ?? DEFAULT_POLL_INTERVAL_MILLIS;
|
|
118
|
+
const pollTimeoutMillis =
|
|
119
|
+
topazOptions?.pollTimeoutMillis ?? DEFAULT_POLL_TIMEOUT_MILLIS;
|
|
120
|
+
const deadline = Date.now() + pollTimeoutMillis;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
while (true) {
|
|
124
|
+
const result = await this.getStatus({
|
|
125
|
+
operation: started.operation,
|
|
126
|
+
headers: options.headers,
|
|
127
|
+
abortSignal: options.abortSignal,
|
|
128
|
+
});
|
|
129
|
+
if (result != null) {
|
|
130
|
+
return {
|
|
131
|
+
...result,
|
|
132
|
+
warnings: [...started.warnings, ...result.warnings],
|
|
133
|
+
response: {
|
|
134
|
+
...result.response,
|
|
135
|
+
timestamp: started.response.timestamp,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Date.now() + pollIntervalMillis > deadline) {
|
|
141
|
+
const headers = combineHeaders(
|
|
142
|
+
await resolve(this.config.headers),
|
|
143
|
+
options.headers,
|
|
144
|
+
);
|
|
145
|
+
await this.cancelQuietly(started.operation.requestId, headers);
|
|
146
|
+
throw new AISDKError({
|
|
147
|
+
name: 'TOPAZ_VIDEO_ENHANCEMENT_TIMEOUT',
|
|
148
|
+
message:
|
|
149
|
+
`Topaz video enhancement did not finish within ${pollTimeoutMillis}ms ` +
|
|
150
|
+
`(request ${started.operation.requestId}). ` +
|
|
151
|
+
'Increase the `pollTimeoutMillis` provider option if the job needs longer.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
await delay(pollIntervalMillis, { abortSignal: options.abortSignal });
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (options.abortSignal?.aborted) {
|
|
158
|
+
const headers = combineHeaders(
|
|
159
|
+
await resolve(this.config.headers),
|
|
160
|
+
options.headers,
|
|
161
|
+
);
|
|
162
|
+
await this.cancelQuietly(started.operation.requestId, headers);
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async start(
|
|
169
|
+
options: Parameters<VideoModelV3['doGenerate']>[0],
|
|
170
|
+
): Promise<
|
|
171
|
+
Omit<VideoModelResult, 'videos'> & { operation: TopazVideoOperation }
|
|
172
|
+
> {
|
|
173
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
174
|
+
const warnings: SharedV3Warning[] = [];
|
|
175
|
+
|
|
176
|
+
const topazOptions = await parseProviderOptions({
|
|
177
|
+
provider: 'topaz',
|
|
178
|
+
providerOptions: options.providerOptions,
|
|
179
|
+
schema: topazVideoModelOptionsSchema,
|
|
180
|
+
});
|
|
181
|
+
const headers = combineHeaders(
|
|
182
|
+
await resolve(this.config.headers),
|
|
183
|
+
options.headers,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
this.addUnsupportedWarnings(options, warnings);
|
|
187
|
+
|
|
188
|
+
const input = this.selectInputVideo(options, warnings);
|
|
189
|
+
const container = resolveContainer(input, topazOptions?.source?.container);
|
|
190
|
+
const source = resolveSource(topazOptions, container);
|
|
191
|
+
const output = buildOutput({ options, topazOptions, source });
|
|
192
|
+
const filters = [
|
|
193
|
+
buildFilter(this.modelId, topazOptions),
|
|
194
|
+
...(topazOptions?.additionalFilters ?? []),
|
|
195
|
+
];
|
|
196
|
+
const contentType =
|
|
197
|
+
containerMediaTypes[source.container] ?? 'application/octet-stream';
|
|
198
|
+
|
|
199
|
+
const metadata = source.metadata;
|
|
200
|
+
const bytes = input.type === 'file' ? fileBytes(input) : undefined;
|
|
201
|
+
let requestId: string | undefined;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const { value: created, responseHeaders } = await postJsonToApi({
|
|
205
|
+
url: `${this.config.baseURL}/video/express`,
|
|
206
|
+
headers,
|
|
207
|
+
body: {
|
|
208
|
+
source: {
|
|
209
|
+
container: source.container,
|
|
210
|
+
...(metadata != null
|
|
211
|
+
? {
|
|
212
|
+
duration: metadata.duration,
|
|
213
|
+
frameCount: metadata.frameCount,
|
|
214
|
+
frameRate: metadata.frameRate,
|
|
215
|
+
resolution: {
|
|
216
|
+
width: metadata.width,
|
|
217
|
+
height: metadata.height,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
221
|
+
...(input.type === 'url'
|
|
222
|
+
? // Topaz fetches URL inputs itself, so, like other providers,
|
|
223
|
+
// the SDK never downloads them.
|
|
224
|
+
{ external: { provider: 's3', presignedUrl: input.url } }
|
|
225
|
+
: { size: bytes?.byteLength }),
|
|
226
|
+
},
|
|
227
|
+
output,
|
|
228
|
+
filters,
|
|
229
|
+
},
|
|
230
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
231
|
+
topazVideoExpressResponseSchema,
|
|
232
|
+
),
|
|
233
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
234
|
+
abortSignal: options.abortSignal,
|
|
235
|
+
fetch: this.config.fetch,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
requestId = requireRequestId(created.requestId);
|
|
239
|
+
|
|
240
|
+
if (bytes != null) {
|
|
241
|
+
await this.uploadVideo({
|
|
242
|
+
requestId,
|
|
243
|
+
bytes,
|
|
244
|
+
urls: created.uploadUrls,
|
|
245
|
+
contentType,
|
|
246
|
+
abortSignal: options.abortSignal,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
// The output container travels with the operation so the polling code can
|
|
252
|
+
// report the right media type without re-deriving it.
|
|
253
|
+
operation: { requestId, outputContainer: output.container },
|
|
254
|
+
warnings,
|
|
255
|
+
providerMetadata: {
|
|
256
|
+
topaz: {
|
|
257
|
+
requestId,
|
|
258
|
+
// Preliminary: Topaz estimates up front only when it gets source
|
|
259
|
+
// metadata, and the completed status carries the billed value.
|
|
260
|
+
...(created.estimates?.cost != null
|
|
261
|
+
? { estimatedCredits: created.estimates.cost }
|
|
262
|
+
: {}),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
response: {
|
|
266
|
+
timestamp: currentDate,
|
|
267
|
+
modelId: this.modelId,
|
|
268
|
+
headers: responseHeaders,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Canceling before processing starts refunds any reserved credits.
|
|
273
|
+
if (requestId != null) {
|
|
274
|
+
await this.cancelQuietly(requestId, headers);
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async getStatus(options: {
|
|
281
|
+
operation: TopazVideoOperation;
|
|
282
|
+
headers?: Record<string, string | undefined>;
|
|
283
|
+
abortSignal?: AbortSignal;
|
|
284
|
+
}): Promise<VideoModelResult | undefined> {
|
|
285
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
286
|
+
const { requestId, outputContainer } = options.operation as {
|
|
287
|
+
requestId: string;
|
|
288
|
+
outputContainer?: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const { value: status, responseHeaders } = await topazGetFromApi({
|
|
292
|
+
url: `${this.config.baseURL}/video/${requestId}/status`,
|
|
293
|
+
// Built from the configured baseURL, not from response data.
|
|
294
|
+
validateUrl: false,
|
|
295
|
+
headers: combineHeaders(
|
|
296
|
+
await resolve(this.config.headers),
|
|
297
|
+
options.headers,
|
|
298
|
+
),
|
|
299
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
300
|
+
topazVideoStatusResponseSchema,
|
|
301
|
+
),
|
|
302
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
303
|
+
abortSignal: options.abortSignal,
|
|
304
|
+
fetch: this.config.fetch,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const response = {
|
|
308
|
+
timestamp: currentDate,
|
|
309
|
+
modelId: this.modelId,
|
|
310
|
+
headers: responseHeaders,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (status.status === 'complete') {
|
|
314
|
+
// Topaz confirmed it invoices the lower bound of `estimates.cost`, which
|
|
315
|
+
// it recomputes once the source upload has been received.
|
|
316
|
+
const credits = lowerBoundCredits(status.estimates?.cost);
|
|
317
|
+
|
|
318
|
+
const url = status.download?.url;
|
|
319
|
+
if (url == null) {
|
|
320
|
+
throw new InvalidResponseDataError({
|
|
321
|
+
data: status,
|
|
322
|
+
message: `Topaz reported request ${requestId} complete but returned no download URL.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
videos: [
|
|
328
|
+
{
|
|
329
|
+
type: 'url',
|
|
330
|
+
url,
|
|
331
|
+
mediaType:
|
|
332
|
+
(outputContainer != null
|
|
333
|
+
? containerMediaTypes[outputContainer]
|
|
334
|
+
: undefined) ?? 'video/mp4',
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
warnings: [],
|
|
338
|
+
response,
|
|
339
|
+
providerMetadata: {
|
|
340
|
+
topaz: {
|
|
341
|
+
requestId,
|
|
342
|
+
...(credits != null ? { credits } : {}),
|
|
343
|
+
...(status.estimates?.cost != null
|
|
344
|
+
? { estimatedCredits: status.estimates.cost }
|
|
345
|
+
: {}),
|
|
346
|
+
...(status.outputSize != null
|
|
347
|
+
? { outputSize: status.outputSize }
|
|
348
|
+
: {}),
|
|
349
|
+
...(status.download?.expiresAt != null
|
|
350
|
+
? { expiresAt: status.download.expiresAt }
|
|
351
|
+
: {}),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (status.status === 'failed' || status.status === 'canceled') {
|
|
358
|
+
throw new AISDKError({
|
|
359
|
+
name: 'TOPAZ_VIDEO_ENHANCEMENT_FAILED',
|
|
360
|
+
message:
|
|
361
|
+
`Topaz video request ${requestId} ${status.status}` +
|
|
362
|
+
(status.errorCode != null ? ` (${status.errorCode})` : '') +
|
|
363
|
+
(status.message != null ? `: ${status.message}` : '.'),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Documented in-progress values are requested, accepted, initializing,
|
|
368
|
+
// preprocessing, processing, postprocessing and canceling. Anything else
|
|
369
|
+
// keeps polling too, so a newly added state cannot break running jobs.
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private addUnsupportedWarnings(
|
|
374
|
+
options: Parameters<VideoModelV3['doGenerate']>[0],
|
|
375
|
+
warnings: SharedV3Warning[],
|
|
376
|
+
): void {
|
|
377
|
+
// `generateVideo` requires a prompt, so an empty one is the expected way
|
|
378
|
+
// to call an enhancement model and does not warrant a warning.
|
|
379
|
+
if (options.prompt != null && options.prompt.trim() !== '') {
|
|
380
|
+
warnings.push({
|
|
381
|
+
type: 'unsupported',
|
|
382
|
+
feature: 'prompt',
|
|
383
|
+
details:
|
|
384
|
+
'Topaz video models enhance an existing video and do not take a text prompt. ' +
|
|
385
|
+
'The prompt was ignored.',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (options.aspectRatio != null) {
|
|
390
|
+
warnings.push({
|
|
391
|
+
type: 'unsupported',
|
|
392
|
+
feature: 'aspectRatio',
|
|
393
|
+
details:
|
|
394
|
+
'Topaz video models do not support aspectRatio. Use `resolution`, or the ' +
|
|
395
|
+
'`output` provider option, to set the output dimensions.',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (options.seed != null) {
|
|
400
|
+
warnings.push({
|
|
401
|
+
type: 'unsupported',
|
|
402
|
+
feature: 'seed',
|
|
403
|
+
details: 'Topaz video models do not support seed.',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (options.duration != null) {
|
|
408
|
+
warnings.push({
|
|
409
|
+
type: 'unsupported',
|
|
410
|
+
feature: 'duration',
|
|
411
|
+
details:
|
|
412
|
+
'Topaz video models enhance the whole input video, so duration was ignored. ' +
|
|
413
|
+
'Pass the input duration via the `source.duration` provider option instead.',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (options.generateAudio != null) {
|
|
418
|
+
warnings.push({
|
|
419
|
+
type: 'unsupported',
|
|
420
|
+
feature: 'generateAudio',
|
|
421
|
+
details:
|
|
422
|
+
'Topaz video models do not generate audio. Use the `output.audioTransfer` ' +
|
|
423
|
+
'provider option to control how the input audio track is carried over.',
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (options.frameImages != null && options.frameImages.length > 0) {
|
|
428
|
+
warnings.push({
|
|
429
|
+
type: 'unsupported',
|
|
430
|
+
feature: 'frameImages',
|
|
431
|
+
details:
|
|
432
|
+
'Topaz video models do not support first/last frame generation. The frame ' +
|
|
433
|
+
'images were ignored.',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (options.n > 1) {
|
|
438
|
+
warnings.push({
|
|
439
|
+
type: 'unsupported',
|
|
440
|
+
feature: 'n',
|
|
441
|
+
details:
|
|
442
|
+
'Topaz video models enhance one video per call. Only 1 video will be produced.',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private selectInputVideo(
|
|
448
|
+
options: Parameters<VideoModelV3['doGenerate']>[0],
|
|
449
|
+
warnings: SharedV3Warning[],
|
|
450
|
+
): VideoModelV3File {
|
|
451
|
+
const references = options.inputReferences ?? [];
|
|
452
|
+
const videos = references.filter(isVideoReference);
|
|
453
|
+
|
|
454
|
+
if (videos.length === 0) {
|
|
455
|
+
if (options.image != null) {
|
|
456
|
+
throw new InvalidArgumentError({
|
|
457
|
+
argument: 'image',
|
|
458
|
+
message:
|
|
459
|
+
'Topaz video models enhance an existing video, not a still image. Pass the ' +
|
|
460
|
+
'input video via `inputReferences`.',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new InvalidArgumentError({
|
|
465
|
+
argument: 'inputReferences',
|
|
466
|
+
message:
|
|
467
|
+
'Topaz video models require an input video. Pass it via `inputReferences`, ' +
|
|
468
|
+
'e.g. `inputReferences: [{ type: "file", mediaType: "video/mp4", data: bytes }]`. ' +
|
|
469
|
+
'For URL references, include `mediaType` so the reference is recognized as a video.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (references.length > 1) {
|
|
474
|
+
warnings.push({
|
|
475
|
+
type: 'unsupported',
|
|
476
|
+
feature: 'inputReferences',
|
|
477
|
+
details:
|
|
478
|
+
'Topaz video models enhance a single video. Only the first video reference was used.',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return videos[0];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async uploadVideo({
|
|
486
|
+
requestId,
|
|
487
|
+
bytes,
|
|
488
|
+
urls,
|
|
489
|
+
contentType,
|
|
490
|
+
abortSignal,
|
|
491
|
+
}: {
|
|
492
|
+
requestId: string;
|
|
493
|
+
bytes: Uint8Array;
|
|
494
|
+
urls: string[] | null | undefined;
|
|
495
|
+
contentType: string;
|
|
496
|
+
abortSignal: AbortSignal | undefined;
|
|
497
|
+
}): Promise<void> {
|
|
498
|
+
const url = urls?.[0];
|
|
499
|
+
if (url == null) {
|
|
500
|
+
throw new InvalidResponseDataError({
|
|
501
|
+
data: urls,
|
|
502
|
+
message: `Topaz returned no upload URL for request ${requestId}.`,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The upload URL comes from the Topaz response body. `getFromApi` cannot
|
|
507
|
+
// be used for a PUT, so the same trust decision is made explicitly here:
|
|
508
|
+
// validate unless the URL points back at the configured base URL, and
|
|
509
|
+
// never attach the API key (presigned URLs carry their own credentials).
|
|
510
|
+
if (!isSameOrigin(url, this.config.baseURL)) {
|
|
511
|
+
validateDownloadUrl(url);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const fetchImpl = this.config.fetch ?? globalThis.fetch;
|
|
515
|
+
const response = await fetchImpl(url, {
|
|
516
|
+
method: 'PUT',
|
|
517
|
+
headers: withUserAgentSuffix(
|
|
518
|
+
{ 'Content-Type': contentType },
|
|
519
|
+
`ai-sdk-topaz/${VERSION}`,
|
|
520
|
+
) as HeadersInit,
|
|
521
|
+
body: bytes as BodyInit,
|
|
522
|
+
signal: abortSignal,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
throw new APICallError({
|
|
527
|
+
message: `Uploading the input video failed with status ${response.status}.`,
|
|
528
|
+
url,
|
|
529
|
+
requestBodyValues: {},
|
|
530
|
+
statusCode: response.status,
|
|
531
|
+
responseHeaders: Object.fromEntries(response.headers.entries()),
|
|
532
|
+
responseBody: await response.text(),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private async cancelQuietly(
|
|
538
|
+
requestId: string,
|
|
539
|
+
headers: Record<string, string | undefined>,
|
|
540
|
+
): Promise<void> {
|
|
541
|
+
const fetchImpl = this.config.fetch ?? globalThis.fetch;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
// Deliberately not tied to the caller's abort signal, which may be the
|
|
545
|
+
// reason the start failed.
|
|
546
|
+
await fetchImpl(`${this.config.baseURL}/video/${requestId}`, {
|
|
547
|
+
method: 'DELETE',
|
|
548
|
+
headers: removeUndefinedEntries(headers),
|
|
549
|
+
});
|
|
550
|
+
} catch {
|
|
551
|
+
// Best effort: the original error is more useful to the caller.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function lowerBoundCredits(
|
|
557
|
+
cost: number[] | null | undefined,
|
|
558
|
+
): number | undefined {
|
|
559
|
+
return cost != null && cost.length > 0 ? Math.min(...cost) : undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function fileBytes(
|
|
563
|
+
input: Extract<VideoModelV3File, { type: 'file' }>,
|
|
564
|
+
): Uint8Array {
|
|
565
|
+
return typeof input.data === 'string'
|
|
566
|
+
? convertBase64ToUint8Array(input.data)
|
|
567
|
+
: input.data;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function resolveContainer(
|
|
571
|
+
input: VideoModelV3File,
|
|
572
|
+
declaredContainer: TopazSourceContainer | undefined,
|
|
573
|
+
): TopazSourceContainer {
|
|
574
|
+
if (declaredContainer != null) {
|
|
575
|
+
return declaredContainer;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (input.type === 'file') {
|
|
579
|
+
const container = mediaTypeContainers[input.mediaType.toLowerCase()];
|
|
580
|
+
|
|
581
|
+
if (container == null) {
|
|
582
|
+
throw new InvalidArgumentError({
|
|
583
|
+
argument: 'inputReferences',
|
|
584
|
+
message:
|
|
585
|
+
`Could not map the media type "${input.mediaType}" onto a Topaz container. ` +
|
|
586
|
+
'Set the `source.container` provider option explicitly.',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return container;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const container =
|
|
594
|
+
(input.mediaType != null
|
|
595
|
+
? mediaTypeContainers[input.mediaType.toLowerCase()]
|
|
596
|
+
: undefined) ?? containerFromUrl(input.url);
|
|
597
|
+
|
|
598
|
+
if (container == null) {
|
|
599
|
+
throw new InvalidArgumentError({
|
|
600
|
+
argument: 'inputReferences',
|
|
601
|
+
message:
|
|
602
|
+
`Could not determine the container of the input video at "${input.url}". Set ` +
|
|
603
|
+
'the `source.container` provider option, or pass `mediaType` on the reference.',
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return container;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isVideoReference(reference: VideoModelV3File): boolean {
|
|
611
|
+
if (reference.type === 'file') {
|
|
612
|
+
return reference.mediaType.toLowerCase().startsWith('video/');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (reference.mediaType != null) {
|
|
616
|
+
return reference.mediaType.toLowerCase().startsWith('video/');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return containerFromUrl(reference.url) != null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function containerFromUrl(url: string): TopazSourceContainer | undefined {
|
|
623
|
+
const withoutQuery = url.split(/[?#]/)[0];
|
|
624
|
+
const extension = withoutQuery.split('.').pop()?.toLowerCase();
|
|
625
|
+
return extension != null ? extensionContainers[extension] : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type SourceMetadata = {
|
|
629
|
+
duration: number;
|
|
630
|
+
frameRate: number;
|
|
631
|
+
frameCount: number;
|
|
632
|
+
width: number;
|
|
633
|
+
height: number;
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
type ResolvedSource = {
|
|
637
|
+
container: TopazSourceContainer;
|
|
638
|
+
metadata: SourceMetadata | undefined;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Reads the optional source metadata, which is all or nothing. Starlight
|
|
643
|
+
* models require it, and it lets Topaz estimate the cost up front. Nothing is
|
|
644
|
+
* read out of the video bytes: no provider package inspects media files.
|
|
645
|
+
*/
|
|
646
|
+
function resolveSource(
|
|
647
|
+
topazOptions: TopazVideoModelOptions | undefined,
|
|
648
|
+
container: TopazSourceContainer,
|
|
649
|
+
): ResolvedSource {
|
|
650
|
+
const source = topazOptions?.source;
|
|
651
|
+
const { width, height, duration, frameRate } = source ?? {};
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
width == null &&
|
|
655
|
+
height == null &&
|
|
656
|
+
duration == null &&
|
|
657
|
+
frameRate == null &&
|
|
658
|
+
source?.frameCount == null
|
|
659
|
+
) {
|
|
660
|
+
return { container, metadata: undefined };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const frameCount =
|
|
664
|
+
source?.frameCount ??
|
|
665
|
+
(duration != null && frameRate != null
|
|
666
|
+
? Math.round(duration * frameRate)
|
|
667
|
+
: undefined);
|
|
668
|
+
|
|
669
|
+
const missing = [
|
|
670
|
+
width == null ? 'source.width' : undefined,
|
|
671
|
+
height == null ? 'source.height' : undefined,
|
|
672
|
+
duration == null ? 'source.duration' : undefined,
|
|
673
|
+
frameRate == null ? 'source.frameRate' : undefined,
|
|
674
|
+
].filter(field => field != null);
|
|
675
|
+
|
|
676
|
+
if (
|
|
677
|
+
width == null ||
|
|
678
|
+
height == null ||
|
|
679
|
+
duration == null ||
|
|
680
|
+
frameRate == null ||
|
|
681
|
+
frameCount == null
|
|
682
|
+
) {
|
|
683
|
+
throw new InvalidArgumentError({
|
|
684
|
+
argument: 'providerOptions.topaz.source',
|
|
685
|
+
message: `Source metadata must be complete. Missing: ${missing.join(', ')}.`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
container,
|
|
691
|
+
metadata: { duration, frameRate, frameCount, width, height },
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function parseResolution(resolution: `${number}x${number}` | undefined): {
|
|
696
|
+
width: number | undefined;
|
|
697
|
+
height: number | undefined;
|
|
698
|
+
} {
|
|
699
|
+
if (resolution == null) {
|
|
700
|
+
return { width: undefined, height: undefined };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const [width, height] = resolution.split('x').map(Number);
|
|
704
|
+
return { width, height };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function buildOutput({
|
|
708
|
+
options,
|
|
709
|
+
topazOptions,
|
|
710
|
+
source,
|
|
711
|
+
}: {
|
|
712
|
+
options: Parameters<VideoModelV3['doGenerate']>[0];
|
|
713
|
+
topazOptions: TopazVideoModelOptions | undefined;
|
|
714
|
+
source: ResolvedSource;
|
|
715
|
+
}): Record<string, unknown> & { container: TopazOutputContainer } {
|
|
716
|
+
const output = topazOptions?.output;
|
|
717
|
+
const resolution = parseResolution(options.resolution);
|
|
718
|
+
|
|
719
|
+
const width = output?.width ?? resolution.width ?? source.metadata?.width;
|
|
720
|
+
const height = output?.height ?? resolution.height ?? source.metadata?.height;
|
|
721
|
+
|
|
722
|
+
if (width == null || height == null) {
|
|
723
|
+
throw new InvalidArgumentError({
|
|
724
|
+
argument: 'resolution',
|
|
725
|
+
message:
|
|
726
|
+
'Topaz needs the output resolution. Set the `resolution` call option or the ' +
|
|
727
|
+
'`output.width` / `output.height` provider options.',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const frameRate =
|
|
732
|
+
output?.frameRate ?? options.fps ?? source.metadata?.frameRate;
|
|
733
|
+
const audioTransfer = output?.audioTransfer ?? 'Copy';
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
resolution: { width, height },
|
|
737
|
+
...(frameRate != null ? { frameRate } : {}),
|
|
738
|
+
audioTransfer,
|
|
739
|
+
...(audioTransfer !== 'None'
|
|
740
|
+
? { audioCodec: output?.audioCodec ?? 'AAC' }
|
|
741
|
+
: output?.audioCodec != null
|
|
742
|
+
? { audioCodec: output.audioCodec }
|
|
743
|
+
: {}),
|
|
744
|
+
...(output?.audioBitrate != null
|
|
745
|
+
? { audioBitrate: output.audioBitrate }
|
|
746
|
+
: {}),
|
|
747
|
+
...(output?.videoEncoder != null
|
|
748
|
+
? { videoEncoder: output.videoEncoder }
|
|
749
|
+
: {}),
|
|
750
|
+
...(output?.videoProfile != null
|
|
751
|
+
? { videoProfile: output.videoProfile }
|
|
752
|
+
: {}),
|
|
753
|
+
...(output?.videoBitrate != null
|
|
754
|
+
? { videoBitrate: output.videoBitrate }
|
|
755
|
+
: {}),
|
|
756
|
+
...(output?.dynamicCompressionLevel != null
|
|
757
|
+
? { dynamicCompressionLevel: output.dynamicCompressionLevel }
|
|
758
|
+
: {}),
|
|
759
|
+
...(output?.cropToFit != null ? { cropToFit: output.cropToFit } : {}),
|
|
760
|
+
container: resolveOutputContainer(output, source.container),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Mirrors Topaz's container rules so the polling code can report the media type of
|
|
766
|
+
* the file Topaz will actually produce.
|
|
767
|
+
*/
|
|
768
|
+
function resolveOutputContainer(
|
|
769
|
+
output: TopazVideoModelOptions['output'],
|
|
770
|
+
sourceContainer: TopazSourceContainer,
|
|
771
|
+
): TopazOutputContainer {
|
|
772
|
+
switch (output?.videoEncoder) {
|
|
773
|
+
case 'ProRes':
|
|
774
|
+
return 'mov';
|
|
775
|
+
case 'AV1':
|
|
776
|
+
case 'VP9':
|
|
777
|
+
return 'mp4';
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (output?.container != null) {
|
|
781
|
+
return output.container;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// The default H265 encoder only writes mp4, mov and mkv.
|
|
785
|
+
return sourceContainer === 'mov' || sourceContainer === 'mkv'
|
|
786
|
+
? sourceContainer
|
|
787
|
+
: 'mp4';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function requireRequestId(requestId: string | null | undefined): string {
|
|
791
|
+
if (requestId == null) {
|
|
792
|
+
throw new InvalidResponseDataError({
|
|
793
|
+
data: requestId,
|
|
794
|
+
message: 'Topaz did not return a requestId for the video request.',
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return requestId;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function buildFilter(
|
|
802
|
+
modelId: string,
|
|
803
|
+
topazOptions: TopazVideoModelOptions | undefined,
|
|
804
|
+
): Record<string, unknown> {
|
|
805
|
+
const filter: Record<string, unknown> = {
|
|
806
|
+
model: resolveTopazVideoApiModelId(modelId),
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
if (topazOptions != null) {
|
|
810
|
+
const nonFilterKeys = new Set<string>(TOPAZ_NON_FILTER_OPTION_KEYS);
|
|
811
|
+
|
|
812
|
+
for (const [key, value] of Object.entries(topazOptions)) {
|
|
813
|
+
if (!nonFilterKeys.has(key) && value !== undefined) {
|
|
814
|
+
filter[key] = value;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
Object.assign(filter, topazOptions.filter ?? {});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return filter;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// `cost` (credits) and `time` (seconds) are [lowerBound, upperBound] pairs.
|
|
825
|
+
const topazVideoEstimatesSchema = z
|
|
826
|
+
.object({
|
|
827
|
+
cost: z.array(z.number()).nullish(),
|
|
828
|
+
time: z.array(z.number()).nullish(),
|
|
829
|
+
})
|
|
830
|
+
.nullish();
|
|
831
|
+
|
|
832
|
+
const topazVideoExpressResponseSchema = z.object({
|
|
833
|
+
requestId: z.string().nullish(),
|
|
834
|
+
uploadUrls: z.array(z.string()).nullish(),
|
|
835
|
+
estimates: topazVideoEstimatesSchema,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const topazVideoStatusResponseSchema = z.object({
|
|
839
|
+
status: z.string().nullish(),
|
|
840
|
+
message: z.string().nullish(),
|
|
841
|
+
errorCode: z.string().nullish(),
|
|
842
|
+
// Documented as a string, accepted as a number too.
|
|
843
|
+
outputSize: z.union([z.string(), z.number()]).nullish(),
|
|
844
|
+
estimates: topazVideoEstimatesSchema,
|
|
845
|
+
download: z
|
|
846
|
+
.object({
|
|
847
|
+
url: z.string().nullish(),
|
|
848
|
+
expiresAt: z.union([z.string(), z.number()]).nullish(),
|
|
849
|
+
})
|
|
850
|
+
.nullish(),
|
|
851
|
+
});
|