19 · Adding a New Video Provider — Exhaustive Guide
This guide adds a new video-generation provider (Kling, Runway, Wan-Alpha via Replicate, Pika, Luma) to NeuroLink.
Read first. Unlike TTS / STT / Realtime, the video subsystem has no handler abstraction yet. The current code has a single hardcoded import of
generateVideoWithVertexinbaseProvider.ts:1755. To add a second video provider, you must first introduce aVideoHandlerinterface and aVideoProcessorregistry. This guide covers both: §A is the one-time refactor, §B is the recurring per-provider work.
Current state (the problem)
src/lib/core/baseProvider.ts:815-816:
if (options.output?.mode === "video") {
return await this.handleVideoGeneration(options, startTime);
}
src/lib/core/baseProvider.ts:1755-1756:
const { generateVideoWithVertex, VideoError, VIDEO_ERROR_CODES } =
await import("../adapters/video/vertexVideoHandler.js");
The handleVideoGeneration method directly imports a Vertex-specific function. There is no:
VideoHandlerinterfaceVideoProcessorregistry- Type for
output.video.provider - Way to route to non-Vertex video providers
Any new video provider PR must either (a) refactor this dispatch, or (b) bolt on a switch (provider) (which doesn't scale and gets rejected). Do (a).
§A — The one-time refactor
This refactor is behaviour-preserving for Vertex. After it lands, adding new video providers becomes mechanical (§B).
A1. Move shared video types into a dedicated file
File: src/lib/types/video.ts — NEW.
Per CLAUDE.md rule 11 (no local types directories), shared video types live at the canonical types path. Today they live in src/lib/types/multimodal.ts:150-221 (VideoOutputOptions, VideoGenerationResult); leave those re-exports in place for backwards compat.
/**
* Video Generation Type Definitions
*
* Shared types for video generation across providers (Vertex Veo, Kling,
* Runway, Replicate-hosted models, etc.).
*
* @module types/video
*/
import type {
VideoOutputOptions,
VideoGenerationResult,
} from "./multimodal.js";
// Re-export from multimodal for caller convenience
export type {
VideoOutputOptions,
VideoGenerationResult,
} from "./multimodal.js";
/**
* Director-mode transition options (shared by every provider that supports
* first-and-last-frame interpolation, e.g. Veo 3.1 Fast).
*/
export type VideoTransitionOptions = {
aspectRatio?: "9:16" | "16:9";
resolution?: "720p" | "1080p";
audio?: boolean;
};
/**
* Handler contract for video generation providers.
*
* Implementations must enforce their own timeouts (recommended: 3 minutes
* for predictLongRunning APIs that involve polling).
*/
export type VideoHandler = {
/**
* Generate a single video clip from an input image and prompt.
*/
generate(
image: Buffer,
prompt: string,
options: VideoOutputOptions,
region?: string,
): Promise<VideoGenerationResult>;
/**
* Optional — generate a transition clip between two frames (Director Mode).
* Providers without this capability omit the method.
*/
generateTransition?(
firstFrame: Buffer,
lastFrame: Buffer,
prompt: string,
options?: VideoTransitionOptions,
durationSeconds?: 4 | 6 | 8,
region?: string,
): Promise<Buffer>;
/**
* Validate the provider is configured (auth, base URL, etc.).
*/
isConfigured(): boolean;
/**
* Maximum video duration in seconds supported by this provider.
*/
readonly maxDurationSeconds?: number;
/**
* Supported aspect ratios. Convention: `["9:16", "16:9", "1:1"]` — others
* may be added per provider.
*/
readonly supportedAspectRatios?: ("9:16" | "16:9" | "1:1" | "4:3" | "3:4")[];
/**
* Supported resolutions.
*/
readonly supportedResolutions?: ("480p" | "720p" | "1080p" | "4k")[];
};
Add this file to src/lib/types/index.ts via export * from "./video.js" (per rule 10, barrel uses export * only).
A2. Create the VideoProcessor registry
File: src/lib/utils/videoProcessor.ts — NEW.
Mirror src/lib/utils/ttsProcessor.ts:75-352:
import { logger } from "./logger.js";
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
import { NeuroLinkError } from "./errorHandling.js";
import {
SpanSerializer,
SpanType,
SpanStatus,
getMetricsAggregator,
} from "../observability/index.js";
import type {
VideoGenerationResult,
VideoHandler,
VideoOutputOptions,
VideoTransitionOptions,
} from "../types/index.js";
export const VIDEO_ERROR_CODES = {
PROVIDER_NOT_SUPPORTED: "VIDEO_PROVIDER_NOT_SUPPORTED",
PROVIDER_NOT_CONFIGURED: "VIDEO_PROVIDER_NOT_CONFIGURED",
GENERATION_FAILED: "VIDEO_GENERATION_FAILED",
POLL_TIMEOUT: "VIDEO_POLL_TIMEOUT",
INVALID_INPUT: "VIDEO_INVALID_INPUT",
TRANSITION_NOT_SUPPORTED: "VIDEO_TRANSITION_NOT_SUPPORTED",
DIRECTOR_TRANSITION_FAILED: "VIDEO_DIRECTOR_TRANSITION_FAILED",
} as const;
export class VideoError extends NeuroLinkError {
// Same shape as TTSError / STTError
}
export class VideoProcessor {
private static readonly handlers = new Map<string, VideoHandler>();
static registerHandler(name: string, handler: VideoHandler): void {
if (!name) throw new Error("Provider name required");
if (!handler) throw new Error("Handler required");
const key = name.toLowerCase();
if (this.handlers.has(key)) {
logger.warn(`[VideoProcessor] Overwriting handler for: ${key}`);
}
this.handlers.set(key, handler);
}
static supports(name: string): boolean {
return name ? this.handlers.has(name.toLowerCase()) : false;
}
private static getHandler(name: string): VideoHandler | undefined {
return this.handlers.get(name.toLowerCase());
}
static async generate(
provider: string,
image: Buffer,
prompt: string,
options: VideoOutputOptions,
region?: string,
): Promise<VideoGenerationResult> {
const span = SpanSerializer.createSpan(SpanType.VIDEO, "video.generate", {
"video.provider": provider,
"video.resolution": options.resolution,
"video.duration": options.length,
});
try {
const handler = this.getHandler(provider);
if (!handler) {
throw new VideoError({
code: VIDEO_ERROR_CODES.PROVIDER_NOT_SUPPORTED,
message: `Video provider "${provider}" not registered. Available: ${Array.from(this.handlers.keys()).join(", ")}`,
category: ErrorCategory.CONFIGURATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
if (!handler.isConfigured()) {
throw new VideoError({
code: VIDEO_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
message: `Video provider "${provider}" is not configured`,
category: ErrorCategory.CONFIGURATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
const result = await handler.generate(image, prompt, options, region);
const ended = SpanSerializer.endSpan(span, SpanStatus.OK);
getMetricsAggregator().recordSpan(ended);
return result;
} catch (err) {
const ended = SpanSerializer.endSpan(
span,
SpanStatus.ERROR,
err instanceof Error ? err.message : String(err),
);
getMetricsAggregator().recordSpan(ended);
throw err;
}
}
static async generateTransition(
provider: string,
firstFrame: Buffer,
lastFrame: Buffer,
prompt: string,
options?: VideoTransitionOptions,
durationSeconds?: 4 | 6 | 8,
region?: string,
): Promise<Buffer> {
const handler = this.getHandler(provider);
if (!handler) throw /* PROVIDER_NOT_SUPPORTED */ ...;
if (!handler.generateTransition) {
throw new VideoError({
code: VIDEO_ERROR_CODES.TRANSITION_NOT_SUPPORTED,
message: `Provider "${provider}" does not support transition clips`,
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
});
}
return handler.generateTransition(firstFrame, lastFrame, prompt, options, durationSeconds, region);
}
}
Also add SpanType.VIDEO to src/lib/types/span.ts (the existing STT enum entry from 27a31c32 is the template).
A3. Wrap the existing Vertex handler in a class
File: src/lib/adapters/video/vertexVideoHandler.ts (existing) — add a class export at the bottom that delegates to the existing free functions.
import type { VideoHandler } from "../../types/index.js";
export class VertexVideoHandler implements VideoHandler {
public readonly maxDurationSeconds = 8;
public readonly supportedAspectRatios = ["9:16", "16:9"] as const;
public readonly supportedResolutions = ["720p", "1080p"] as const;
isConfigured(): boolean {
return isVertexVideoConfigured();
}
generate(
image: Buffer,
prompt: string,
options: VideoOutputOptions,
region?: string,
): Promise<VideoGenerationResult> {
return generateVideoWithVertex(image, prompt, options, region);
}
generateTransition(
firstFrame: Buffer,
lastFrame: Buffer,
prompt: string,
options?: VideoTransitionOptions,
durationSeconds?: 4 | 6 | 8,
region?: string,
): Promise<Buffer> {
return generateTransitionWithVertex(
firstFrame,
lastFrame,
prompt,
options ?? {},
durationSeconds ?? 4,
region,
);
}
}
Keep the existing free functions exported. External callers (Director's directorPipeline.ts, third-party scripts) reference them directly. Removing the functions is a public-API break.
A4. Register Vertex in providerRegistry.ts
File: src/lib/factories/providerRegistry.ts. Add a new section after the Realtime block (~line 666):
// ===== VIDEO HANDLER REGISTRATION =====
try {
const { VideoProcessor } = await import("../utils/videoProcessor.js");
const { VertexVideoHandler } =
await import("../adapters/video/vertexVideoHandler.js");
VideoProcessor.registerHandler("vertex", new VertexVideoHandler());
logger.debug("Video handlers registered: vertex");
} catch (err) {
logger.warn(
`[ProviderRegistry] vertex video registration failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
A5. Replace the hardcoded import in baseProvider.ts
File: src/lib/core/baseProvider.ts:1750-2006. The full method handleVideoGeneration currently directly imports generateVideoWithVertex. Replace with a VideoProcessor.generate call:
private async handleVideoGeneration(
options: TextGenerationOptions,
startTime: number,
): Promise<EnhancedGenerateResult> {
- const { generateVideoWithVertex, VideoError, VIDEO_ERROR_CODES } =
- await import("../adapters/video/vertexVideoHandler.js");
+ const { VideoProcessor, VideoError, VIDEO_ERROR_CODES } =
+ await import("../utils/videoProcessor.js");
const {
validateVideoGenerationInput,
validateImageForVideo,
validateDirectorModeInput,
} = await import("../utils/parameterValidation.js");
const { ErrorFactory } = await import("../utils/errorHandling.js");
// ... validation, image loading (unchanged) ...
+ const provider = options.output?.video?.provider ?? options.provider ?? "vertex";
// Generate video using selected handler
- const videoResult = await generateVideoWithVertex(
+ const videoResult = await VideoProcessor.generate(provider, {
+ image: imageBuffer,
+ prompt,
+ region: options.region,
+ ...(options.output?.video ?? {}),
+ });
// Build result
const baseResult: EnhancedGenerateResult = {
content: prompt,
- provider: "vertex",
+ provider,
model: options.model || "veo-3.1-generate-001",
usage: { input: 0, output: 0, total: 0 },
video: videoResult,
};
return await this.enhanceResult(baseResult, options, startTime);
}
Same replacement applies to the Director-mode branch (directorPipeline.ts:289):
- const result = await generateVideoWithVertex(
+ const result = await VideoProcessor.generate("vertex", {
+ image, prompt, region, ...opts,
+ });
directorPipeline orchestrates multiple segments and transitions; it should accept a provider argument and thread it through. Keep Vertex as the default for backwards compat.
A6. Add provider to VideoOutputOptions
File: src/lib/types/multimodal.ts (where VideoOutputOptions lives).
export type VideoOutputOptions = {
+ /** Override the video-gen provider. Defaults to the LLM provider or "vertex". */
+ provider?: string;
resolution?: "720p" | "1080p";
length?: 4 | 6 | 8;
aspectRatio?: "9:16" | "16:9";
audio?: boolean;
...
};
This is additive — existing callers ignore the new field.
A7. CLI surface for --video-provider
File: src/cli/factories/commandFactory.ts:2450 (the output: { mode: "video" } block).
...
mode: "video" as const,
+ "video-provider": {
+ type: "string",
+ description: "Video-gen provider override (default: vertex)",
+ },
Threading: the CLI handler reads argv.videoProvider and sets options.output.video.provider.
A8. Tests for the refactor
Add to test/continuous-test-suite-media-gen.ts:
{
name: "VideoProcessor.supports vertex",
fn: async () => {
const { VideoProcessor } = await import("@juspay/neurolink");
return VideoProcessor.supports("vertex");
},
},
{
name: "VideoProcessor rejects unknown provider",
fn: async () => {
const { VideoProcessor } = await import("@juspay/neurolink");
try {
await VideoProcessor.generate("nonexistent", {
image: Buffer.alloc(1),
prompt: "test",
});
return false;
} catch (err) {
return err.code === "VIDEO_PROVIDER_NOT_SUPPORTED";
}
},
},
The existing Vertex-mode video tests (golden-path E2E) should keep passing without modification — that's the behaviour-preservation gate for the refactor.
§B — Adding a video provider after the refactor
Once §A is in place, adding Kling / Runway / Pika / Luma is mechanical. Per provider: