UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

317 lines 13.2 kB
/** * Runway Video Handler (Gen-3 Alpha / Gen-4 Turbo) * * Async generation: POST /v1/image_to_video → poll /v1/tasks/{id}. * * @module adapters/video/runwayVideoHandler * @see https://docs.dev.runwayml.com/api/ */ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js"; import { VIDEO_ERROR_CODES } from "../../constants/videoErrors.js"; import { logger } from "../../utils/logger.js"; import { sanitizeForLog } from "../../utils/logSanitize.js"; import { safeDownload } from "../../utils/safeFetch.js"; import { VideoError } from "../../utils/videoProcessor.js"; import { MAX_VIDEO_BYTES } from "../../utils/sizeGuard.js"; const DEFAULT_BASE_URL = "https://api.dev.runwayml.com/v1"; const REQUEST_TIMEOUT_MS = 30_000; const POLL_INTERVAL_MS = 5_000; const TOTAL_TIMEOUT_MS = 5 * 60_000; /** * Runway Video Handler. * * Auth: `Authorization: Bearer ${RUNWAY_API_KEY}` + `X-Runway-Version` * header. Models: gen3a_turbo (Gen-3 Alpha Turbo, default), gen4_turbo. */ export class RunwayVideoHandler { maxDurationSeconds = 10; supportedAspectRatios = [ "16:9", "9:16", ]; supportedResolutions = [ "720p", "1080p", ]; apiKey; baseUrl; apiVersion; constructor(apiKey) { const resolved = (apiKey ?? process.env.RUNWAY_API_KEY ?? "").trim(); this.apiKey = resolved.length > 0 ? resolved : null; this.baseUrl = (process.env.RUNWAY_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, ""); this.apiVersion = process.env.RUNWAY_API_VERSION ?? "2024-11-06"; } isConfigured() { return this.apiKey !== null; } async generate(image, prompt, options) { if (!this.apiKey) { throw new VideoError({ code: VIDEO_ERROR_CODES.PROVIDER_NOT_CONFIGURED, message: "RUNWAY_API_KEY not configured", category: ErrorCategory.CONFIGURATION, severity: ErrorSeverity.HIGH, retriable: false, }); } if (options.aspectRatio !== undefined && !this.supportedAspectRatios.includes(options.aspectRatio)) { throw new VideoError({ code: VIDEO_ERROR_CODES.INVALID_INPUT, message: `Unsupported aspect ratio for Runway: "${options.aspectRatio}". Supported: ${this.supportedAspectRatios.join(", ")}.`, category: ErrorCategory.VALIDATION, severity: ErrorSeverity.MEDIUM, retriable: false, context: { requested: options.aspectRatio, supported: this.supportedAspectRatios, }, }); } const startTime = Date.now(); const abortSignal = options.abortSignal; const taskId = await this.submitTask(image, prompt, options); const videoUrl = await this.pollUntilComplete(taskId, abortSignal); const buffer = await this.downloadVideo(videoUrl); const processingTime = Date.now() - startTime; logger.info(`[RunwayVideoHandler] Generated ${buffer.length} bytes in ${processingTime}ms — task ${taskId}`); return { data: buffer, mediaType: "video/mp4", metadata: { duration: options.length ?? 4, dimensions: this.calculateDimensions(options), model: options.model ?? "gen3a_turbo", provider: "runway", aspectRatio: options.aspectRatio ?? "16:9", audioEnabled: false, processingTime, }, }; } async submitTask(image, prompt, options) { const dataUri = `data:image/${this.detectImageType(image)};base64,${image.toString("base64")}`; const body = { model: options.model ?? "gen3a_turbo", promptImage: dataUri, promptText: prompt, duration: options.length ?? 4, ratio: options.aspectRatio === "9:16" ? "9:16" : "16:9", seed: undefined, }; const response = await this.fetchWithTimeout(`${this.baseUrl}/image_to_video`, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", "X-Runway-Version": this.apiVersion, }, body: JSON.stringify(body), }); if (!response.ok) { const raw = await response.text(); const retriable = response.status === 408 || response.status === 429 || response.status >= 500; throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway submit failed: ${response.status}${sanitizeForLog(raw, 500)}`, category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable, context: { status: response.status }, }); } const json = (await response.json()); if (!json.id) { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: "Runway submit response missing id", category: ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable: false, context: { response: json }, }); } return json.id; } async pollUntilComplete(taskId, abortSignal) { const startTime = Date.now(); while (Date.now() - startTime < TOTAL_TIMEOUT_MS) { if (abortSignal?.aborted) { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway poll for task ${taskId} aborted by caller`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.MEDIUM, retriable: false, context: { taskId }, }); } const response = await this.fetchWithTimeout(`${this.baseUrl}/tasks/${taskId}`, { method: "GET", headers: { Authorization: `Bearer ${this.apiKey}`, "X-Runway-Version": this.apiVersion, }, }, abortSignal); if (!response.ok) { const raw = await response.text(); throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway poll failed: ${response.status}${sanitizeForLog(raw, 500)}`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.MEDIUM, retriable: response.status >= 500, }); } const data = (await response.json()); if (data.status === "SUCCEEDED" || data.status === "succeeded") { const url = Array.isArray(data.output) ? data.output[0] : data.output; if (typeof url !== "string") { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway task ${taskId} completed but no output URL`, category: ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable: false, context: { taskId, data }, }); } return url; } if (data.status === "FAILED" || data.status === "failed") { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway task ${taskId} failed: ${data.failure ?? data.error ?? "unknown"}`, category: ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable: false, context: { taskId, data }, }); } // Abortable sleep. await new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); reject(new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway poll for task ${taskId} aborted by caller`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.MEDIUM, retriable: false, context: { taskId }, })); }; const timer = setTimeout(() => { abortSignal?.removeEventListener("abort", onAbort); resolve(); }, POLL_INTERVAL_MS); abortSignal?.addEventListener("abort", onAbort, { once: true }); }); } throw new VideoError({ code: VIDEO_ERROR_CODES.POLL_TIMEOUT, message: `Runway task ${taskId} did not complete within ${TOTAL_TIMEOUT_MS / 1000}s`, category: ErrorCategory.TIMEOUT, severity: ErrorSeverity.MEDIUM, retriable: true, context: { taskId }, }); } async downloadVideo(url) { try { return await safeDownload(url, { maxBytes: MAX_VIDEO_BYTES, label: "Runway video", timeoutMs: REQUEST_TIMEOUT_MS, }); } catch (err) { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway video download rejected: ${err instanceof Error ? err.message : String(err)}`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.HIGH, retriable: false, context: { url }, originalError: err instanceof Error ? err : undefined, }); } } async fetchWithTimeout(url, init, callerAbortSignal) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const onCallerAbort = () => controller.abort(); callerAbortSignal?.addEventListener("abort", onCallerAbort, { once: true }); try { return await fetch(url, { ...init, signal: controller.signal }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway request to ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.HIGH, retriable: true, originalError: err, }); } throw new VideoError({ code: VIDEO_ERROR_CODES.GENERATION_FAILED, message: `Runway fetch failed: ${err instanceof Error ? err.message : String(err)}`, category: ErrorCategory.NETWORK, severity: ErrorSeverity.HIGH, retriable: true, originalError: err instanceof Error ? err : undefined, }); } finally { callerAbortSignal?.removeEventListener("abort", onCallerAbort); clearTimeout(timeoutId); } } detectImageType(buffer) { if (buffer.length < 4) { return "jpeg"; } if (buffer[0] === 0x89 && buffer[1] === 0x50) { return "png"; } if (buffer[0] === 0xff && buffer[1] === 0xd8) { return "jpeg"; } // RIFF container: check offset 8 to distinguish WebP from WAV so audio // data passed as image is not silently misidentified as WebP. if (buffer.length >= 12 && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) { if (buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { return "webp"; } // RIFF but not WEBP (e.g. WAVE audio) — not a valid image. return "jpeg"; } return "jpeg"; } calculateDimensions(options) { const aspectRatio = options.aspectRatio ?? "16:9"; const resolution = options.resolution ?? "720p"; if (resolution === "1080p") { return aspectRatio === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; } return aspectRatio === "9:16" ? { width: 720, height: 1280 } : { width: 1280, height: 720 }; } } //# sourceMappingURL=runwayVideoHandler.js.map