@ai-sdk/topaz 0.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchWithValidatedEndpoint,
|
|
3
|
+
fetchWithValidatedRedirects,
|
|
4
|
+
getFromApi,
|
|
5
|
+
isSameOrigin,
|
|
6
|
+
type FetchFunction,
|
|
7
|
+
} from '@ai-sdk/provider-utils';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapts URL validation to v6's getFromApi, which does not yet expose the
|
|
11
|
+
* validateUrl and credentialedOrigin options.
|
|
12
|
+
*/
|
|
13
|
+
export async function topazGetFromApi<T>({
|
|
14
|
+
validateUrl,
|
|
15
|
+
credentialedOrigin,
|
|
16
|
+
trustedOrigin,
|
|
17
|
+
...options
|
|
18
|
+
}: Parameters<typeof getFromApi<T>>[0] & {
|
|
19
|
+
validateUrl: boolean;
|
|
20
|
+
credentialedOrigin?: string;
|
|
21
|
+
trustedOrigin?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const validatedFetch: FetchFunction = (url, init) =>
|
|
24
|
+
fetchWithValidatedRedirects({
|
|
25
|
+
url: url.toString(),
|
|
26
|
+
headers: init?.headers,
|
|
27
|
+
abortSignal: init?.signal ?? undefined,
|
|
28
|
+
trustedOrigin,
|
|
29
|
+
fetch: (hopUrl, hopInit) =>
|
|
30
|
+
fetchWithValidatedEndpoint({
|
|
31
|
+
url: hopUrl.toString(),
|
|
32
|
+
init: {
|
|
33
|
+
...hopInit,
|
|
34
|
+
headers:
|
|
35
|
+
credentialedOrigin != null &&
|
|
36
|
+
isSameOrigin(hopUrl.toString(), credentialedOrigin)
|
|
37
|
+
? hopInit?.headers
|
|
38
|
+
: undefined,
|
|
39
|
+
},
|
|
40
|
+
trustedOrigin,
|
|
41
|
+
redirect: 'manual',
|
|
42
|
+
fetch: options.fetch,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// v6 ignores this flag; validation is handled by the injected fetch above.
|
|
47
|
+
const requestOptions = {
|
|
48
|
+
...options,
|
|
49
|
+
validateUrl: false,
|
|
50
|
+
fetch: validateUrl ? validatedFetch : options.fetch,
|
|
51
|
+
};
|
|
52
|
+
return getFromApi(requestOptions);
|
|
53
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lazySchema,
|
|
3
|
+
zodSchema,
|
|
4
|
+
type InferSchema,
|
|
5
|
+
} from '@ai-sdk/provider-utils';
|
|
6
|
+
import { z } from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Provider options for Topaz generative image models (Wonder 3.5).
|
|
10
|
+
*
|
|
11
|
+
* The documented request schema fields are sent in snake_case
|
|
12
|
+
* (`output_width`, `output_format`, ...); model-specific settings are sent in
|
|
13
|
+
* camelCase, matching the Topaz model reference.
|
|
14
|
+
*
|
|
15
|
+
* @see https://developer.topazlabs.com/image-models/wonder/wonder-3.5-new
|
|
16
|
+
*/
|
|
17
|
+
export const topazImageModelOptionsSchema = lazySchema(() =>
|
|
18
|
+
zodSchema(
|
|
19
|
+
z.object({
|
|
20
|
+
/**
|
|
21
|
+
* How aggressively the model enhances the image. Defaults to `high`.
|
|
22
|
+
*/
|
|
23
|
+
enhancementStrength: z.enum(['low', 'medium', 'high']).optional(),
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether to add grain to the output. Defaults to `false`.
|
|
27
|
+
*/
|
|
28
|
+
grain: z.boolean().optional(),
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Grain intensity, 0.0 to 1.0. Defaults to 0.5.
|
|
32
|
+
*/
|
|
33
|
+
grainDensity: z.number().min(0).max(1).optional(),
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Grain model. Defaults to `silver`.
|
|
37
|
+
*/
|
|
38
|
+
grainModel: z.enum(['silver', 'gaussian', 'grey']).optional(),
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Grain particle size, 1 to 5. Defaults to 1.
|
|
42
|
+
*/
|
|
43
|
+
grainSize: z.number().min(1).max(5).optional(),
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Grain effect strength, 0.0 to 1.0. Defaults to 0.5.
|
|
47
|
+
*/
|
|
48
|
+
grainStrength: z.number().min(0).max(1).optional(),
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Width of the input image in pixels. Topaz infers this from the upload when
|
|
52
|
+
* omitted.
|
|
53
|
+
*/
|
|
54
|
+
inputWidth: z.number().int().positive().optional(),
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Height of the input image in pixels. Topaz infers this from the upload
|
|
58
|
+
* when omitted.
|
|
59
|
+
*/
|
|
60
|
+
inputHeight: z.number().int().positive().optional(),
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Width of the output image in pixels, 1 to 32000. Takes precedence over the
|
|
64
|
+
* width derived from the `size` call option.
|
|
65
|
+
*/
|
|
66
|
+
outputWidth: z.number().int().min(1).max(32000).optional(),
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Height of the output image in pixels, 1 to 32000. Takes precedence over
|
|
70
|
+
* the height derived from the `size` call option.
|
|
71
|
+
*/
|
|
72
|
+
outputHeight: z.number().int().min(1).max(32000).optional(),
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Output image format. Defaults to the Topaz API default.
|
|
76
|
+
*/
|
|
77
|
+
outputFormat: z.enum(['jpeg', 'jpg', 'png', 'tiff', 'tif']).optional(),
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether to crop the output to fill the requested dimensions. Defaults to
|
|
81
|
+
* `false`.
|
|
82
|
+
*/
|
|
83
|
+
cropToFill: z.boolean().optional(),
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* URL to receive job-status webhooks.
|
|
87
|
+
*/
|
|
88
|
+
webhookUrl: z.string().optional(),
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How often to poll the Topaz status endpoint, in milliseconds. Defaults to
|
|
92
|
+
* 2000.
|
|
93
|
+
*/
|
|
94
|
+
pollIntervalMillis: z.number().int().positive().optional(),
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How long to wait for the job to finish before failing, in milliseconds.
|
|
98
|
+
* Defaults to 600000 (10 minutes).
|
|
99
|
+
*/
|
|
100
|
+
pollTimeoutMillis: z.number().int().positive().optional(),
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export type TopazImageModelOptions = InferSchema<
|
|
106
|
+
typeof topazImageModelOptionsSchema
|
|
107
|
+
>;
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AISDKError,
|
|
3
|
+
InvalidArgumentError,
|
|
4
|
+
InvalidResponseDataError,
|
|
5
|
+
type ImageModelV3,
|
|
6
|
+
type ImageModelV3CallOptions,
|
|
7
|
+
type SharedV3Warning,
|
|
8
|
+
} from '@ai-sdk/provider';
|
|
9
|
+
import {
|
|
10
|
+
combineHeaders,
|
|
11
|
+
convertBase64ToUint8Array,
|
|
12
|
+
createBinaryResponseHandler,
|
|
13
|
+
createJsonResponseHandler,
|
|
14
|
+
delay,
|
|
15
|
+
mediaTypeToExtension,
|
|
16
|
+
parseProviderOptions,
|
|
17
|
+
postFormDataToApi,
|
|
18
|
+
removeUndefinedEntries,
|
|
19
|
+
resolve,
|
|
20
|
+
} from '@ai-sdk/provider-utils';
|
|
21
|
+
import { z } from 'zod/v4';
|
|
22
|
+
import type { TopazConfig } from './topaz-config';
|
|
23
|
+
import { topazFailedResponseHandler } from './topaz-error';
|
|
24
|
+
import { topazGetFromApi } from './topaz-get-from-api';
|
|
25
|
+
import {
|
|
26
|
+
topazImageModelOptionsSchema,
|
|
27
|
+
type TopazImageModelOptions,
|
|
28
|
+
} from './topaz-image-model-options';
|
|
29
|
+
import {
|
|
30
|
+
resolveTopazImageApiModelId,
|
|
31
|
+
type TopazImageModelId,
|
|
32
|
+
} from './topaz-image-settings';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_POLL_INTERVAL_MILLIS = 2000;
|
|
35
|
+
const DEFAULT_POLL_TIMEOUT_MILLIS = 600_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Topaz enhances an image that the caller supplies, so `files` is required and
|
|
39
|
+
* exactly one image is processed per call.
|
|
40
|
+
*/
|
|
41
|
+
export class TopazImageModel implements ImageModelV3 {
|
|
42
|
+
readonly specificationVersion = 'v3';
|
|
43
|
+
readonly maxImagesPerCall = 1;
|
|
44
|
+
readonly supportsFileInputs = true;
|
|
45
|
+
readonly supportsMaskInputs = false;
|
|
46
|
+
|
|
47
|
+
get provider(): string {
|
|
48
|
+
return this.config.provider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
readonly modelId: TopazImageModelId,
|
|
53
|
+
private readonly config: TopazConfig,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
async doGenerate(
|
|
57
|
+
options: ImageModelV3CallOptions,
|
|
58
|
+
): Promise<Awaited<ReturnType<ImageModelV3['doGenerate']>>> {
|
|
59
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
60
|
+
const warnings: SharedV3Warning[] = [];
|
|
61
|
+
|
|
62
|
+
const topazOptions = await parseProviderOptions({
|
|
63
|
+
provider: 'topaz',
|
|
64
|
+
providerOptions: options.providerOptions,
|
|
65
|
+
schema: topazImageModelOptionsSchema,
|
|
66
|
+
});
|
|
67
|
+
const headers = combineHeaders(
|
|
68
|
+
await resolve(this.config.headers),
|
|
69
|
+
options.headers,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
this.addUnsupportedWarnings(options, warnings);
|
|
73
|
+
|
|
74
|
+
const formData = await this.buildFormData(options, topazOptions, warnings);
|
|
75
|
+
|
|
76
|
+
const { value: submitResponse } = await postFormDataToApi({
|
|
77
|
+
url: `${this.config.baseURL}/image/v1/enhance-gen/async`,
|
|
78
|
+
headers,
|
|
79
|
+
formData,
|
|
80
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
81
|
+
topazImageSubmitResponseSchema,
|
|
82
|
+
),
|
|
83
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
84
|
+
abortSignal: options.abortSignal,
|
|
85
|
+
fetch: this.config.fetch,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const processId = submitResponse.process_id;
|
|
89
|
+
if (processId == null) {
|
|
90
|
+
throw new InvalidResponseDataError({
|
|
91
|
+
data: submitResponse,
|
|
92
|
+
message: 'Topaz did not return a process_id for the enhance request.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let status: TopazImageStatusResponse;
|
|
97
|
+
try {
|
|
98
|
+
status = await this.waitForCompletion({
|
|
99
|
+
processId,
|
|
100
|
+
headers,
|
|
101
|
+
abortSignal: options.abortSignal,
|
|
102
|
+
pollIntervalMillis:
|
|
103
|
+
topazOptions?.pollIntervalMillis ?? DEFAULT_POLL_INTERVAL_MILLIS,
|
|
104
|
+
pollTimeoutMillis:
|
|
105
|
+
topazOptions?.pollTimeoutMillis ?? DEFAULT_POLL_TIMEOUT_MILLIS,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// Topaz charges on completion, so a job nobody will download should not
|
|
109
|
+
// be left running after the caller aborts.
|
|
110
|
+
if (options.abortSignal?.aborted) {
|
|
111
|
+
await this.cancelQuietly(processId, headers);
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { image, responseHeaders } = await this.download({
|
|
117
|
+
processId,
|
|
118
|
+
headers,
|
|
119
|
+
abortSignal: options.abortSignal,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
images: [image],
|
|
124
|
+
warnings,
|
|
125
|
+
response: {
|
|
126
|
+
timestamp: currentDate,
|
|
127
|
+
modelId: this.modelId,
|
|
128
|
+
headers: responseHeaders,
|
|
129
|
+
},
|
|
130
|
+
providerMetadata: {
|
|
131
|
+
topaz: {
|
|
132
|
+
images: [
|
|
133
|
+
{
|
|
134
|
+
processId,
|
|
135
|
+
// Topaz bills exactly the credits reported on the completed job.
|
|
136
|
+
...(status.credits != null ? { credits: status.credits } : {}),
|
|
137
|
+
...(status.output_width != null
|
|
138
|
+
? { width: status.output_width }
|
|
139
|
+
: {}),
|
|
140
|
+
...(status.output_height != null
|
|
141
|
+
? { height: status.output_height }
|
|
142
|
+
: {}),
|
|
143
|
+
...(status.output_format != null
|
|
144
|
+
? { format: status.output_format }
|
|
145
|
+
: {}),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private addUnsupportedWarnings(
|
|
154
|
+
options: ImageModelV3CallOptions,
|
|
155
|
+
warnings: SharedV3Warning[],
|
|
156
|
+
): void {
|
|
157
|
+
if (options.prompt != null) {
|
|
158
|
+
warnings.push({
|
|
159
|
+
type: 'unsupported',
|
|
160
|
+
feature: 'prompt',
|
|
161
|
+
details:
|
|
162
|
+
'Topaz image models enhance an existing image and do not take a text prompt. ' +
|
|
163
|
+
'The prompt was ignored.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (options.aspectRatio != null) {
|
|
168
|
+
warnings.push({
|
|
169
|
+
type: 'unsupported',
|
|
170
|
+
feature: 'aspectRatio',
|
|
171
|
+
details:
|
|
172
|
+
'Topaz image models do not support aspectRatio. Use `size`, or the ' +
|
|
173
|
+
'`outputWidth` / `outputHeight` provider options, to set output dimensions.',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (options.seed != null) {
|
|
178
|
+
warnings.push({
|
|
179
|
+
type: 'unsupported',
|
|
180
|
+
feature: 'seed',
|
|
181
|
+
details: 'Topaz image models do not support seed.',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (options.mask != null) {
|
|
186
|
+
warnings.push({
|
|
187
|
+
type: 'unsupported',
|
|
188
|
+
feature: 'mask',
|
|
189
|
+
details:
|
|
190
|
+
'Topaz image models do not support masks. The mask was ignored.',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options.n > 1) {
|
|
195
|
+
warnings.push({
|
|
196
|
+
type: 'unsupported',
|
|
197
|
+
feature: 'n',
|
|
198
|
+
details:
|
|
199
|
+
'Topaz image models enhance one image per call. Only 1 image will be returned.',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async buildFormData(
|
|
205
|
+
options: ImageModelV3CallOptions,
|
|
206
|
+
topazOptions: TopazImageModelOptions | undefined,
|
|
207
|
+
warnings: SharedV3Warning[],
|
|
208
|
+
): Promise<FormData> {
|
|
209
|
+
const file = options.files?.[0];
|
|
210
|
+
|
|
211
|
+
if (file == null) {
|
|
212
|
+
throw new InvalidArgumentError({
|
|
213
|
+
argument: 'files',
|
|
214
|
+
message:
|
|
215
|
+
'Topaz image models enhance an existing image. Pass the input image via ' +
|
|
216
|
+
'`prompt.images`.',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (options.files != null && options.files.length > 1) {
|
|
221
|
+
warnings.push({
|
|
222
|
+
type: 'unsupported',
|
|
223
|
+
feature: 'files',
|
|
224
|
+
details:
|
|
225
|
+
'Topaz image models enhance one image per call. Only the first file was used.',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const formData = new FormData();
|
|
230
|
+
formData.append('model', resolveTopazImageApiModelId(this.modelId));
|
|
231
|
+
|
|
232
|
+
if (file.type === 'url') {
|
|
233
|
+
// Topaz fetches the source itself when given a URL, so the bytes never
|
|
234
|
+
// need to pass through the SDK.
|
|
235
|
+
formData.append('source_url', file.url);
|
|
236
|
+
} else {
|
|
237
|
+
const bytes =
|
|
238
|
+
typeof file.data === 'string'
|
|
239
|
+
? convertBase64ToUint8Array(file.data)
|
|
240
|
+
: file.data;
|
|
241
|
+
const extension = mediaTypeToExtension(file.mediaType) ?? 'png';
|
|
242
|
+
|
|
243
|
+
formData.append(
|
|
244
|
+
'image',
|
|
245
|
+
new File([bytes as BlobPart], `image.${extension}`, {
|
|
246
|
+
type: file.mediaType,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { width, height } = parseSize(options.size);
|
|
252
|
+
|
|
253
|
+
appendIfDefined(
|
|
254
|
+
formData,
|
|
255
|
+
'output_width',
|
|
256
|
+
topazOptions?.outputWidth ?? width,
|
|
257
|
+
);
|
|
258
|
+
appendIfDefined(
|
|
259
|
+
formData,
|
|
260
|
+
'output_height',
|
|
261
|
+
topazOptions?.outputHeight ?? height,
|
|
262
|
+
);
|
|
263
|
+
appendIfDefined(formData, 'output_format', topazOptions?.outputFormat);
|
|
264
|
+
appendIfDefined(formData, 'crop_to_fill', topazOptions?.cropToFill);
|
|
265
|
+
appendIfDefined(formData, 'webhook_url', topazOptions?.webhookUrl);
|
|
266
|
+
|
|
267
|
+
// Model-specific settings use camelCase, unlike the request schema fields
|
|
268
|
+
// above.
|
|
269
|
+
appendIfDefined(
|
|
270
|
+
formData,
|
|
271
|
+
'enhancementStrength',
|
|
272
|
+
topazOptions?.enhancementStrength,
|
|
273
|
+
);
|
|
274
|
+
appendIfDefined(formData, 'grain', topazOptions?.grain);
|
|
275
|
+
appendIfDefined(formData, 'grainDensity', topazOptions?.grainDensity);
|
|
276
|
+
appendIfDefined(formData, 'grainModel', topazOptions?.grainModel);
|
|
277
|
+
appendIfDefined(formData, 'grainSize', topazOptions?.grainSize);
|
|
278
|
+
appendIfDefined(formData, 'grainStrength', topazOptions?.grainStrength);
|
|
279
|
+
appendIfDefined(formData, 'inputWidth', topazOptions?.inputWidth);
|
|
280
|
+
appendIfDefined(formData, 'inputHeight', topazOptions?.inputHeight);
|
|
281
|
+
|
|
282
|
+
return formData;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async waitForCompletion({
|
|
286
|
+
processId,
|
|
287
|
+
headers,
|
|
288
|
+
abortSignal,
|
|
289
|
+
pollIntervalMillis,
|
|
290
|
+
pollTimeoutMillis,
|
|
291
|
+
}: {
|
|
292
|
+
processId: string;
|
|
293
|
+
headers: Record<string, string | undefined> | undefined;
|
|
294
|
+
abortSignal: AbortSignal | undefined;
|
|
295
|
+
pollIntervalMillis: number;
|
|
296
|
+
pollTimeoutMillis: number;
|
|
297
|
+
}): Promise<TopazImageStatusResponse> {
|
|
298
|
+
const deadline = Date.now() + pollTimeoutMillis;
|
|
299
|
+
|
|
300
|
+
while (true) {
|
|
301
|
+
const { value: status } = await topazGetFromApi({
|
|
302
|
+
url: `${this.config.baseURL}/image/v1/status/${processId}`,
|
|
303
|
+
// Built from the configured baseURL, not from response data.
|
|
304
|
+
validateUrl: false,
|
|
305
|
+
headers,
|
|
306
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
307
|
+
topazImageStatusResponseSchema,
|
|
308
|
+
),
|
|
309
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
310
|
+
abortSignal,
|
|
311
|
+
fetch: this.config.fetch,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (status.status === 'Completed') {
|
|
315
|
+
return status;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (status.status === 'Failed' || status.status === 'Cancelled') {
|
|
319
|
+
throw new AISDKError({
|
|
320
|
+
name: 'TOPAZ_IMAGE_ENHANCEMENT_FAILED',
|
|
321
|
+
message: `Topaz image enhancement ${status.status.toLowerCase()} for process ${processId}.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Date.now() + pollIntervalMillis > deadline) {
|
|
326
|
+
await this.cancelQuietly(processId, headers ?? {});
|
|
327
|
+
throw new AISDKError({
|
|
328
|
+
name: 'TOPAZ_IMAGE_ENHANCEMENT_TIMEOUT',
|
|
329
|
+
message:
|
|
330
|
+
`Topaz image enhancement did not finish within ${pollTimeoutMillis}ms ` +
|
|
331
|
+
`(process ${processId}, last status ${status.status ?? 'unknown'}). ` +
|
|
332
|
+
'Increase the `pollTimeoutMillis` provider option if the job needs longer.',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await delay(pollIntervalMillis, { abortSignal });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async cancelQuietly(
|
|
341
|
+
processId: string,
|
|
342
|
+
headers: Record<string, string | undefined>,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
const fetchImpl = this.config.fetch ?? globalThis.fetch;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Deliberately not tied to the caller's abort signal, which may be the
|
|
348
|
+
// reason polling stopped.
|
|
349
|
+
await fetchImpl(`${this.config.baseURL}/image/v1/cancel/${processId}`, {
|
|
350
|
+
method: 'DELETE',
|
|
351
|
+
headers: removeUndefinedEntries(headers),
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
// Best effort: the original error is more useful to the caller.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async download({
|
|
359
|
+
processId,
|
|
360
|
+
headers,
|
|
361
|
+
abortSignal,
|
|
362
|
+
}: {
|
|
363
|
+
processId: string;
|
|
364
|
+
headers: Record<string, string | undefined> | undefined;
|
|
365
|
+
abortSignal: AbortSignal | undefined;
|
|
366
|
+
}): Promise<{
|
|
367
|
+
image: Uint8Array;
|
|
368
|
+
responseHeaders: Record<string, string> | undefined;
|
|
369
|
+
}> {
|
|
370
|
+
const { value: downloadResponse } = await topazGetFromApi({
|
|
371
|
+
url: `${this.config.baseURL}/image/v1/download/${processId}`,
|
|
372
|
+
// Built from the configured baseURL, not from response data.
|
|
373
|
+
validateUrl: false,
|
|
374
|
+
headers,
|
|
375
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
376
|
+
topazImageDownloadResponseSchema,
|
|
377
|
+
),
|
|
378
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
379
|
+
abortSignal,
|
|
380
|
+
fetch: this.config.fetch,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const downloadUrl = downloadResponse.download_url;
|
|
384
|
+
if (downloadUrl == null) {
|
|
385
|
+
throw new InvalidResponseDataError({
|
|
386
|
+
data: downloadResponse,
|
|
387
|
+
message: `Topaz did not return a download URL for process ${processId}.`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const { value: image, responseHeaders } = await topazGetFromApi({
|
|
392
|
+
url: downloadUrl,
|
|
393
|
+
// Comes from the Topaz response body and normally points at object
|
|
394
|
+
// storage, so it is validated and the API key is withheld off-origin.
|
|
395
|
+
validateUrl: true,
|
|
396
|
+
credentialedOrigin: this.config.baseURL,
|
|
397
|
+
trustedOrigin: this.config.baseURL,
|
|
398
|
+
headers,
|
|
399
|
+
successfulResponseHandler: createBinaryResponseHandler(),
|
|
400
|
+
failedResponseHandler: topazFailedResponseHandler,
|
|
401
|
+
abortSignal,
|
|
402
|
+
fetch: this.config.fetch,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return { image, responseHeaders };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function parseSize(size: `${number}x${number}` | undefined): {
|
|
410
|
+
width: number | undefined;
|
|
411
|
+
height: number | undefined;
|
|
412
|
+
} {
|
|
413
|
+
if (size == null) {
|
|
414
|
+
return { width: undefined, height: undefined };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const [width, height] = size.split('x').map(Number);
|
|
418
|
+
return { width, height };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function appendIfDefined(
|
|
422
|
+
formData: FormData,
|
|
423
|
+
key: string,
|
|
424
|
+
value: string | number | boolean | undefined,
|
|
425
|
+
): void {
|
|
426
|
+
if (value !== undefined) {
|
|
427
|
+
formData.append(key, String(value));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const topazImageSubmitResponseSchema = z.object({
|
|
432
|
+
process_id: z.string().nullish(),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const topazImageStatusResponseSchema = z.object({
|
|
436
|
+
// Documented values are Pending, Processing, Completed, Cancelled and Failed.
|
|
437
|
+
// Unknown values keep polling rather than failing the parse.
|
|
438
|
+
status: z.string().nullish(),
|
|
439
|
+
credits: z.number().nullish(),
|
|
440
|
+
output_width: z.number().nullish(),
|
|
441
|
+
output_height: z.number().nullish(),
|
|
442
|
+
output_format: z.string().nullish(),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
type TopazImageStatusResponse = z.infer<typeof topazImageStatusResponseSchema>;
|
|
446
|
+
|
|
447
|
+
const topazImageDownloadResponseSchema = z.object({
|
|
448
|
+
download_url: z.string().nullish(),
|
|
449
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topaz image model ids.
|
|
3
|
+
*
|
|
4
|
+
* The AI SDK exposes a readable id that is mapped onto the name the Topaz API
|
|
5
|
+
* expects in the `model` form field (see `topazImageApiModelIds`). Passing a
|
|
6
|
+
* raw Topaz name such as `Wonder 3.5` also works - unknown ids are forwarded
|
|
7
|
+
* to the API unchanged.
|
|
8
|
+
*/
|
|
9
|
+
export type TopazImageModelId = 'wonder-3.5' | (string & {});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maps AI SDK image model ids onto Topaz API model names.
|
|
13
|
+
*
|
|
14
|
+
* @see https://developer.topazlabs.com/image-models/wonder/wonder-3.5-new
|
|
15
|
+
*/
|
|
16
|
+
export const topazImageApiModelIds: Record<string, string> = {
|
|
17
|
+
'wonder-3.5': 'Wonder 3.5',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function resolveTopazImageApiModelId(modelId: string): string {
|
|
21
|
+
return topazImageApiModelIds[modelId] ?? modelId;
|
|
22
|
+
}
|