pi-media-models 0.1.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/src/input.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, extname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { MediaError } from './errors.js'
5
+ import { HttpClient } from './http.js'
6
+ import type { ResolvedInput } from './types.js'
7
+
8
+ const MIME_BY_EXTENSION: Record<string, string> = {
9
+ '.aac': 'audio/aac', '.flac': 'audio/flac', '.gif': 'image/gif', '.jpeg': 'image/jpeg',
10
+ '.jpg': 'image/jpeg', '.m4a': 'audio/mp4', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime',
11
+ '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mpeg': 'video/mpeg', '.oga': 'audio/ogg',
12
+ '.ogg': 'audio/ogg', '.png': 'image/png', '.wav': 'audio/wav', '.webm': 'video/webm',
13
+ '.webp': 'image/webp',
14
+ }
15
+
16
+ export function mimeFromName(name: string, fallback = 'application/octet-stream'): string {
17
+ const clean = name.split(/[?#]/, 1)[0] ?? name
18
+ return MIME_BY_EXTENSION[extname(clean).toLowerCase()] ?? fallback
19
+ }
20
+
21
+ function extensionForMime(mime: string): string {
22
+ return Object.entries(MIME_BY_EXTENSION).find(([, value]) => value === mime)?.[0] ?? '.bin'
23
+ }
24
+
25
+ function decodeDataUri(input: string): { bytes: Uint8Array; mimeType: string } {
26
+ const match = /^data:([^;,]+)?((?:;[^,]*)*?),(.*)$/s.exec(input)
27
+ if (!match) throw new MediaError('INPUT', 'Invalid data URI')
28
+ const mimeType = match[1] || 'application/octet-stream'
29
+ const metadata = match[2] ?? ''
30
+ const payload = match[3] ?? ''
31
+ try {
32
+ return {
33
+ mimeType,
34
+ bytes: metadata.includes(';base64')
35
+ ? Buffer.from(payload, 'base64')
36
+ : Buffer.from(decodeURIComponent(payload), 'utf8'),
37
+ }
38
+ } catch (error) {
39
+ throw new MediaError('INPUT', 'Invalid data URI payload', { cause: error })
40
+ }
41
+ }
42
+
43
+ export class InputResolver {
44
+ constructor(private readonly http: HttpClient, private readonly cwd = process.cwd()) {}
45
+
46
+ async resolve(input: string): Promise<ResolvedInput> {
47
+ const value = input.startsWith('@') ? input.slice(1) : input
48
+ if (/^https?:\/\//i.test(value)) {
49
+ const parsed = new URL(value)
50
+ const fileName = basename(parsed.pathname) || 'remote.bin'
51
+ return { original: input, kind: 'url', url: value, mimeType: mimeFromName(fileName), fileName }
52
+ }
53
+ if (value.startsWith('data:')) {
54
+ const decoded = decodeDataUri(value)
55
+ return {
56
+ original: input,
57
+ kind: 'data',
58
+ bytes: decoded.bytes,
59
+ mimeType: decoded.mimeType,
60
+ fileName: `inline${extensionForMime(decoded.mimeType)}`,
61
+ }
62
+ }
63
+
64
+ const path = value.startsWith('file://') ? fileURLToPath(value) : resolve(this.cwd, value)
65
+ try {
66
+ const bytes = await readFile(path)
67
+ return {
68
+ original: input,
69
+ kind: 'file',
70
+ bytes,
71
+ mimeType: mimeFromName(path),
72
+ fileName: basename(path),
73
+ }
74
+ } catch (error) {
75
+ throw new MediaError('INPUT', `Cannot read media input: ${path}`, { cause: error })
76
+ }
77
+ }
78
+
79
+ async bytes(input: ResolvedInput, signal?: AbortSignal): Promise<Uint8Array> {
80
+ if (input.bytes) return input.bytes
81
+ if (!input.url) throw new MediaError('INPUT', `Input has no readable data: ${input.original}`)
82
+ const response = await this.http.request(input.url, { signal, timeoutMs: 60_000, retries: 1 })
83
+ return new Uint8Array(await response.arrayBuffer())
84
+ }
85
+
86
+ async asDataUri(source: string, signal?: AbortSignal): Promise<string> {
87
+ const input = await this.resolve(source)
88
+ if (input.kind === 'url') return input.url ?? source
89
+ const bytes = await this.bytes(input, signal)
90
+ return `data:${input.mimeType};base64,${Buffer.from(bytes).toString('base64')}`
91
+ }
92
+
93
+ async asInlineData(source: string, signal?: AbortSignal): Promise<{ mimeType: string; data: string }> {
94
+ const input = await this.resolve(source)
95
+ const bytes = await this.bytes(input, signal)
96
+ return { mimeType: input.mimeType, data: Buffer.from(bytes).toString('base64') }
97
+ }
98
+
99
+ async asBlob(source: string, signal?: AbortSignal): Promise<{ blob: Blob; fileName: string; mimeType: string }> {
100
+ const input = await this.resolve(source)
101
+ const bytes = await this.bytes(input, signal)
102
+ return { blob: new Blob([Uint8Array.from(bytes)], { type: input.mimeType }), fileName: input.fileName, mimeType: input.mimeType }
103
+ }
104
+ }
@@ -0,0 +1,89 @@
1
+ import { MediaError } from './errors.js'
2
+ import type { JobStatus, MediaJobOptions } from './types.js'
3
+
4
+ function wait(ms: number, signal: AbortSignal): Promise<void> {
5
+ return new Promise((resolve, reject) => {
6
+ const done = () => {
7
+ signal.removeEventListener('abort', onAbort)
8
+ resolve()
9
+ }
10
+ const timer = setTimeout(done, ms)
11
+ const onAbort = () => {
12
+ clearTimeout(timer)
13
+ signal.removeEventListener('abort', onAbort)
14
+ reject(new DOMException('Aborted', 'AbortError'))
15
+ }
16
+ if (signal.aborted) return onAbort()
17
+ signal.addEventListener('abort', onAbort, { once: true })
18
+ })
19
+ }
20
+
21
+ export class MediaJob<T> {
22
+ constructor(private readonly options: MediaJobOptions<T>) {}
23
+
24
+ async wait(): Promise<T> {
25
+ const {
26
+ timeoutMs = 20 * 60_000,
27
+ minDelayMs = 1_000,
28
+ maxDelayMs = 10_000,
29
+ provider,
30
+ } = this.options
31
+ const timeoutController = new AbortController()
32
+ const timer = setTimeout(() => timeoutController.abort(new DOMException('Job timed out', 'TimeoutError')), timeoutMs)
33
+ const signal = this.options.signal
34
+ ? AbortSignal.any([this.options.signal, timeoutController.signal])
35
+ : timeoutController.signal
36
+ let delay = minDelayMs
37
+
38
+ try {
39
+ while (true) {
40
+ const status = await this.options.poll(signal)
41
+ this.options.onProgress?.(status)
42
+ if (status.state === 'succeeded') {
43
+ if (status.result === undefined) throw new MediaError('PROVIDER', `${provider} job completed without a result`, { provider })
44
+ return status.result
45
+ }
46
+ if (status.state === 'failed' || status.state === 'cancelled') {
47
+ throw new MediaError('PROVIDER', status.message ?? `${provider} job ${status.state}`, { provider })
48
+ }
49
+ const sleepFor = status.retryAfterMs ?? delay
50
+ await wait(sleepFor, signal)
51
+ delay = Math.min(maxDelayMs, Math.round(delay * 1.7))
52
+ }
53
+ } catch (error) {
54
+ if (this.options.signal?.aborted) {
55
+ await this.cancelBestEffort()
56
+ throw new MediaError('ABORTED', `${provider} job ${this.options.id} aborted`, { provider, cause: error })
57
+ }
58
+ if (timeoutController.signal.aborted) {
59
+ await this.cancelBestEffort()
60
+ throw new MediaError('TIMEOUT', `${provider} job ${this.options.id} timed out after ${timeoutMs}ms`, { provider, cause: error })
61
+ }
62
+ throw error
63
+ } finally {
64
+ clearTimeout(timer)
65
+ }
66
+ }
67
+
68
+ private async cancelBestEffort(): Promise<void> {
69
+ if (!this.options.cancel) return
70
+ const controller = new AbortController()
71
+ const timer = setTimeout(() => controller.abort(), 5_000)
72
+ try {
73
+ await this.options.cancel(controller.signal)
74
+ } catch {
75
+ // Cancellation is advisory; preserve the original abort/timeout error.
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
80
+ }
81
+
82
+ export function mapJobState(raw: unknown): JobStatus<never>['state'] {
83
+ const value = String(raw ?? '').toLowerCase().replace(/[\s-]+/g, '_')
84
+ if (['completed', 'complete', 'succeeded', 'success', 'done', 'ready'].includes(value)) return 'succeeded'
85
+ if (['failed', 'failure', 'error'].includes(value)) return 'failed'
86
+ if (['cancelled', 'canceled'].includes(value)) return 'cancelled'
87
+ if (['queued', 'pending', 'submitted', 'created'].includes(value)) return 'queued'
88
+ return 'running'
89
+ }
package/src/router.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { ArtifactDownloader } from './artifacts.js'
2
+ import type { MediaConfig } from './config.js'
3
+ import { asMediaError, MediaError } from './errors.js'
4
+ import { HttpClient, type FetchLike } from './http.js'
5
+ import { InputResolver } from './input.js'
6
+ import type { AdapterContext, Capability, MediaRequest, ModelDescriptor, NormalizedResult, ProviderAdapter } from './types.js'
7
+ import { AtlasAdapter } from './adapters/atlas.js'
8
+ import { CustomOpenAICompatibleAdapter } from './adapters/custom.js'
9
+ import { DashScopeAdapter } from './adapters/dashscope.js'
10
+ import { FalAdapter } from './adapters/fal.js'
11
+ import { GoogleMediaAdapter } from './adapters/google.js'
12
+ import { OpenAIAdapter } from './adapters/openai.js'
13
+ import { OpenRouterAdapter } from './adapters/openrouter.js'
14
+ import { XAIAdapter } from './adapters/xai.js'
15
+
16
+ export interface RouterOptions {
17
+ cwd: string
18
+ config: MediaConfig
19
+ env?: NodeJS.ProcessEnv
20
+ fetch?: FetchLike
21
+ }
22
+
23
+ export class CapabilityRouter {
24
+ private readonly adapters = new Map<string, ProviderAdapter>()
25
+ private readonly downloader: ArtifactDownloader
26
+ private readonly env: NodeJS.ProcessEnv
27
+ private readonly providerDefaults: Record<string, Record<string, unknown>>
28
+
29
+ constructor(options: RouterOptions) {
30
+ const http = new HttpClient(options.fetch)
31
+ const input = new InputResolver(http, options.cwd)
32
+ const dependencies = { http, input, ...(options.env ? { env: options.env } : {}) }
33
+ this.env = options.env ?? process.env
34
+ this.providerDefaults = options.config.providerOptions ?? {}
35
+ const builtins: ProviderAdapter[] = [
36
+ new OpenRouterAdapter(dependencies),
37
+ new FalAdapter(dependencies),
38
+ new DashScopeAdapter('dashscope', 'https://dashscope.aliyuncs.com', dependencies),
39
+ new DashScopeAdapter('qwencloud', 'https://dashscope-intl.aliyuncs.com', dependencies),
40
+ new OpenAIAdapter(dependencies),
41
+ new GoogleMediaAdapter('gemini', dependencies),
42
+ new GoogleMediaAdapter('vertex', dependencies),
43
+ new XAIAdapter(dependencies),
44
+ new AtlasAdapter(dependencies),
45
+ ...options.config.customProviders.map(provider => new CustomOpenAICompatibleAdapter(provider, dependencies)),
46
+ ]
47
+ for (const adapter of builtins) this.adapters.set(adapter.id, adapter)
48
+ this.downloader = new ArtifactDownloader(http, options.config.outputDir)
49
+ }
50
+
51
+ private isConfigured(adapter: ProviderAdapter): boolean {
52
+ if (!adapter.envKey) return true
53
+ if (this.env[adapter.envKey]) return true
54
+ if (this.providerDefaults[adapter.id]?.apiKey) return true
55
+ return false
56
+ }
57
+
58
+ list(provider?: string, capability?: Capability): Array<ModelDescriptor & { configured: boolean }> {
59
+ const adapters = provider ? [this.adapters.get(provider)].filter((item): item is ProviderAdapter => Boolean(item)) : [...this.adapters.values()]
60
+ return adapters.flatMap(adapter => adapter.models()
61
+ .filter(model => !capability || model.capabilities.includes(capability))
62
+ .map(model => ({ ...model, configured: this.isConfigured(adapter) })))
63
+ }
64
+
65
+ providers(): Array<{ id: string; name: string; configured: boolean; envKey?: string }> {
66
+ return [...this.adapters.values()].map(adapter => ({
67
+ id: adapter.id,
68
+ name: adapter.displayName,
69
+ configured: this.isConfigured(adapter),
70
+ ...(adapter.envKey ? { envKey: adapter.envKey } : {}),
71
+ }))
72
+ }
73
+
74
+ async execute(request: MediaRequest, context: AdapterContext = {}): Promise<NormalizedResult> {
75
+ const adapter = this.adapters.get(request.provider)
76
+ if (!adapter) throw new MediaError('CONFIG', `Unknown media provider: ${request.provider}`)
77
+ const providerOptions = { ...(this.providerDefaults[request.provider] ?? {}), ...(request.providerOptions ?? {}) }
78
+ const resolvedRequest: MediaRequest = { ...request, providerOptions }
79
+ try {
80
+ const result = await adapter.execute(resolvedRequest, context)
81
+ const artifacts = await this.downloader.downloadAll(result.artifacts, context.signal)
82
+ return {
83
+ provider: result.provider,
84
+ model: result.model,
85
+ capability: result.capability,
86
+ artifacts,
87
+ warnings: result.warnings ?? [],
88
+ ...(result.jobId ? { jobId: result.jobId } : {}),
89
+ ...(result.text ? { text: result.text } : {}),
90
+ }
91
+ } catch (error) {
92
+ throw asMediaError(error, adapter.id)
93
+ }
94
+ }
95
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,205 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+ import { Type } from 'typebox'
3
+ import { loadMediaConfig } from './config.js'
4
+ import { MediaError } from './errors.js'
5
+ import { CapabilityRouter } from './router.js'
6
+ import type { Capability, JsonObject, MediaRequest, NormalizedResult } from './types.js'
7
+
8
+ const providerModel = {
9
+ provider: Type.String({ description: 'Provider id: openrouter, fal, dashscope, qwencloud, openai, gemini, vertex, xai, atlas, or an explicitly configured custom provider' }),
10
+ model: Type.String({ description: 'Exact provider model id or fal endpoint slug' }),
11
+ }
12
+ const providerOptions = Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: 'Provider-native options. These override normalized mappings; never include API keys.' }))
13
+ const commonOutput = {
14
+ resolution: Type.Optional(Type.String()),
15
+ aspectRatio: Type.Optional(Type.String()),
16
+ seed: Type.Optional(Type.Integer()),
17
+ providerOptions,
18
+ }
19
+
20
+ async function routerFor(ctx: ExtensionContext): Promise<CapabilityRouter> {
21
+ const config = await loadMediaConfig(ctx.cwd, ctx.isProjectTrusted())
22
+ return new CapabilityRouter({ cwd: ctx.cwd, config })
23
+ }
24
+
25
+ function progress(onUpdate: ((result: { content: Array<{ type: 'text'; text: string }>; details?: unknown }) => void) | undefined, message: string): void {
26
+ onUpdate?.({ content: [{ type: 'text', text: message }] })
27
+ }
28
+
29
+ function concise(result: NormalizedResult): string {
30
+ const lines = [`${result.provider}/${result.model} · ${result.capability}`]
31
+ if (result.artifacts.length) lines.push(...result.artifacts.map(artifact => `Saved ${artifact.kind}: ${artifact.path}`))
32
+ if (result.text) lines.push(result.text)
33
+ if (result.jobId) lines.push(`Job: ${result.jobId}`)
34
+ if (result.warnings.length) lines.push(`Warnings: ${result.warnings.join('; ')}`)
35
+ return lines.join('\n')
36
+ }
37
+
38
+ async function executeRequest(request: MediaRequest, signal: AbortSignal | undefined, onUpdate: Parameters<Parameters<ExtensionAPI['registerTool']>[0]['execute']>[3], ctx: ExtensionContext) {
39
+ const router = await routerFor(ctx)
40
+ progress(onUpdate as never, `Starting ${request.capability} with ${request.provider}/${request.model}…`)
41
+ const result = await router.execute(request, {
42
+ ...(signal ? { signal } : {}),
43
+ onProgress: message => progress(onUpdate as never, message),
44
+ })
45
+ return { content: [{ type: 'text' as const, text: concise(result) }], details: result }
46
+ }
47
+
48
+ export function registerMediaTools(pi: ExtensionAPI): void {
49
+ pi.registerTool({
50
+ name: 'media_models',
51
+ label: 'Media Models',
52
+ description: 'List media providers and explicitly known/configured model capabilities. Does not infer custom capabilities from GET /models.',
53
+ promptSnippet: 'List available media providers/models/capabilities before choosing a model',
54
+ parameters: Type.Object({
55
+ provider: Type.Optional(Type.String()),
56
+ capability: Type.Optional(Type.String()),
57
+ }),
58
+ async execute(_id, params, _signal, _onUpdate, ctx) {
59
+ const router = await routerFor(ctx)
60
+ const capability = params.capability as Capability | undefined
61
+ const models = router.list(params.provider, capability)
62
+ const providers = router.providers()
63
+ const text = models.length
64
+ ? models.map(model => `${model.provider}/${model.id} [vendor=${model.vendor}; configured=${model.configured}] ${model.capabilities.join(', ')}`).join('\n')
65
+ : 'No matching declared media models.'
66
+ return { content: [{ type: 'text', text }], details: { providers, models } }
67
+ },
68
+ })
69
+
70
+ pi.registerTool({
71
+ name: 'image_generate',
72
+ label: 'Generate Image',
73
+ description: 'Generate an image from text or reference image(s). Inputs accept local paths, file://, http(s) URLs, and data URIs. Results are downloaded automatically.',
74
+ promptSnippet: 'Generate images through the provider-neutral media router',
75
+ parameters: Type.Object({
76
+ ...providerModel,
77
+ prompt: Type.String(),
78
+ inputImage: Type.Optional(Type.String()),
79
+ referenceImages: Type.Optional(Type.Array(Type.String())),
80
+ count: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
81
+ ...commonOutput,
82
+ }),
83
+ async execute(_id, params, signal, onUpdate, ctx) {
84
+ const references = params.referenceImages ?? []
85
+ const capability: Capability = references.length + (params.inputImage ? 1 : 0) > 1
86
+ ? 'image.multi_reference'
87
+ : params.inputImage || references.length ? 'image.image_to_image' : 'image.text_to_image'
88
+ return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
89
+ },
90
+ })
91
+
92
+ pi.registerTool({
93
+ name: 'image_edit',
94
+ label: 'Edit Image',
95
+ description: 'Edit one or more images with an optional mask. Inputs accept local paths, file://, http(s) URLs, and data URIs. Results are downloaded automatically.',
96
+ promptSnippet: 'Edit images through the provider-neutral media router',
97
+ parameters: Type.Object({
98
+ ...providerModel,
99
+ prompt: Type.String(),
100
+ inputImage: Type.String(),
101
+ referenceImages: Type.Optional(Type.Array(Type.String())),
102
+ mask: Type.Optional(Type.String()),
103
+ count: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
104
+ ...commonOutput,
105
+ }),
106
+ async execute(_id, params, signal, onUpdate, ctx) {
107
+ const capability: Capability = params.referenceImages?.length ? 'image.multi_reference' : 'image.edit'
108
+ return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
109
+ },
110
+ })
111
+
112
+ pi.registerTool({
113
+ name: 'video_generate',
114
+ label: 'Generate Video',
115
+ description: 'Unified video generation/edit/extension tool. Automatically maps text, first/end frames, references, and input video to the provider capability; downloads temporary results immediately.',
116
+ promptSnippet: 'Generate, edit, or extend video through the provider-neutral media router',
117
+ parameters: Type.Object({
118
+ ...providerModel,
119
+ prompt: Type.String(),
120
+ inputImage: Type.Optional(Type.String()),
121
+ endImage: Type.Optional(Type.String()),
122
+ referenceImages: Type.Optional(Type.Array(Type.String())),
123
+ referenceVideos: Type.Optional(Type.Array(Type.String())),
124
+ referenceAudios: Type.Optional(Type.Array(Type.String())),
125
+ inputVideo: Type.Optional(Type.String()),
126
+ duration: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
127
+ resolution: Type.Optional(Type.String()),
128
+ aspectRatio: Type.Optional(Type.String()),
129
+ seed: Type.Optional(Type.Integer()),
130
+ generateAudio: Type.Optional(Type.Boolean()),
131
+ operation: Type.Optional(Type.String({ description: 'generate, reference, edit, or extend; omitted for automatic mapping' })),
132
+ providerOptions,
133
+ }),
134
+ async execute(_id, params, signal, onUpdate, ctx) {
135
+ const capability = videoCapability(params)
136
+ return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
137
+ },
138
+ })
139
+
140
+ pi.registerTool({
141
+ name: 'audio_generate',
142
+ label: 'Generate Audio',
143
+ description: 'Generate music or model-native audio from a prompt. This is separate from TTS and downloads the result automatically.',
144
+ promptSnippet: 'Generate music or audio through the provider-neutral media router',
145
+ parameters: Type.Object({
146
+ ...providerModel,
147
+ prompt: Type.String(),
148
+ text: Type.Optional(Type.String({ description: 'Optional lyrics or secondary text input' })),
149
+ inputAudio: Type.Optional(Type.String()),
150
+ duration: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
151
+ seed: Type.Optional(Type.Integer()),
152
+ providerOptions,
153
+ }),
154
+ async execute(_id, params, signal, onUpdate, ctx) {
155
+ return executeRequest({ ...params, capability: 'audio.generate', providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
156
+ },
157
+ })
158
+
159
+ pi.registerTool({
160
+ name: 'speech_generate',
161
+ label: 'Speech',
162
+ description: 'Run provider-supported TTS or STT. Use operation="tts" with text, or operation="stt" with inputAudio. Generated audio is downloaded; STT returns concise text.',
163
+ promptSnippet: 'Synthesize speech or transcribe audio through the provider-neutral media router',
164
+ parameters: Type.Object({
165
+ ...providerModel,
166
+ operation: Type.String({ description: 'tts or stt' }),
167
+ text: Type.Optional(Type.String()),
168
+ inputAudio: Type.Optional(Type.String()),
169
+ prompt: Type.Optional(Type.String()),
170
+ voice: Type.Optional(Type.String()),
171
+ language: Type.Optional(Type.String()),
172
+ responseFormat: Type.Optional(Type.String()),
173
+ providerOptions,
174
+ }),
175
+ async execute(_id, params, signal, onUpdate, ctx) {
176
+ const operation = params.operation.toLowerCase()
177
+ if (!['tts', 'stt', 'transcribe'].includes(operation)) throw new MediaError('INPUT', 'speech_generate operation must be tts or stt')
178
+ const capability: Capability = operation === 'tts' ? 'speech.tts' : 'speech.stt'
179
+ if (capability === 'speech.tts' && !params.text && !params.prompt) throw new MediaError('INPUT', 'TTS requires text')
180
+ if (capability === 'speech.stt' && !params.inputAudio) throw new MediaError('INPUT', 'STT requires inputAudio')
181
+ return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
182
+ },
183
+ })
184
+ }
185
+
186
+ function videoCapability(params: {
187
+ operation?: string
188
+ inputImage?: string
189
+ endImage?: string
190
+ inputVideo?: string
191
+ referenceImages?: string[]
192
+ referenceVideos?: string[]
193
+ referenceAudios?: string[]
194
+ }): Capability {
195
+ const operation = params.operation?.toLowerCase()
196
+ if (operation === 'edit') return 'video.edit'
197
+ if (operation === 'extend') return 'video.extend'
198
+ if (operation === 'reference') return 'video.reference'
199
+ if (operation && operation !== 'generate') throw new MediaError('INPUT', 'video_generate operation must be generate, reference, edit, or extend')
200
+ if (params.inputVideo) return 'video.edit'
201
+ if (params.referenceImages?.length || params.referenceVideos?.length || params.referenceAudios?.length) return 'video.reference'
202
+ if (params.endImage) return 'video.first_last_frame'
203
+ if (params.inputImage) return 'video.image_to_video'
204
+ return 'video.text_to_video'
205
+ }
package/src/types.ts ADDED
@@ -0,0 +1,146 @@
1
+ export const CAPABILITIES = [
2
+ 'image.text_to_image',
3
+ 'image.image_to_image',
4
+ 'image.edit',
5
+ 'image.multi_reference',
6
+ 'video.text_to_video',
7
+ 'video.image_to_video',
8
+ 'video.first_last_frame',
9
+ 'video.reference',
10
+ 'video.edit',
11
+ 'video.extend',
12
+ 'video.native_audio',
13
+ 'audio.generate',
14
+ 'speech.tts',
15
+ 'speech.stt',
16
+ ] as const
17
+
18
+ export type Capability = (typeof CAPABILITIES)[number]
19
+ export type ProviderId =
20
+ | 'openrouter'
21
+ | 'fal'
22
+ | 'dashscope'
23
+ | 'qwencloud'
24
+ | 'openai'
25
+ | 'gemini'
26
+ | 'vertex'
27
+ | 'xai'
28
+ | 'atlas'
29
+ | string
30
+
31
+ export type MediaKind = 'image' | 'video' | 'audio' | 'text'
32
+ export type InputSource = string
33
+ export type JsonObject = Record<string, unknown>
34
+
35
+ export interface MediaRequest {
36
+ capability: Capability
37
+ provider: ProviderId
38
+ model: string
39
+ prompt?: string
40
+ inputImage?: InputSource
41
+ endImage?: InputSource
42
+ referenceImages?: InputSource[]
43
+ referenceVideos?: InputSource[]
44
+ referenceAudios?: InputSource[]
45
+ inputVideo?: InputSource
46
+ inputAudio?: InputSource
47
+ mask?: InputSource
48
+ duration?: number
49
+ resolution?: string
50
+ aspectRatio?: string
51
+ seed?: number
52
+ count?: number
53
+ generateAudio?: boolean
54
+ operation?: string
55
+ text?: string
56
+ voice?: string
57
+ language?: string
58
+ responseFormat?: string
59
+ providerOptions?: JsonObject
60
+ }
61
+
62
+ export interface ResolvedInput {
63
+ original: string
64
+ kind: 'url' | 'data' | 'file'
65
+ mimeType: string
66
+ fileName: string
67
+ bytes?: Uint8Array
68
+ url?: string
69
+ }
70
+
71
+ export interface RemoteArtifact {
72
+ kind: MediaKind
73
+ url?: string
74
+ base64?: string
75
+ mimeType?: string
76
+ fileName?: string
77
+ headers?: Record<string, string>
78
+ }
79
+
80
+ export interface NormalizedArtifact {
81
+ kind: MediaKind
82
+ path: string
83
+ mimeType?: string
84
+ bytes?: number
85
+ }
86
+
87
+ export interface AdapterResult {
88
+ provider: string
89
+ model: string
90
+ capability: Capability
91
+ jobId?: string
92
+ artifacts: RemoteArtifact[]
93
+ text?: string
94
+ warnings?: string[]
95
+ }
96
+
97
+ export interface NormalizedResult {
98
+ provider: string
99
+ model: string
100
+ capability: Capability
101
+ jobId?: string
102
+ artifacts: NormalizedArtifact[]
103
+ text?: string
104
+ warnings: string[]
105
+ }
106
+
107
+ export interface JobStatus<T> {
108
+ state: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
109
+ result?: T
110
+ message?: string
111
+ retryAfterMs?: number
112
+ }
113
+
114
+ export interface MediaJobOptions<T> {
115
+ id: string
116
+ provider: string
117
+ poll: (signal: AbortSignal) => Promise<JobStatus<T>>
118
+ cancel?: (signal: AbortSignal) => Promise<void>
119
+ timeoutMs?: number
120
+ minDelayMs?: number
121
+ maxDelayMs?: number
122
+ signal?: AbortSignal
123
+ onProgress?: (status: JobStatus<T>) => void
124
+ }
125
+
126
+ export interface ModelDescriptor {
127
+ provider: string
128
+ vendor: string
129
+ id: string
130
+ capabilities: Capability[]
131
+ notes?: string
132
+ }
133
+
134
+ export interface AdapterContext {
135
+ signal?: AbortSignal
136
+ onProgress?: (message: string) => void
137
+ }
138
+
139
+ export interface ProviderAdapter {
140
+ readonly id: string
141
+ readonly displayName: string
142
+ readonly envKey?: string
143
+ models(): ModelDescriptor[]
144
+ supports(capability: Capability, model: string): boolean
145
+ execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult>
146
+ }