@tanstack/ai-byteplus 0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -0
  3. package/dist/esm/adapters/image.d.ts +89 -0
  4. package/dist/esm/adapters/image.js +229 -0
  5. package/dist/esm/adapters/image.js.map +1 -0
  6. package/dist/esm/adapters/text.d.ts +163 -0
  7. package/dist/esm/adapters/text.js +347 -0
  8. package/dist/esm/adapters/text.js.map +1 -0
  9. package/dist/esm/adapters/transcription.d.ts +102 -0
  10. package/dist/esm/adapters/transcription.js +274 -0
  11. package/dist/esm/adapters/transcription.js.map +1 -0
  12. package/dist/esm/adapters/tts.d.ts +143 -0
  13. package/dist/esm/adapters/tts.js +307 -0
  14. package/dist/esm/adapters/tts.js.map +1 -0
  15. package/dist/esm/adapters/video.d.ts +182 -0
  16. package/dist/esm/adapters/video.js +442 -0
  17. package/dist/esm/adapters/video.js.map +1 -0
  18. package/dist/esm/audio/transcription-provider-options.d.ts +46 -0
  19. package/dist/esm/audio/tts-provider-options.d.ts +114 -0
  20. package/dist/esm/audio/wire-types.d.ts +261 -0
  21. package/dist/esm/audio/wire-types.js +28 -0
  22. package/dist/esm/audio/wire-types.js.map +1 -0
  23. package/dist/esm/image/image-provider-options.d.ts +165 -0
  24. package/dist/esm/image/image-provider-options.js +134 -0
  25. package/dist/esm/image/image-provider-options.js.map +1 -0
  26. package/dist/esm/image/wire-types.d.ts +149 -0
  27. package/dist/esm/index.d.ts +25 -0
  28. package/dist/esm/index.js +11 -0
  29. package/dist/esm/message-types.d.ts +154 -0
  30. package/dist/esm/model-meta.d.ts +594 -0
  31. package/dist/esm/model-meta.js +619 -0
  32. package/dist/esm/model-meta.js.map +1 -0
  33. package/dist/esm/text/text-provider-options.d.ts +109 -0
  34. package/dist/esm/utils/client.d.ts +183 -0
  35. package/dist/esm/utils/client.js +253 -0
  36. package/dist/esm/utils/client.js.map +1 -0
  37. package/dist/esm/video/video-provider-options.d.ts +197 -0
  38. package/dist/esm/video/video-provider-options.js +191 -0
  39. package/dist/esm/video/video-provider-options.js.map +1 -0
  40. package/dist/esm/video/wire-types.d.ts +248 -0
  41. package/package.json +77 -0
  42. package/src/adapters/image.ts +409 -0
  43. package/src/adapters/text.ts +539 -0
  44. package/src/adapters/transcription.ts +479 -0
  45. package/src/adapters/tts.ts +447 -0
  46. package/src/adapters/video.ts +732 -0
  47. package/src/audio/transcription-provider-options.ts +46 -0
  48. package/src/audio/tts-provider-options.ts +122 -0
  49. package/src/audio/wire-types.ts +290 -0
  50. package/src/image/image-provider-options.ts +288 -0
  51. package/src/image/wire-types.ts +169 -0
  52. package/src/index.ts +222 -0
  53. package/src/message-types.ts +169 -0
  54. package/src/model-meta.ts +954 -0
  55. package/src/text/text-provider-options.ts +151 -0
  56. package/src/utils/client.ts +377 -0
  57. package/src/video/video-provider-options.ts +361 -0
  58. package/src/video/wire-types.ts +293 -0
@@ -0,0 +1,732 @@
1
+ import { resolveMediaPrompt } from '@tanstack/ai'
2
+ import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
3
+ import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
4
+ import {
5
+ bytePlusArkError,
6
+ bytePlusArkHeaders,
7
+ bytePlusTimeoutSignal,
8
+ getBytePlusArkApiKeyFromEnv,
9
+ readJsonBody,
10
+ toHeaderRecord,
11
+ withBytePlusArkDefaults,
12
+ } from '../utils/client'
13
+ import {
14
+ getBytePlusVideoDurationOptions,
15
+ isKnownBytePlusVideoModel,
16
+ } from '../model-meta'
17
+ import {
18
+ resolveBytePlusVideoResolution,
19
+ resolveBytePlusVideoSize,
20
+ supportsLastFrame,
21
+ supportsReferenceMedia,
22
+ } from '../video/video-provider-options'
23
+ import type { DurationOptions } from '@tanstack/ai/adapters'
24
+ import type {
25
+ AudioPart,
26
+ ImagePart,
27
+ MediaInputMetadata,
28
+ TokenUsage,
29
+ VideoGenerationOptions,
30
+ VideoJobResult,
31
+ VideoPart,
32
+ VideoStatusResult,
33
+ VideoUrlResult,
34
+ } from '@tanstack/ai'
35
+ import type {
36
+ BytePlusVideoContentPart,
37
+ BytePlusVideoCreateRequest,
38
+ BytePlusVideoCreateResponse,
39
+ BytePlusVideoTask,
40
+ BytePlusVideoTaskStatus,
41
+ BytePlusVideoTaskUsage,
42
+ } from '../video/wire-types'
43
+ import type { BytePlusVideoProviderOptions } from '../video/video-provider-options'
44
+ import type {
45
+ BytePlusVideoModelOrString,
46
+ ResolveBytePlusVideoInputModalities,
47
+ ResolveBytePlusVideoSize,
48
+ } from '../model-meta'
49
+ import type { BytePlusArkConfig } from '../utils/client'
50
+
51
+ /**
52
+ * Configuration for the BytePlus Seedance video adapter.
53
+ *
54
+ * @experimental Video generation is an experimental feature and may change.
55
+ */
56
+ export interface BytePlusVideoConfig extends BytePlusArkConfig {}
57
+
58
+ /** Path of the Seedance task API, relative to the Ark base URL. */
59
+ const TASKS_PATH = '/contents/generations/tasks'
60
+
61
+ /**
62
+ * `content.video_url` and `content.last_frame_url` are deleted 24 hours after
63
+ * the task produces them.
64
+ */
65
+ const VIDEO_URL_TTL_MS = 24 * 60 * 60 * 1000
66
+
67
+ /**
68
+ * Converts a media prompt part into the URL string Seedance's `content[]`
69
+ * takes: public URLs pass through (BytePlus fetches them server-side), data
70
+ * sources become base64 data URIs.
71
+ */
72
+ function mediaPartToUrl(
73
+ part:
74
+ | ImagePart<MediaInputMetadata>
75
+ | VideoPart<MediaInputMetadata>
76
+ | AudioPart<MediaInputMetadata>,
77
+ ): string {
78
+ const { source } = part
79
+ if (source.type === 'url') return source.value
80
+ if (source.value.startsWith('data:')) return source.value
81
+ return `data:${source.mimeType.toLowerCase()};base64,${source.value}`
82
+ }
83
+
84
+ /** Coerces a usage count that the API types as a string but sends as a number. */
85
+ function toTokenCount(value: number | string | undefined): number | undefined {
86
+ if (typeof value === 'number') return value
87
+ if (typeof value === 'string') {
88
+ const parsed = Number(value)
89
+ return Number.isFinite(parsed) ? parsed : undefined
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Maps a finished task's usage onto `TokenUsage`.
96
+ *
97
+ * Seedance bills output only — the API documents input tokens as always 0 and
98
+ * `total_tokens` as equal to `completion_tokens` — so `promptTokens` is 0 and
99
+ * the completion count doubles as `unitsBilled`.
100
+ */
101
+ function buildBytePlusVideoUsage(
102
+ usage: BytePlusVideoTaskUsage | undefined,
103
+ ): TokenUsage | undefined {
104
+ if (!usage) return undefined
105
+
106
+ const completionTokens = toTokenCount(usage.completion_tokens)
107
+ const totalTokens = toTokenCount(usage.total_tokens)
108
+ if (completionTokens === undefined && totalTokens === undefined) {
109
+ return undefined
110
+ }
111
+
112
+ const completion = completionTokens ?? totalTokens ?? 0
113
+ return {
114
+ promptTokens: 0,
115
+ completionTokens: completion,
116
+ totalTokens: totalTokens ?? completion,
117
+ unitsBilled: completion,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Formats a terminal task's error detail for a status / failure message.
123
+ *
124
+ * Always returns a string. Core surfaces a failed job as
125
+ * `throw new Error(statusResult.error || 'Video generation failed')`, so
126
+ * returning `undefined` for a failure Ark reported without an `error` block
127
+ * would hand the caller an unattributable error. The final fallback is a
128
+ * snapshot of the identifying fields instead.
129
+ */
130
+ function describeTaskFailure(task: BytePlusVideoTask): string {
131
+ const { code, message } = task.error ?? {}
132
+ if (code && message) return `${code}: ${message}`
133
+ if (message) return message
134
+ if (code) return code
135
+ // `expired` and `cancelled` are terminal without an `error` block.
136
+ if (task.status === 'expired') {
137
+ return 'Task expired before it finished (execution_expires_after elapsed).'
138
+ }
139
+ if (task.status === 'cancelled') return 'Task was cancelled.'
140
+ return (
141
+ `Task reported status "${task.status ?? 'unknown'}" with no error detail ` +
142
+ `(id=${task.id ?? 'unknown'}, model=${task.model ?? 'unknown'}).`
143
+ )
144
+ }
145
+
146
+ /**
147
+ * BytePlus Seedance video generation adapter.
148
+ *
149
+ * Drives Ark's asynchronous task API — `POST /contents/generations/tasks` to
150
+ * submit, `GET /contents/generations/tasks/{id}` to poll and to read the
151
+ * finished video URL. Core owns the polling loop; this adapter implements the
152
+ * three primitives plus the duration metadata.
153
+ *
154
+ * Prompt parts map onto Seedance's `content[]` roles, which the API sorts into
155
+ * mutually exclusive task types:
156
+ *
157
+ * - `'start_frame'` (or a single un-roled image) → `first_frame` — the frame
158
+ * the video opens on (`i2v`).
159
+ * - `'end_frame'` → `last_frame` — the frame it closes on (`flf2v`); Seedance
160
+ * requires a `first_frame` alongside it, and
161
+ * `seedance-1-0-pro-fast-251015` does not support it at all.
162
+ * - `'reference'` / `'character'` → `reference_image`, video parts →
163
+ * `reference_video`, audio parts → `reference_audio` — subject and style
164
+ * references the model draws on (`r2v`, Seedance 2.0 family only).
165
+ *
166
+ * Frame roles and reference roles cannot be combined in one request, so the
167
+ * adapter rejects a mix up front rather than surfacing a raw 400.
168
+ *
169
+ * @experimental Video generation is an experimental feature and may change.
170
+ *
171
+ * @example
172
+ * ```typescript
173
+ * const adapter = byteplusVideo('seedance-1-0-pro-fast-251015')
174
+ *
175
+ * const { jobId } = await generateVideo({
176
+ * adapter,
177
+ * prompt: 'a guitar being played in a store',
178
+ * size: '16:9_720p',
179
+ * duration: 4,
180
+ * modelOptions: { service_tier: 'flex' },
181
+ * })
182
+ * ```
183
+ */
184
+ export class BytePlusVideoAdapter<
185
+ TModel extends BytePlusVideoModelOrString,
186
+ > extends BaseVideoAdapter<
187
+ TModel,
188
+ BytePlusVideoProviderOptions,
189
+ Record<TModel, BytePlusVideoProviderOptions>,
190
+ Record<TModel, ResolveBytePlusVideoSize<TModel>>,
191
+ Record<TModel, ResolveBytePlusVideoInputModalities<TModel>>,
192
+ Record<TModel, number>
193
+ > {
194
+ readonly name = 'byteplus' as const
195
+
196
+ /** Config with the Ark base URL resolved and its trailing slashes trimmed. */
197
+ private readonly clientConfig: Omit<BytePlusVideoConfig, 'baseURL'> & {
198
+ baseURL: string
199
+ }
200
+
201
+ constructor(config: BytePlusVideoConfig, model: TModel) {
202
+ super({}, model)
203
+ this.clientConfig = withBytePlusArkDefaults(config)
204
+ }
205
+
206
+ private async request(
207
+ path: string,
208
+ init?: Omit<RequestInit, 'headers'>,
209
+ ): Promise<{ response: Response; body: unknown }> {
210
+ const fetchImpl = this.clientConfig.fetch ?? fetch
211
+ const signal = bytePlusTimeoutSignal(this.clientConfig.timeout)
212
+ const response = await fetchImpl(`${this.clientConfig.baseURL}${path}`, {
213
+ ...init,
214
+ ...(signal && { signal }),
215
+ headers: bytePlusArkHeaders(
216
+ this.clientConfig.apiKey,
217
+ toHeaderRecord(this.clientConfig.defaultHeaders),
218
+ ),
219
+ })
220
+ return { response, body: await readJsonBody(response) }
221
+ }
222
+
223
+ /**
224
+ * Builds the `content[]` array from the resolved prompt, enforcing
225
+ * Seedance's role vocabulary and mode exclusivity.
226
+ */
227
+ private buildContent(
228
+ resolved: ReturnType<typeof resolveMediaPrompt>,
229
+ ): Array<BytePlusVideoContentPart> {
230
+ const model = this.model
231
+ const content: Array<BytePlusVideoContentPart> = []
232
+ if (resolved.text) content.push({ type: 'text', text: resolved.text })
233
+
234
+ // Every rule below except the role vocabulary itself is a claim about a
235
+ // *specific* model's capabilities, drawn from probing the six models that
236
+ // exist today. None of it can be true of a model that does not exist yet,
237
+ // so for an unknown id the guards stand down and Ark rules — otherwise the
238
+ // escape hatch would block exactly the requests it exists to enable (see
239
+ // BytePlusVideoModelOrString). 'mask' / 'control' still throw: Seedance's
240
+ // wire format has no field to carry them on any model.
241
+ const gated = isKnownBytePlusVideoModel(model)
242
+
243
+ let firstFrames = 0
244
+ let lastFrames = 0
245
+ // Audio counts as a reference for the mode-exclusivity check but not for
246
+ // the "audio can't be the only reference" rule, which wants a visual.
247
+ let visualReferences = 0
248
+ let audioReferences = 0
249
+
250
+ for (const part of resolved.images) {
251
+ const role = part.metadata?.role
252
+ switch (role) {
253
+ case 'mask':
254
+ case 'control':
255
+ throw new Error(
256
+ `byteplus: Seedance has no '${role}' image input on model ${model}. ` +
257
+ `Use 'start_frame', 'end_frame' or 'reference'.`,
258
+ )
259
+ case 'end_frame': {
260
+ if (gated && !supportsLastFrame(model)) {
261
+ throw new Error(
262
+ `byteplus: ${model} does not support a closing frame — it does ` +
263
+ `text-to-video and first-frame image-to-video only. Drop the ` +
264
+ `'end_frame' image or switch to a model with first-and-last-frame support.`,
265
+ )
266
+ }
267
+ lastFrames++
268
+ content.push({
269
+ type: 'image_url',
270
+ image_url: { url: mediaPartToUrl(part) },
271
+ role: 'last_frame',
272
+ })
273
+ break
274
+ }
275
+ case 'reference':
276
+ case 'character': {
277
+ if (gated && !supportsReferenceMedia(model)) {
278
+ throw new Error(
279
+ `byteplus: ${model} does not support reference images. Reference ` +
280
+ `media is available on the Seedance 2.0 family; on this model use ` +
281
+ `'start_frame' / 'end_frame' images instead.`,
282
+ )
283
+ }
284
+ visualReferences++
285
+ content.push({
286
+ type: 'image_url',
287
+ image_url: { url: mediaPartToUrl(part) },
288
+ role: 'reference_image',
289
+ })
290
+ break
291
+ }
292
+ // An un-roled image is the opening frame, matching the API's own
293
+ // default and the fal / Veo adapters' positional convention.
294
+ case 'start_frame':
295
+ case undefined: {
296
+ firstFrames++
297
+ content.push({
298
+ type: 'image_url',
299
+ image_url: { url: mediaPartToUrl(part) },
300
+ role: 'first_frame',
301
+ })
302
+ break
303
+ }
304
+ }
305
+ }
306
+
307
+ // Video and audio parts only exist in reference mode: Seedance rejects an
308
+ // un-roled video with "reference media mode requires video role to be
309
+ // reference_video", and has no frame-style role for either modality.
310
+ for (const part of resolved.videos) {
311
+ if (gated && !supportsReferenceMedia(model)) {
312
+ throw new Error(
313
+ `byteplus: ${model} does not accept video prompt parts. Reference ` +
314
+ `video is available on the Seedance 2.0 family only.`,
315
+ )
316
+ }
317
+ visualReferences++
318
+ content.push({
319
+ type: 'video_url',
320
+ video_url: { url: mediaPartToUrl(part) },
321
+ role: 'reference_video',
322
+ })
323
+ }
324
+
325
+ for (const part of resolved.audios) {
326
+ if (gated && !supportsReferenceMedia(model)) {
327
+ throw new Error(
328
+ `byteplus: ${model} does not accept audio prompt parts. Reference ` +
329
+ `audio is available on the Seedance 2.0 family only.`,
330
+ )
331
+ }
332
+ audioReferences++
333
+ content.push({
334
+ type: 'audio_url',
335
+ audio_url: { url: mediaPartToUrl(part) },
336
+ role: 'reference_audio',
337
+ })
338
+ }
339
+
340
+ const frames = firstFrames + lastFrames
341
+ if (gated && frames > 0 && visualReferences + audioReferences > 0) {
342
+ throw new Error(
343
+ `byteplus: first/last frame inputs cannot be combined with reference ` +
344
+ `media on model ${model}. Use either frame roles ('start_frame', ` +
345
+ `'end_frame') or reference roles ('reference', 'character', video, ` +
346
+ `audio) — not both.`,
347
+ )
348
+ }
349
+
350
+ if (gated && firstFrames > 1) {
351
+ throw new Error(
352
+ `byteplus: ${model} accepts at most one opening frame; received ` +
353
+ `${firstFrames} un-roled or 'start_frame' images. Use metadata.role ` +
354
+ `('end_frame', 'reference') to disambiguate the others.`,
355
+ )
356
+ }
357
+
358
+ if (gated && lastFrames > 1) {
359
+ throw new Error(
360
+ `byteplus: ${model} accepts at most one closing frame; received ` +
361
+ `${lastFrames} 'end_frame' images.`,
362
+ )
363
+ }
364
+
365
+ // Seedance treats a closing frame as the second half of first-and-last-
366
+ // frame mode: on its own it fails with "last frame image content cannot be
367
+ // mixed with first frame or reference image content".
368
+ if (gated && lastFrames > 0 && firstFrames === 0) {
369
+ throw new Error(
370
+ `byteplus: a closing frame needs an opening frame alongside it on ` +
371
+ `model ${model}. Add a 'start_frame' image, or drop the 'end_frame' role.`,
372
+ )
373
+ }
374
+
375
+ if (gated && audioReferences > 0 && visualReferences === 0) {
376
+ throw new Error(
377
+ `byteplus: a reference audio input cannot be the only reference on ` +
378
+ `model ${model}. Pair it with a reference image or video.`,
379
+ )
380
+ }
381
+
382
+ if (content.length === 0) {
383
+ throw new Error(
384
+ `byteplus: a video prompt must carry text or at least one media input ` +
385
+ `(model: ${model}).`,
386
+ )
387
+ }
388
+
389
+ return content
390
+ }
391
+
392
+ async createVideoJob(
393
+ options: VideoGenerationOptions<
394
+ BytePlusVideoProviderOptions,
395
+ ResolveBytePlusVideoSize<TModel>,
396
+ number
397
+ >,
398
+ ): Promise<VideoJobResult> {
399
+ const { size, modelOptions, logger } = options
400
+ const model = this.model
401
+
402
+ const content = this.buildContent(resolveMediaPrompt(options.prompt))
403
+
404
+ // The generic `size` carries a "ratio_resolution" template and splits back
405
+ // into Seedance's separate fields. Explicit modelOptions win below.
406
+ const parsedSize =
407
+ size !== undefined ? resolveBytePlusVideoSize(model, size) : undefined
408
+
409
+ // Coerce the requested duration into the model's range rather than letting
410
+ // the API reject it. `modelOptions.duration` is deliberately not snapped:
411
+ // it is the escape hatch for `-1` (model picks the length).
412
+ //
413
+ // An unknown model's duration goes through verbatim. Snapping it would
414
+ // mean clamping against the ranges today's models happen to have, so a
415
+ // future model's legitimate 20-second request would silently become 15 —
416
+ // corrupting the request instead of protecting it.
417
+ const duration =
418
+ options.duration !== undefined
419
+ ? isKnownBytePlusVideoModel(model)
420
+ ? this.snapDuration(options.duration)
421
+ : options.duration
422
+ : undefined
423
+
424
+ const request: BytePlusVideoCreateRequest = {
425
+ ...(parsedSize && {
426
+ ratio: parsedSize.ratio,
427
+ ...(parsedSize.resolution !== undefined && {
428
+ resolution: parsedSize.resolution,
429
+ }),
430
+ }),
431
+ ...(duration !== undefined && { duration }),
432
+ // Explicit provider options win over everything derived above.
433
+ ...modelOptions,
434
+ model,
435
+ content,
436
+ }
437
+
438
+ // Validate what actually ships, not just what `size` contributed: a
439
+ // `modelOptions.resolution` overriding an already-checked size would
440
+ // otherwise reach Ark unchecked.
441
+ if (request.resolution !== undefined) {
442
+ request.resolution = resolveBytePlusVideoResolution(
443
+ model,
444
+ request.resolution,
445
+ )
446
+ }
447
+
448
+ try {
449
+ logger.request(
450
+ `activity=video.create provider=${this.name} model=${model} size=${size ?? 'default'} duration=${request.duration ?? 'default'}`,
451
+ { provider: this.name, model },
452
+ )
453
+
454
+ const { response, body } = await this.request(TASKS_PATH, {
455
+ method: 'POST',
456
+ body: JSON.stringify(request),
457
+ })
458
+ if (!response.ok) {
459
+ throw bytePlusArkError(response.status, body, 'video task creation')
460
+ }
461
+
462
+ const { id } = (body ?? {}) as BytePlusVideoCreateResponse
463
+ if (!id) {
464
+ throw new Error('byteplus: video task creation returned no task id.')
465
+ }
466
+
467
+ return { jobId: id, model }
468
+ } catch (error: unknown) {
469
+ logger.errors(`${this.name}.createVideoJob fatal`, {
470
+ error: toRunErrorPayload(error, `${this.name}.createVideoJob failed`),
471
+ source: `${this.name}.createVideoJob`,
472
+ })
473
+ throw error
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Fetches a task, tagging the thrown error with the HTTP status.
479
+ *
480
+ * The 200 body is validated rather than cast. `readJsonBody` returns
481
+ * `undefined` for an empty body and the raw text for a non-JSON one — both
482
+ * documented failure modes of these hosts (an HTML error page from a proxy
483
+ * in front of the API). Casting either to `BytePlusVideoTask` yields a task
484
+ * whose `status` is `undefined`, which {@link mapStatus} would have to
485
+ * interpret; the honest answer is that the response was not a task at all,
486
+ * so say so while the body is still in hand.
487
+ */
488
+ private async retrieveTask(jobId: string): Promise<BytePlusVideoTask> {
489
+ const { response, body } = await this.request(
490
+ `${TASKS_PATH}/${encodeURIComponent(jobId)}`,
491
+ )
492
+ if (!response.ok) {
493
+ const error = bytePlusArkError(response.status, body, 'video task lookup')
494
+ ;(error as { status?: number }).status = response.status
495
+ throw error
496
+ }
497
+ if (typeof body !== 'object' || body === null) {
498
+ throw bytePlusArkError(
499
+ response.status,
500
+ body,
501
+ `video task lookup (job ${jobId}) returned a non-object body`,
502
+ )
503
+ }
504
+ return body as BytePlusVideoTask
505
+ }
506
+
507
+ async getVideoStatus(jobId: string): Promise<VideoStatusResult> {
508
+ let task: BytePlusVideoTask
509
+ try {
510
+ task = await this.retrieveTask(jobId)
511
+ } catch (error) {
512
+ // A task record lives 7 days from creation; past that the id 404s. Keep
513
+ // Ark's own code/message: a 404 from a wrong baseURL, a proxy, or a
514
+ // region mismatch is not an expired job id, and collapsing them all to
515
+ // "Job not found" sends the caller hunting the wrong thing.
516
+ if ((error as { status?: number }).status === 404) {
517
+ return {
518
+ jobId,
519
+ status: 'failed',
520
+ error: `Job not found: ${jobId} (${(error as Error).message})`,
521
+ }
522
+ }
523
+ throw error
524
+ }
525
+
526
+ const status = this.mapStatus(task.status)
527
+ const failure = status === 'failed' ? describeTaskFailure(task) : undefined
528
+ return {
529
+ jobId,
530
+ status,
531
+ ...(failure !== undefined && { error: failure }),
532
+ }
533
+ }
534
+
535
+ async getVideoUrl(jobId: string): Promise<VideoUrlResult> {
536
+ let task: BytePlusVideoTask
537
+ try {
538
+ task = await this.retrieveTask(jobId)
539
+ } catch (error) {
540
+ // See getVideoStatus: Ark's detail distinguishes an expired id from a
541
+ // misrouted request.
542
+ if ((error as { status?: number }).status === 404) {
543
+ throw new Error(
544
+ `Video job not found: ${jobId} (${(error as Error).message})`,
545
+ )
546
+ }
547
+ throw error
548
+ }
549
+
550
+ const status = this.mapStatus(task.status)
551
+ if (status === 'failed') {
552
+ throw new Error(
553
+ `Video generation failed: ${describeTaskFailure(task)}. Job ID: ${jobId}`,
554
+ )
555
+ }
556
+
557
+ const url = task.content?.video_url
558
+ if (!url) {
559
+ throw new Error(
560
+ `Video is not ready for download. Check status first. Job ID: ${jobId}`,
561
+ )
562
+ }
563
+
564
+ // The 24-hour window runs from when the output was produced, which is the
565
+ // last status change on a succeeded task — `created_at` anchors the
566
+ // separate 7-day retention of the task record itself, and can be far
567
+ // earlier (a live `flex` task sat queued ~15 minutes). Corroborated by the
568
+ // signed TOS link itself, which carries `X-Tos-Expires=86400` from an
569
+ // `X-Tos-Date` matching `updated_at`. Fall back to `created_at` only when
570
+ // `updated_at` is missing.
571
+ const anchorSeconds = task.updated_at ?? task.created_at
572
+ const expiresAt =
573
+ anchorSeconds !== undefined
574
+ ? new Date(anchorSeconds * 1000 + VIDEO_URL_TTL_MS)
575
+ : undefined
576
+
577
+ const usage = buildBytePlusVideoUsage(task.usage)
578
+ return {
579
+ jobId,
580
+ url,
581
+ ...(expiresAt && { expiresAt }),
582
+ ...(usage && { usage }),
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Maps Seedance task states onto the generic video status set. `expired`
588
+ * (the task outlived `execution_expires_after`) and `cancelled` are
589
+ * terminal non-successes, so both report as failed.
590
+ *
591
+ * An unrecognized state throws rather than defaulting to `processing`.
592
+ * Core's poll loop treats `processing` as "keep waiting", so mapping an
593
+ * unknown state — a missing `status`, or a terminal one Ark adds later such
594
+ * as `rejected` — onto it means polling until `maxDuration` and then
595
+ * reporting a generic timeout, with the state Ark actually sent never
596
+ * reaching the caller. Failing here names it.
597
+ */
598
+ protected mapStatus(
599
+ apiStatus: BytePlusVideoTaskStatus | string | undefined,
600
+ ): VideoStatusResult['status'] {
601
+ switch (apiStatus) {
602
+ case 'queued':
603
+ return 'pending'
604
+ case 'running':
605
+ return 'processing'
606
+ case 'succeeded':
607
+ return 'completed'
608
+ case 'failed':
609
+ case 'expired':
610
+ case 'cancelled':
611
+ return 'failed'
612
+ case undefined:
613
+ default:
614
+ throw new Error(
615
+ `byteplus: unrecognized Seedance task status ` +
616
+ `${apiStatus === undefined ? '(missing)' : `"${apiStatus}"`}. ` +
617
+ `Known states: queued, running, succeeded, failed, expired, cancelled.`,
618
+ )
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Seedance accepts any whole second inside a per-model range: 4–15s on the
624
+ * 2.0 family, 4–12s on 1.5-pro, 2–12s on the 1.0 models. An unknown model
625
+ * reports the union of those ranges as a UI hint — see
626
+ * `BYTEPLUS_VIDEO_FALLBACK_DURATIONS`, which `createVideoJob` does not snap
627
+ * against.
628
+ */
629
+ override availableDurations(): DurationOptions<number> {
630
+ return getBytePlusVideoDurationOptions(this.model)
631
+ }
632
+
633
+ /**
634
+ * Coerce a raw seconds value to the closest duration this model accepts
635
+ * (clamped to its range and rounded to whole seconds).
636
+ */
637
+ override snapDuration(seconds: number): number | undefined {
638
+ return snapToDurationOption(seconds, this.availableDurations())
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Creates a BytePlus Seedance video adapter with an explicit API key.
644
+ * Type resolution happens here at the call site.
645
+ *
646
+ * @experimental Video generation is an experimental feature and may change.
647
+ *
648
+ * @param model - The model name (e.g., 'seedance-1-0-pro-fast-251015')
649
+ * @param apiKey - Your BytePlus Ark API key
650
+ * @param config - Optional additional configuration
651
+ * @returns Configured BytePlus video adapter instance with resolved types
652
+ *
653
+ * @example
654
+ * ```typescript
655
+ * const adapter = createBytePlusVideo('seedance-1-5-pro-251215', 'ark-...')
656
+ *
657
+ * const { jobId } = await generateVideo({
658
+ * adapter,
659
+ * prompt: 'a guitar being played in a store',
660
+ * size: '16:9_1080p',
661
+ * duration: 5,
662
+ * })
663
+ * ```
664
+ */
665
+ export function createBytePlusVideo<TModel extends BytePlusVideoModelOrString>(
666
+ model: TModel,
667
+ apiKey: string,
668
+ config?: Omit<BytePlusVideoConfig, 'apiKey'>,
669
+ ): BytePlusVideoAdapter<TModel> {
670
+ return new BytePlusVideoAdapter({ apiKey, ...config }, model)
671
+ }
672
+
673
+ /**
674
+ * Creates a BytePlus Seedance video adapter, reading `ARK_API_KEY` from the
675
+ * environment. Type resolution happens here at the call site.
676
+ *
677
+ * Note that Ark keys are region-isolated and Seedance is only served from the
678
+ * Asia-Pacific endpoint — an EU key will not work here.
679
+ *
680
+ * @experimental Video generation is an experimental feature and may change.
681
+ *
682
+ * @param model - The model name (e.g., 'dreamina-seedance-2-0-260128')
683
+ * @param config - Optional configuration (excluding apiKey, auto-detected)
684
+ * @returns Configured BytePlus video adapter instance with resolved types
685
+ * @throws Error if ARK_API_KEY is not found in environment
686
+ *
687
+ * @example
688
+ * ```typescript
689
+ * const adapter = byteplusVideo('dreamina-seedance-2-0-260128')
690
+ *
691
+ * // Image-to-video: an un-roled image is the opening frame.
692
+ * const { jobId } = await generateVideo({
693
+ * adapter,
694
+ * prompt: [
695
+ * { type: 'text', content: 'the guitarist starts playing' },
696
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/shop.jpg' } },
697
+ * ],
698
+ * })
699
+ *
700
+ * const status = await getVideoJobStatus({ adapter, jobId })
701
+ * ```
702
+ *
703
+ * ## Models this package does not know yet
704
+ *
705
+ * `model` also accepts any string, so a Seedance id BytePlus publishes after
706
+ * this release works without upgrading. **Seedance 2.5 is the case this exists
707
+ * for**: `dreamina-seedance-2-5-260628` is real and reachable, but its
708
+ * capability cells could not be probed from this repo's account (Ark answers
709
+ * 404 `ModelNotOpen` until the model is activated in the Ark Console), so it
710
+ * is deliberately absent from the narrowed model tables. Passing it here works
711
+ * for an account that has activated it.
712
+ *
713
+ * An unknown id relaxes both halves of the adapter: the `size` type widens to
714
+ * any string, provider options are ungated, and the runtime guards that encode
715
+ * per-model capabilities — resolution tiers, closing-frame and reference-media
716
+ * support, frame cardinality and mode exclusivity, duration snapping — stand
717
+ * down so Ark decides. Known ids are unaffected. See
718
+ * {@link BytePlusVideoModelOrString} for how to discover and probe an id.
719
+ *
720
+ * @example
721
+ * ```typescript
722
+ * // Seedance 2.5, before this package ships probe-verified metadata for it:
723
+ * const adapter = byteplusVideo('dreamina-seedance-2-5-260628')
724
+ * ```
725
+ */
726
+ export function byteplusVideo<TModel extends BytePlusVideoModelOrString>(
727
+ model: TModel,
728
+ config?: Omit<BytePlusVideoConfig, 'apiKey'>,
729
+ ): BytePlusVideoAdapter<TModel> {
730
+ const apiKey = getBytePlusArkApiKeyFromEnv()
731
+ return createBytePlusVideo(model, apiKey, config)
732
+ }