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