diff --git a/src/ai-sdk/providers/piapi.ts b/src/ai-sdk/providers/piapi.ts new file mode 100644 index 00000000..b043aefa --- /dev/null +++ b/src/ai-sdk/providers/piapi.ts @@ -0,0 +1,324 @@ +/** + * PiAPI AI SDK Provider (Vercel AI SDK v3 compatible) + * + * Provides Seedance 2 video generation via PiAPI's async task API. + * Supports text-to-video, image-to-video, and video editing. + * + * Models: + * - seedance-2-preview: High quality, $0.25/s, auto watermark removal + * - seedance-2-fast-preview: Fast, $0.15/s, no watermark removal + */ + +import type { + EmbeddingModelV3, + ImageModelV3, + ImageModelV3File, + LanguageModelV3, + NoSuchModelError as NoSuchModelErrorType, + ProviderV3, + SharedV3Warning, +} from "@ai-sdk/provider"; +import { NoSuchModelError } from "@ai-sdk/provider"; +import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PIAPI_BASE_URL = "https://api.piapi.ai"; +const POLL_INTERVAL_MS = 10_000; // 10s between polls +const POLL_MAX_ATTEMPTS = 360; // 1 hour max + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PiAPIProviderSettings { + apiKey?: string; + baseUrl?: string; +} + +export interface PiAPIProvider extends ProviderV3 { + videoModel(modelId: string): VideoModelV3; +} + +/** PiAPI task response shape. */ +interface PiAPITaskData { + task_id: string; + model: string; + task_type: string; + status: string; + input: Record; + output: { video?: string } | null; + error: { code: number; message: string; raw_message?: string }; +} + +interface PiAPIResponse { + code: number; + data: PiAPITaskData; + message: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +class PiAPIError extends Error { + constructor( + message: string, + public statusCode?: number, + ) { + super(message); + this.name = "PiAPIError"; + } +} + +function resolveConfig(settings: PiAPIProviderSettings = {}) { + const apiKey = settings.apiKey ?? process.env.PIAPI_API_KEY ?? ""; + const baseUrl = settings.baseUrl ?? PIAPI_BASE_URL; + return { apiKey, baseUrl }; +} + +async function submitTask( + baseUrl: string, + apiKey: string, + body: Record, +): Promise { + const response = await fetch(`${baseUrl}/api/v1/task`, { + method: "POST", + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new PiAPIError( + `piapi submit failed (${response.status}): ${errorText}`, + response.status, + ); + } + + const data = (await response.json()) as PiAPIResponse; + const taskId = data.data?.task_id; + if (!taskId) { + throw new PiAPIError("no task_id in piapi response"); + } + return taskId; +} + +async function pollTask( + baseUrl: string, + apiKey: string, + taskId: string, + maxAttempts = POLL_MAX_ATTEMPTS, + intervalMs = POLL_INTERVAL_MS, + abortSignal?: AbortSignal, +): Promise<{ url: string }> { + for (let i = 0; i < maxAttempts; i++) { + if (abortSignal?.aborted) { + throw new PiAPIError("request aborted"); + } + + const res = await fetch(`${baseUrl}/api/v1/task/${taskId}`, { + method: "GET", + headers: { + "X-API-Key": apiKey, + Accept: "application/json", + }, + signal: abortSignal, + }); + + if (!res.ok) { + throw new PiAPIError( + `piapi status check failed (${res.status})`, + res.status, + ); + } + + const body = (await res.json()) as PiAPIResponse; + const status = body.data?.status?.toLowerCase(); + + if (status === "completed") { + const videoUrl = body.data?.output?.video; + if (!videoUrl) { + throw new PiAPIError("piapi task completed but no video URL"); + } + return { url: videoUrl }; + } + + if (status === "failed") { + const errMsg = + body.data?.error?.message || + body.data?.error?.raw_message || + "piapi task failed"; + throw new PiAPIError(errMsg); + } + + // Still in progress — wait + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new PiAPIError( + `piapi polling timed out after ${(maxAttempts * intervalMs) / 1000}s`, + ); +} + +async function removeWatermark( + baseUrl: string, + apiKey: string, + videoUrl: string, + abortSignal?: AbortSignal, +): Promise<{ url: string }> { + const taskId = await submitTask(baseUrl, apiKey, { + model: "seedance", + task_type: "remove-watermark", + input: { video_url: videoUrl }, + }); + + // Watermark removal is faster — 5s interval, 10 min max + return pollTask(baseUrl, apiKey, taskId, 120, 5_000, abortSignal); +} + +// --------------------------------------------------------------------------- +// Video Model +// --------------------------------------------------------------------------- + +class PiAPIVideoModel implements VideoModelV3 { + readonly specificationVersion = "v3" as const; + readonly provider = "piapi"; + readonly modelId: string; + readonly maxVideosPerCall = 1; + private baseUrl: string; + private apiKey: string; + + constructor(modelId: string, baseUrl: string, apiKey: string) { + this.modelId = modelId; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + async doGenerate(options: VideoModelV3CallOptions) { + const warnings: SharedV3Warning[] = []; + + // Map files to image_urls / video_urls + const imageUrls: string[] = []; + const videoUrls: string[] = []; + + if (options.files?.length) { + for (const f of options.files) { + if (f.type === "url") { + const url = (f as { type: "url"; url: string }).url; + // Detect video files by extension + const ext = url.split(".").pop()?.toLowerCase(); + if (ext && ["mp4", "webm", "mov", "avi", "mkv"].includes(ext)) { + videoUrls.push(url); + } else { + imageUrls.push(url); + } + } else if (f.type === "file") { + warnings.push({ + type: "other" as const, + message: + "PiAPI requires URLs for input files. Inline file data is not supported — upload to a CDN first.", + }); + } + } + } + + const input: Record = { + prompt: options.prompt, + }; + if (options.duration != null) input.duration = options.duration; + if (options.aspectRatio) input.aspect_ratio = options.aspectRatio; + if (imageUrls.length) input.image_urls = imageUrls; + if (videoUrls.length) input.video_urls = videoUrls; + + // Provider-specific options + const providerOpts = options.providerOptions?.piapi as + | Record + | undefined; + if (providerOpts?.parent_task_id) { + input.parent_task_id = providerOpts.parent_task_id; + } + + // Submit the generation task + const taskId = await submitTask(this.baseUrl, this.apiKey, { + model: "seedance", + task_type: this.modelId, + input, + }); + + // Poll for completion + let result = await pollTask( + this.baseUrl, + this.apiKey, + taskId, + POLL_MAX_ATTEMPTS, + POLL_INTERVAL_MS, + options.abortSignal, + ); + + // Auto watermark removal for seedance-2-preview + if (this.modelId === "seedance-2-preview") { + result = await removeWatermark( + this.baseUrl, + this.apiKey, + result.url, + options.abortSignal, + ); + } + + // Download the video + const videoRes = await fetch(result.url, { + signal: options.abortSignal, + }); + if (!videoRes.ok) { + throw new PiAPIError( + `failed to download video from ${result.url}: ${videoRes.status}`, + ); + } + + const videoData = new Uint8Array(await videoRes.arrayBuffer()); + + return { + videos: [videoData], + warnings, + response: { + timestamp: new Date(), + modelId: this.modelId, + headers: undefined, + }, + }; + } +} + +// --------------------------------------------------------------------------- +// Factory + singleton +// --------------------------------------------------------------------------- + +export function createPiAPI( + settings: PiAPIProviderSettings = {}, +): PiAPIProvider { + const { apiKey, baseUrl } = resolveConfig(settings); + + return { + specificationVersion: "v3", + videoModel: (modelId) => new PiAPIVideoModel(modelId, baseUrl, apiKey), + imageModel(modelId: string): ImageModelV3 { + throw new NoSuchModelError({ modelId, modelType: "imageModel" }); + }, + languageModel(modelId: string): LanguageModelV3 { + throw new NoSuchModelError({ modelId, modelType: "languageModel" }); + }, + embeddingModel(modelId: string): EmbeddingModelV3 { + throw new NoSuchModelError({ modelId, modelType: "embeddingModel" }); + }, + }; +} + +const piapi_provider = createPiAPI(); +export { piapi_provider as piapi }; diff --git a/src/definitions/models/index.ts b/src/definitions/models/index.ts index a445d41b..4e7d8e30 100644 --- a/src/definitions/models/index.ts +++ b/src/definitions/models/index.ts @@ -17,6 +17,10 @@ export { export { definition as qwenImage2 } from "./qwen-image-2"; export { definition as recraftV4 } from "./recraft-v4"; export { definition as reve } from "./reve"; +export { + definition as seedance2Preview, + fastDefinition as seedance2FastPreview, +} from "./seedance"; export { definition as sonauto } from "./sonauto"; export { definition as soul } from "./soul"; export { definition as veedFabric } from "./veed-fabric"; @@ -39,6 +43,10 @@ import { import { definition as qwenImage2Definition } from "./qwen-image-2"; import { definition as recraftV4Definition } from "./recraft-v4"; import { definition as reveDefinition } from "./reve"; +import { + fastDefinition as seedance2FastPreviewDefinition, + definition as seedance2PreviewDefinition, +} from "./seedance"; import { definition as sonautoDefinition } from "./sonauto"; import { definition as soulDefinition } from "./soul"; import { definition as veedFabricDefinition } from "./veed-fabric"; @@ -62,6 +70,8 @@ export const allModels = [ whisperDefinition, elevenlabsDefinition, soulDefinition, + seedance2PreviewDefinition, + seedance2FastPreviewDefinition, sonautoDefinition, llamaDefinition, ]; diff --git a/src/definitions/models/seedance.ts b/src/definitions/models/seedance.ts new file mode 100644 index 00000000..ffdceb02 --- /dev/null +++ b/src/definitions/models/seedance.ts @@ -0,0 +1,96 @@ +/** + * Seedance 2 video generation model (via PiAPI) + * High-quality video generation from text, images, or video editing + * + * Two variants: + * - seedance-2-preview: Higher quality, $0.25/s, automatic watermark removal + * - seedance-2-fast-preview: Faster, $0.15/s, no watermark removal needed + */ + +import { z } from "zod"; +import { aspectRatioSchema } from "../../core/schema/shared"; +import type { ModelDefinition, ZodSchema } from "../../core/schema/types"; + +// Seedance supports 5, 10, or 15 second durations +const seedanceDurationSchema = z.union([ + z.literal(5), + z.literal(10), + z.literal(15), +]); + +// Extended aspect ratios supported by Seedance +const seedanceAspectRatioSchema = z.enum(["16:9", "9:16", "4:3", "3:4"]); + +// Input schema +const seedanceInputSchema = z.object({ + prompt: z.string().describe("Text description of the video to generate"), + image_urls: z + .array(z.string().url()) + .max(9) + .optional() + .describe( + "Reference image URLs for image-to-video or subject appearance control. Use @imageN in prompt to reference (e.g. @image1). Maximum 9 images.", + ), + video_urls: z + .array(z.string().url()) + .max(1) + .optional() + .describe( + "Video URL for video edit mode. When provided, the input video is edited based on the prompt. Duration parameter is ignored.", + ), + duration: seedanceDurationSchema + .default(5) + .describe( + "Video duration in seconds (5, 10, or 15). Ignored in video edit mode.", + ), + aspect_ratio: seedanceAspectRatioSchema + .default("16:9") + .describe("Output aspect ratio"), + parent_task_id: z + .string() + .optional() + .describe("Parent task ID for extending a previously generated video"), +}); + +// Output schema +const seedanceOutputSchema = z.object({ + video: z.object({ + url: z.string(), + }), +}); + +const schema: ZodSchema< + typeof seedanceInputSchema, + typeof seedanceOutputSchema +> = { + input: seedanceInputSchema, + output: seedanceOutputSchema, +}; + +export const definition: ModelDefinition = { + type: "model", + name: "seedance-2-preview", + description: + "Seedance 2 video generation — high-quality video from text, images, or video editing. Powered by ByteDance via PiAPI.", + providers: ["piapi"], + defaultProvider: "piapi", + providerModels: { + piapi: "seedance-2-preview", + }, + schema, +}; + +export const fastDefinition: ModelDefinition = { + type: "model", + name: "seedance-2-fast-preview", + description: + "Seedance 2 Fast — faster video generation from text, images, or video editing. Lower cost than seedance-2-preview.", + providers: ["piapi"], + defaultProvider: "piapi", + providerModels: { + piapi: "seedance-2-fast-preview", + }, + schema, +}; + +export default definition; diff --git a/src/providers/index.ts b/src/providers/index.ts index 4881cc56..02559898 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -98,6 +98,8 @@ export { SoulQuality, SoulSize, } from "./higgsfield"; +// PiAPI provider (Seedance video generation) +export { PiAPIProvider, piapiProvider } from "./piapi"; // Replicate provider (video/image generation) export { MODELS, @@ -128,6 +130,7 @@ import { ffmpegProvider } from "./ffmpeg"; import { fireworksProvider } from "./fireworks"; import { groqProvider } from "./groq"; import { higgsfieldProvider } from "./higgsfield"; +import { piapiProvider } from "./piapi"; import { replicateProvider } from "./replicate"; import { storageProvider } from "./storage"; @@ -139,5 +142,6 @@ providers.register(elevenlabsProvider); providers.register(groqProvider); providers.register(fireworksProvider); providers.register(higgsfieldProvider); +providers.register(piapiProvider); providers.register(ffmpegProvider); providers.register(storageProvider); diff --git a/src/providers/piapi.ts b/src/providers/piapi.ts new file mode 100644 index 00000000..ba8a749a --- /dev/null +++ b/src/providers/piapi.ts @@ -0,0 +1,339 @@ +/** + * PiAPI provider for Seedance video generation + * Supports text-to-video, image-to-video, and video editing via ByteDance's Seedance models + */ + +import type { JobStatusUpdate, ProviderConfig } from "../core/schema/types"; +import { BaseProvider } from "./base"; + +const PIAPI_BASE_URL = "https://api.piapi.ai"; + +/** PiAPI task response shape. */ +interface PiAPITaskData { + task_id: string; + model: string; + task_type: string; + status: string; + input: Record; + output: { video?: string } | null; + meta: { + created_at: string; + started_at: string; + ended_at: string; + usage: { type: string; frozen: number; consume: number }; + is_using_private_pool: boolean; + }; + error: { + code: number; + message: string; + raw_message?: string; + detail?: unknown; + }; + logs: unknown[]; +} + +interface PiAPIResponse { + code: number; + data: PiAPITaskData; + message: string; +} + +export class PiAPIProvider extends BaseProvider { + readonly name = "piapi"; + private apiKey: string; + + constructor(config?: ProviderConfig) { + super({ + timeout: 3600000, // 1 hour default (seedance can be slow during peak) + ...config, + }); + this.apiKey = config?.apiKey || process.env.PIAPI_API_KEY || ""; + } + + async submit( + model: string, + inputs: Record, + _config?: ProviderConfig, + ): Promise { + const taskType = (inputs.task_type as string) || model; + const prompt = (inputs.prompt as string) || ""; + const duration = inputs.duration as number | undefined; + const aspectRatio = inputs.aspect_ratio as string | undefined; + const imageUrls = inputs.image_urls as string[] | undefined; + const videoUrls = inputs.video_urls as string[] | undefined; + const parentTaskId = inputs.parent_task_id as string | undefined; + + const requestBody: Record = { + model: "seedance", + task_type: taskType, + input: { + prompt, + ...(duration != null ? { duration } : {}), + ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}), + ...(imageUrls?.length ? { image_urls: imageUrls } : {}), + ...(videoUrls?.length ? { video_urls: videoUrls } : {}), + ...(parentTaskId ? { parent_task_id: parentTaskId } : {}), + }, + }; + + const response = await fetch(`${PIAPI_BASE_URL}/api/v1/task`, { + method: "POST", + headers: { + "X-API-Key": this.apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`piapi submit failed (${response.status}): ${errorText}`); + } + + const data = (await response.json()) as PiAPIResponse; + const taskId = data.data?.task_id; + + if (!taskId) { + throw new Error("no task_id in piapi response"); + } + + console.log(`[piapi] task submitted: ${taskId} (${taskType})`); + return taskId; + } + + async getStatus(jobId: string): Promise { + const res = await fetch(`${PIAPI_BASE_URL}/api/v1/task/${jobId}`, { + method: "GET", + headers: { + "X-API-Key": this.apiKey, + Accept: "application/json", + }, + }); + + if (!res.ok) { + throw new Error(`piapi status check failed (${res.status})`); + } + + const body = (await res.json()) as PiAPIResponse; + const status = body.data?.status?.toLowerCase(); + + const statusMap: Record = { + pending: "queued", + staged: "queued", + processing: "processing", + completed: "completed", + failed: "failed", + }; + + return { + status: statusMap[status] ?? "processing", + output: body.data?.output, + error: body.data?.error?.message || undefined, + }; + } + + async getResult(jobId: string): Promise { + const res = await fetch(`${PIAPI_BASE_URL}/api/v1/task/${jobId}`, { + method: "GET", + headers: { + "X-API-Key": this.apiKey, + Accept: "application/json", + }, + }); + + if (!res.ok) { + throw new Error(`piapi result fetch failed (${res.status})`); + } + + const body = (await res.json()) as PiAPIResponse; + return body.data?.output; + } + + // ============================================================================ + // High-level convenience methods (same pattern as fal provider) + // ============================================================================ + + /** + * Generate a video from text with Seedance. + * For seedance-2-preview, automatically runs watermark removal. + */ + async textToVideo(args: { + prompt: string; + model?: "seedance-2-preview" | "seedance-2-fast-preview"; + duration?: 5 | 10 | 15; + aspectRatio?: "16:9" | "9:16" | "4:3" | "3:4"; + }) { + const model = args.model || "seedance-2-preview"; + + console.log(`[piapi] starting text-to-video: ${model}`); + console.log(`[piapi] prompt: ${args.prompt}`); + + return this.runAndWait(model, { + prompt: args.prompt, + ...(args.duration != null ? { duration: args.duration } : {}), + ...(args.aspectRatio ? { aspect_ratio: args.aspectRatio } : {}), + }); + } + + /** + * Generate a video from image(s) with Seedance. + * Use @imageN in the prompt to reference images (e.g. @image1). + * For seedance-2-preview, automatically runs watermark removal. + */ + async imageToVideo(args: { + prompt: string; + imageUrls: string[]; + model?: "seedance-2-preview" | "seedance-2-fast-preview"; + duration?: 5 | 10 | 15; + aspectRatio?: "16:9" | "9:16" | "4:3" | "3:4"; + }) { + const model = args.model || "seedance-2-preview"; + + console.log(`[piapi] starting image-to-video: ${model}`); + console.log(`[piapi] prompt: ${args.prompt}`); + console.log(`[piapi] images: ${args.imageUrls.length}`); + + return this.runAndWait(model, { + prompt: args.prompt, + image_urls: args.imageUrls, + ...(args.duration != null ? { duration: args.duration } : {}), + ...(args.aspectRatio ? { aspect_ratio: args.aspectRatio } : {}), + }); + } + + /** + * Edit a video with Seedance. The output has the same length as the input. + * Optionally provide image references for character replacement. + * For seedance-2-preview, automatically runs watermark removal. + */ + async editVideo(args: { + prompt: string; + videoUrl: string; + imageUrls?: string[]; + model?: "seedance-2-preview" | "seedance-2-fast-preview"; + aspectRatio?: "16:9" | "9:16" | "4:3" | "3:4"; + }) { + const model = args.model || "seedance-2-preview"; + + console.log(`[piapi] starting video edit: ${model}`); + console.log(`[piapi] prompt: ${args.prompt}`); + + return this.runAndWait(model, { + prompt: args.prompt, + video_urls: [args.videoUrl], + ...(args.imageUrls?.length ? { image_urls: args.imageUrls } : {}), + ...(args.aspectRatio ? { aspect_ratio: args.aspectRatio } : {}), + }); + } + + /** + * Extend a previously generated video. + * If prompt/duration/aspect_ratio are omitted, the parent task params are reused. + */ + async extendVideo(args: { + parentTaskId: string; + prompt?: string; + model?: "seedance-2-preview" | "seedance-2-fast-preview"; + duration?: 5 | 10 | 15; + aspectRatio?: "16:9" | "9:16" | "4:3" | "3:4"; + }) { + const model = args.model || "seedance-2-preview"; + + console.log(`[piapi] starting extend video: ${model}`); + console.log(`[piapi] parent task: ${args.parentTaskId}`); + + return this.runAndWait(model, { + parent_task_id: args.parentTaskId, + ...(args.prompt ? { prompt: args.prompt } : {}), + ...(args.duration != null ? { duration: args.duration } : {}), + ...(args.aspectRatio ? { aspect_ratio: args.aspectRatio } : {}), + }); + } + + /** + * Remove watermark from a video. + */ + async removeWatermark(videoUrl: string) { + console.log("[piapi] submitting watermark removal..."); + + const response = await fetch(`${PIAPI_BASE_URL}/api/v1/task`, { + method: "POST", + headers: { + "X-API-Key": this.apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + model: "seedance", + task_type: "remove-watermark", + input: { video_url: videoUrl }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `piapi watermark removal submit failed (${response.status}): ${errorText}`, + ); + } + + const data = (await response.json()) as PiAPIResponse; + const taskId = data.data?.task_id; + if (!taskId) { + throw new Error("no task_id in piapi watermark removal response"); + } + + console.log(`[piapi] watermark removal task: ${taskId}`); + + const result = (await this.waitForCompletion(taskId, { + maxWait: 600000, // 10 minutes max for watermark removal + pollInterval: 5000, + })) as { video?: string }; + + const videoUrl2 = result?.video; + if (!videoUrl2) { + throw new Error("piapi watermark removal completed but no video URL"); + } + + console.log("[piapi] watermark removal completed!"); + return { video: { url: videoUrl2 } }; + } + + // ============================================================================ + // Internal helpers + // ============================================================================ + + /** + * Submit a task, wait for completion, and auto-remove watermark for seedance-2-preview. + * Returns the same shape as fal convenience methods. + */ + private async runAndWait(model: string, input: Record) { + const jobId = await this.submit(model, { + task_type: model, + ...input, + }); + + const result = (await this.waitForCompletion(jobId, { + maxWait: this.config.timeout ?? 3600000, + pollInterval: 10000, + })) as { video?: string }; + + const videoUrl = result?.video; + if (!videoUrl) { + throw new Error("piapi task completed but no video URL in output"); + } + + // Auto watermark removal for seedance-2-preview + if (model === "seedance-2-preview") { + console.log("[piapi] auto watermark removal for seedance-2-preview..."); + return this.removeWatermark(videoUrl); + } + + console.log("[piapi] completed!"); + return { video: { url: videoUrl } }; + } +} + +// Export singleton instance +export const piapiProvider = new PiAPIProvider();