@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
987 lines (924 loc) • 34.6 kB
text/typescript
/**
* Video Activity (Experimental)
*
* Generates videos from text prompts using a jobs/polling architecture.
* This is a self-contained module with implementation, types, and JSDoc.
*
* @experimental Video generation is an experimental feature and may change.
*/
import { aiEventClient } from '@tanstack/ai-event-client'
import { toRunErrorPayload } from '../error-payload'
import { resolveDebugOption } from '../../logger/resolve'
import {
applyGenerationResultTransforms,
createGenerationContext,
runGenerationAbort,
runGenerationError,
runGenerationFinish,
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type {
GenerationMiddleware,
GenerationMiddlewareContext,
} from '../middleware/types'
import type { VideoAdapter } from './adapter'
import type {
MediaPrompt,
MediaPromptFor,
PersistedArtifactRef,
StreamChunk,
TokenUsage,
VideoJobResult,
VideoStatusResult,
VideoUrlResult,
} from '../../types'
// ===========================
// Activity Kind
// ===========================
/** The adapter kind this activity handles */
export const kind = 'video' as const
// ===========================
// Type Extraction Helpers
// ===========================
/**
* Extract provider options from a VideoAdapter via ~types.
*/
export type VideoProviderOptions<TAdapter> =
TAdapter extends VideoAdapter<any, any, any, any, any, any>
? TAdapter['~types']['providerOptions']
: object
/**
* Extract the size type for a VideoAdapter's model via ~types.
*/
export type VideoSizeForAdapter<TAdapter> =
TAdapter extends VideoAdapter<
infer TModel,
any,
any,
infer TSizeMap,
any,
any
>
? TModel extends keyof TSizeMap
? TSizeMap[TModel]
: string
: string
/**
* Extract the prompt type a model accepts from a VideoAdapter via ~types.
* Mirrors `ImagePromptForModel`: models in the adapter's input-modality map
* get a `prompt` narrowed to text + their supported part types; adapters
* without a map fall back to the full MediaPrompt.
*/
export type VideoPromptForAdapter<TAdapter> =
TAdapter extends VideoAdapter<
infer TModel,
any,
any,
any,
infer ModsByName,
any
>
? string extends keyof ModsByName
? MediaPrompt
: TModel extends keyof ModsByName
? MediaPromptFor<ModsByName[TModel][number]>
: MediaPrompt
: MediaPrompt
/**
* Extract the duration type for a VideoAdapter's model via ~types.
* Mirrors `VideoSizeForAdapter`. Falls back to `number` for adapters that
* haven't declared per-model duration constraints.
*/
export type VideoDurationForAdapter<TAdapter> =
TAdapter extends VideoAdapter<
infer TModel,
any,
any,
any,
any,
infer TDurationMap
>
? TModel extends keyof TDurationMap
? TDurationMap[TModel]
: number
: number
// ===========================
// Activity Options Types
function createId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
// ===========================
/**
* Base options shared by all video activity operations.
* The model is extracted from the adapter's model property.
*/
interface VideoActivityBaseOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
> {
/** The video adapter to use (must be created with a model) */
adapter: TAdapter & { kind: typeof kind }
}
/**
* Options for creating a new video generation job.
* The model is extracted from the adapter's model property.
*
* @template TAdapter - The video adapter type
* @template TStream - Whether to stream the output
*
* @experimental Video generation is an experimental feature and may change.
*/
export type VideoCreateOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
TStream extends boolean = false,
> = VideoActivityBaseOptions<TAdapter> & {
/** Request type - create a new job (default if not specified) */
request?: 'create'
/**
* Description of the desired video. Either a plain string, or — for models
* that support image-conditioned generation — an ordered array of content
* parts interleaving text with image inputs. Image parts may carry
* `metadata.role` (`'start_frame' | 'end_frame' | 'reference' |
* 'character'`) to disambiguate intent; positional fallback otherwise. The
* accepted part types are narrowed per model via the adapter's
* input-modality map.
*/
prompt: VideoPromptForAdapter<TAdapter>
/** Video size — format depends on the provider (e.g., "16:9", "1280x720") */
size?: VideoSizeForAdapter<TAdapter>
/**
* Video duration in seconds. Adapters that declare a per-model duration
* map narrow this to the model's valid union (e.g. `4 | 6 | 8` for Veo 3).
* Pass `adapter.snapDuration(seconds)` to coerce raw seconds to a valid
* value.
*/
duration?: VideoDurationForAdapter<TAdapter>
/**
* Whether to stream the video generation lifecycle.
* When true, returns an AsyncIterable<StreamChunk> that handles the full
* job lifecycle: create job, poll for status, yield updates, and yield final result.
* When false or not provided, returns a Promise<VideoJobResult>.
*
* @default false
*/
stream?: TStream
/** Polling interval in milliseconds (stream mode only). @default 2000 */
pollingInterval?: number
/** Maximum time to wait before timing out in milliseconds (stream mode only). @default 600000 */
maxDuration?: number
/**
* Custom run id (stream mode only) — the id stamped on the emitted
* `RUN_STARTED` / `RUN_FINISHED` chunks.
*
* IGNORED by a non-streaming submit. That run spans two calls, and its id is
* derived from the provider's job instead, so {@link getVideoJobStatus} can
* recompute it from the `jobId` you already have to poll with. Honoring a
* custom id here would reintroduce the failure this avoids: a caller who set
* it on the submit and forgot it on the poll would silently open a second
* record while the first sat unfinished forever.
*/
runId?: string
/**
* Stable conversation/thread id for correlating this run when persisted.
*
* Also the `threadId` stamped on the emitted `RUN_STARTED` / `RUN_FINISHED`
* chunks; when omitted a throwaway id is minted for those chunks only, and
* the persisted run record carries NO thread link rather than a fabricated
* one. Pass it whenever persistence is on — it is the slot a reloading client
* hydrates by, so a run stored without it can only be fetched by run id.
*/
threadId?: string
/**
* Enable debug logging. Pass `true` to enable all categories, `false` to
* silence everything including errors, or a `DebugConfig` object for granular
* control and/or a custom `Logger`.
*/
debug?: DebugOption
/**
* Observe-only middleware notified on start, usage, success, and error. Pass
* `otelMiddleware()` to emit OpenTelemetry spans, `withGenerationPersistence()`
* to persist the run, or implement the `GenerationMiddleware` contract for a
* custom backend.
*
* In streaming mode one run covers the full create→poll→complete lifecycle:
* `onStart` at submission, a terminal `onFinish`/`onError` when the job
* settles, and `onAbort` if the consumer abandons the stream.
*
* In NON-streaming mode the call only SUBMITS the job, so it only opens the
* run: no terminal hook fires here, because the video does not exist yet.
* Pass the same `middleware` and `threadId` to {@link getVideoJobStatus}; the
* poll that observes a terminal job state finishes the run and is where the
* result and its artifacts are recorded. Nothing else has to be threaded
* through — both calls derive the run id from the provider's `jobId`, the one
* id a poller cannot be missing.
*
* Because the job id only exists once the provider accepts the job, `onStart`
* fires AFTER the submit request rather than before it — an observer's span
* therefore covers the run from acceptance onward, not the submit round-trip.
* A submission that FAILS has no job to key on, so it opens and immediately
* fails a run under this call's `requestId`: the thread's latest run reports
* the failure (a client hydrating the slot sees it) even though there is no
* job to resume.
*/
middleware?: Array<GenerationMiddleware>
} & ({} extends VideoProviderOptions<TAdapter>
? {
/** Provider-specific options for video generation */ modelOptions?: VideoProviderOptions<TAdapter>
}
: {
/** Provider-specific options for video generation */ modelOptions: VideoProviderOptions<TAdapter>
})
/**
* Options for polling the status of a video generation job.
*
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoStatusOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
> extends VideoActivityBaseOptions<TAdapter> {
/** Request type - get job status */
request: 'status'
/** The job ID to check status for */
jobId: string
}
/**
* Options for getting the URL of a completed video.
*
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoUrlOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
> extends VideoActivityBaseOptions<TAdapter> {
/** Request type - get video URL */
request: 'url'
/** The job ID to get URL for */
jobId: string
}
/**
* Union type for all video activity options.
* Discriminated by the `request` field.
*
* @experimental Video generation is an experimental feature and may change.
*/
export type VideoActivityOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
TRequest extends 'create' | 'status' | 'url' = 'create',
TStream extends boolean = false,
> = TRequest extends 'status'
? VideoStatusOptions<TAdapter>
: TRequest extends 'url'
? VideoUrlOptions<TAdapter>
: VideoCreateOptions<TAdapter, TStream>
// ===========================
// Activity Result Types
// ===========================
/**
* Result type for the video activity, based on request type and streaming.
* - If stream is true (create request): AsyncIterable<StreamChunk>
* - Otherwise: Promise<VideoJobResult | VideoStatusResult | VideoUrlResult>
*
* @experimental Video generation is an experimental feature and may change.
*/
export type VideoActivityResult<
TRequest extends 'create' | 'status' | 'url' = 'create',
TStream extends boolean = false,
> = TRequest extends 'status'
? Promise<VideoStatusResult>
: TRequest extends 'url'
? Promise<VideoUrlResult>
: TStream extends true
? AsyncIterable<StreamChunk>
: Promise<VideoJobResult>
// ===========================
// Activity Implementation
// ===========================
/**
* Generate video - creates a video generation job from a text prompt.
*
* Uses AI video generation models to create videos based on natural language descriptions.
* Unlike image generation, video generation is asynchronous and requires polling for completion.
*
* When `stream: true` is passed, handles the full job lifecycle automatically:
* create job → poll for status → stream updates → yield final result.
*
* @experimental Video generation is an experimental feature and may change.
*
* @example Create a video generation job
* ```ts
* import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
* import { openaiVideo } from '@tanstack/ai-openai'
*
* // Start a video generation job
* const { jobId } = await generateVideo({
* adapter: openaiVideo('sora-2'),
* prompt: 'A cat chasing a dog in a sunny park'
* })
*
* console.log('Job started:', jobId)
*
* // The submission only OPENS the run; the poll that sees a terminal state is
* // what completes it. The `jobId` is the whole correlation — pass the same
* // `middleware` and `threadId` when you use them.
* const status = await getVideoJobStatus({
* adapter: openaiVideo('sora-2'),
* jobId,
* })
* ```
*
* @example Stream the full video generation lifecycle
* ```ts
* import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai'
* import { openaiVideo } from '@tanstack/ai-openai'
*
* const stream = generateVideo({
* adapter: openaiVideo('sora-2'),
* prompt: 'A cat chasing a dog in a sunny park',
* stream: true,
* pollingInterval: 3000,
* })
*
* return toServerSentEventsResponse(stream)
* ```
*/
export function generateVideo<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
TStream extends boolean = false,
>(
options: VideoCreateOptions<TAdapter, TStream>,
): VideoActivityResult<'create', TStream> {
if (options.stream) {
return runStreamingVideoGeneration(
options as VideoCreateOptions<TAdapter, true>,
) as VideoActivityResult<'create', TStream>
}
return runCreateVideoJob(options) as VideoActivityResult<'create', TStream>
}
/**
* The run id a non-streaming video job is filed under, derived from the
* provider job itself.
*
* A submit-and-poll run spans two calls in two different requests, so the two
* halves need to agree on an id. Deriving it from the `jobId` — the one id a
* poller structurally cannot be missing, because it cannot poll without it —
* means no correlation state has to survive the boundary and there is no
* "forgot to pass the run id" failure to document. The provider is part of the
* key so two providers' job-id spaces cannot collide, and both halves are
* percent-encoded so the joined string stays unambiguous (and url-safe, since
* run ids end up in storage keys and query strings).
*/
function videoRunIdForJob(provider: string, jobId: string): string {
return `video:${encodeURIComponent(provider)}:${encodeURIComponent(jobId)}`
}
/**
* Internal implementation of non-streaming video job creation.
*
* Submitting a job OPENS a run, it does not complete one: the video does not
* exist yet, and the bytes only appear on a later poll. So this fires `onStart`
* and runs the result transforms over the submission result — the jobId lands
* on the run record, which is what lets a later request resume polling — but
* fires NO terminal hook. {@link getVideoJobStatus} finishes the run when the
* job settles, keyed on the same derived id.
*
* `onStart` therefore runs AFTER the submit request: the run's id comes from
* the job, which does not exist until the provider accepts it. A submission
* that fails has no job, so it opens and immediately fails a run under this
* call's `requestId` — terminal and unresumable by construction, but it puts
* the failure where a client hydrating the thread will see it instead of
* showing nothing.
*/
async function runCreateVideoJob<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
>(options: VideoCreateOptions<TAdapter, boolean>): Promise<VideoJobResult> {
const { adapter, prompt, size, duration, modelOptions, middleware } = options
const model = adapter.model
const requestId = createId('video')
const startTime = Date.now()
const logger: InternalLogger = resolveDebugOption(options.debug)
const providerName =
(adapter as { name?: string; provider?: string }).provider ??
(adapter as { name?: string }).name ??
'unknown'
// `runId` is resolved per outcome (from the job, or absent on failure), so the
// context is built once the outcome is known. `options.runId` is deliberately
// not consulted: in non-streaming mode the run id is always the derived one,
// the single rule that keeps the two calls in agreement.
const contextFor = (runId?: string): GenerationMiddlewareContext =>
createGenerationContext({
requestId,
activity: 'video',
provider: adapter.name,
model,
modelOptions,
// Deliberately the CALLER's `threadId` — no minted fallback. A thread id
// nobody else knows would file the run in a slot no client could hydrate,
// which is worse than no link because it looks like one. Mirrors the
// streaming path.
threadId: options.threadId,
runId,
artifactInputs: { prompt },
createId,
})
logger.request(`activity=generateVideo provider=${providerName}`, {
provider: providerName,
model,
})
let jobResult: VideoJobResult
try {
jobResult = await adapter.createVideoJob({
model,
prompt,
size,
duration,
modelOptions,
logger,
})
} catch (error) {
// No jobId exists, so this run can only be keyed on the request. Start it
// just to fail it: `generationRuns.update` on an unknown run id is a no-op
// by contract, so without the `onStart` the failure would persist nowhere.
const failedCtx = contextFor()
await runGenerationStart(middleware, failedCtx)
await runGenerationError(middleware, failedCtx, {
error,
duration: Date.now() - startTime,
})
logger.errors('generateVideo activity failed', {
error,
source: 'generateVideo',
})
throw error
}
logger.output(`activity=generateVideo jobId=${jobResult.jobId}`, {
jobId: jobResult.jobId,
model: jobResult.model,
})
const mwCtx = contextFor(videoRunIdForJob(adapter.name, jobResult.jobId))
await runGenerationStart(middleware, mwCtx)
// Transforms see the submission result (no url yet, so nothing to copy into a
// blob store) purely so the run record captures the jobId and any prompt
// inputs. No finish hook: the run is still running.
return await applyGenerationResultTransforms(mwCtx, jobResult)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Internal streaming implementation for video generation.
* Handles the full job lifecycle: create job → poll for status → stream updates → yield final result.
*/
async function* runStreamingVideoGeneration<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
>(options: VideoCreateOptions<TAdapter, true>): AsyncIterable<StreamChunk> {
const { adapter, prompt, size, duration, modelOptions, middleware } = options
const model = adapter.model
const runId = options.runId ?? createId('run')
const requestId = createId('video')
const obsStartTime = Date.now()
const pollingInterval = options.pollingInterval ?? 2000
const maxDuration = options.maxDuration ?? 600_000
const logger: InternalLogger = resolveDebugOption(options.debug)
const providerName =
(adapter as { name?: string; provider?: string }).provider ??
(adapter as { name?: string }).name ??
'unknown'
// The wire needs a thread id on every RUN_* chunk, so one is minted when the
// caller passes none — matching `streamGenerationResult`, which the other
// activities stream through.
const wireThreadId = options.threadId ?? createId('thread')
yield {
type: 'RUN_STARTED',
runId,
threadId: wireThreadId,
timestamp: Date.now(),
} as StreamChunk
const mwCtx = createGenerationContext({
requestId,
activity: 'video',
provider: adapter.name,
model,
modelOptions,
// Identity has to reach the middleware, not just the chunks: persistence
// keys the run record on these, and without them it falls back to the
// internal `requestId` and records no thread link at all.
//
// Deliberately the CALLER's `threadId`, never `wireThreadId`: a minted id is
// known to nobody, so persisting it would file the run in a slot no client
// could ever hydrate — worse than recording no link, because it looks like
// one. This mirrors `generateImage`.
threadId: options.threadId,
runId,
artifactInputs: { prompt },
createId,
})
await runGenerationStart(middleware, mwCtx)
logger.request(
`activity=generateVideo provider=${providerName} stream=true`,
{
provider: providerName,
model,
},
)
// Tracks whether a terminal observer event (finish/error) has already fired,
// so the `finally` below can fire one on abandonment without double-firing.
let settled = false
try {
// Create the video generation job
const jobResult = await adapter.createVideoJob({
model,
prompt,
size,
duration,
modelOptions,
logger,
})
yield {
type: 'CUSTOM',
name: 'video:job:created',
value: { jobId: jobResult.jobId },
timestamp: Date.now(),
}
// Poll for completion
const startTime = Date.now()
while (Date.now() - startTime < maxDuration) {
await sleep(pollingInterval)
const statusResult = await adapter.getVideoStatus(jobResult.jobId)
yield {
type: 'CUSTOM',
name: 'video:status',
value: {
jobId: jobResult.jobId,
status: statusResult.status,
progress: statusResult.progress,
error: statusResult.error,
},
timestamp: Date.now(),
}
if (statusResult.status === 'completed') {
const urlResult = await adapter.getVideoUrl(jobResult.jobId)
logger.output(
`activity=generateVideo jobId=${jobResult.jobId} status=completed`,
{
jobId: jobResult.jobId,
url: urlResult.url,
},
)
// Run the result transforms before anything observes the result, the
// same as every other media activity. This is what lets persistence
// copy the video into a blob store, attach its artifact refs, and
// rewrite `url` to a durable app-origin one — so the chunk below and
// the stored run record carry the SAME urls. Skipping it leaves a
// result whose only url is the provider's expiring link.
const rawResult = {
jobId: jobResult.jobId,
status: 'completed' as const,
url: urlResult.url,
expiresAt: urlResult.expiresAt,
...(urlResult.usage ? { usage: urlResult.usage } : {}),
}
const result = await applyGenerationResultTransforms(mwCtx, rawResult)
// Fire finish before yielding the terminal chunks: the generation has
// succeeded, so a consumer that stops reading after `generation:result`
// (without pulling `RUN_FINISHED`) must not trip the abandonment path in
// `finally`, which would otherwise report a spurious cancellation.
if (urlResult.usage)
await runGenerationUsage(middleware, mwCtx, urlResult.usage)
await runGenerationFinish(middleware, mwCtx, {
duration: Date.now() - obsStartTime,
usage: urlResult.usage,
})
settled = true
yield {
type: 'CUSTOM',
name: 'generation:result',
value: result,
timestamp: Date.now(),
}
yield {
type: 'RUN_FINISHED',
runId,
threadId: wireThreadId,
finishReason: 'stop',
timestamp: Date.now(),
} as StreamChunk
return
}
if (statusResult.status === 'failed') {
throw new Error(statusResult.error || 'Video generation failed')
}
}
throw new Error('Video generation timed out')
} catch (error: unknown) {
const payload = toRunErrorPayload(error, 'Video generation failed')
// Mark settled before firing onError: if a user error-hook throws, the
// `finally` below must still not double-fire onAbort over the same op
// (which would mask the original error and end the span twice).
settled = true
await runGenerationError(middleware, mwCtx, {
error,
duration: Date.now() - obsStartTime,
})
logger.errors('generateVideo activity failed', {
message: payload.message,
code: payload.code,
source: 'generateVideo',
})
yield {
type: 'RUN_ERROR',
runId,
threadId: wireThreadId,
message: payload.message,
code: payload.code,
error: payload,
timestamp: Date.now(),
} as StreamChunk
} finally {
if (!settled) {
// The consumer abandoned the stream (broke the `for await` loop or
// disconnected) before completion, so the generator is being unwound at
// a `yield` without reaching finish/error. Fire `onAbort` — a cancel, not
// an error — so otelMiddleware ends its span instead of leaking it.
await runGenerationAbort(middleware, mwCtx, {
reason: 'Video generation stream abandoned before completion',
duration: Date.now() - obsStartTime,
})
}
}
}
/**
* Options for {@link getVideoJobStatus}.
*
* The run this poll finishes is identified by `adapter` + `jobId` alone — the
* same pair the submitting `generateVideo()` call derived it from — so there is
* no run id to thread through. Pass the submission's `threadId` and the same
* `middleware`.
*
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoJobStatusOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
> {
/** The video adapter to use (must be created with a model) */
adapter: TAdapter & { kind: typeof kind }
/** The job ID to check status for */
jobId: string
/**
* The scope the run is filed under. Must match the submission's `threadId` —
* generation persistence REFUSES a run without a scope (a run filed under
* none can never be hydrated by one), so omitting it throws rather than
* quietly filing the finished video somewhere unreachable.
*/
threadId?: string
/**
* Observe-only middleware. Hooks fire ONLY on the poll that observes a
* terminal job state: `onStart` (resuming the submission's run), then the
* result transforms — which is where persistence copies the video into a blob
* store and rewrites `url` to a durable one, so the returned result carries
* the same urls as the stored record — then `onFinish`, or `onError` when the
* job failed. Intermediate polls invoke nothing, so a middleware is not
* charged for the wait.
*/
middleware?: Array<GenerationMiddleware>
}
/**
* The status of a video job, plus the video itself once the job completed.
*
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoJobStatusResult {
/** Job identifier */
jobId: string
status: 'pending' | 'processing' | 'completed' | 'failed'
progress?: number
url?: string
/** When the provider url expires, if it reported one. */
expiresAt?: Date
error?: string
usage?: TokenUsage
/** Durable artifact references, when generation persistence is wired. */
artifacts?: Array<PersistedArtifactRef>
}
/**
* Get video job status - returns the current status, progress, and URL if available.
*
* This function combines status checking and URL retrieval. If the job is completed,
* it will automatically fetch and include the video URL.
*
* It is also where a non-streaming `generateVideo()` run ENDS: pass the same
* `middleware` and `threadId`, and the poll that first sees a terminal job state
* finishes the run (recording the result and its artifacts) or fails it. The run
* is identified by `adapter` + `jobId`, exactly what the submission derived it
* from, so there is nothing else to carry between the two calls.
*
* @experimental Video generation is an experimental feature and may change.
*
* @example Check job status
* ```ts
* import { getVideoJobStatus } from '@tanstack/ai'
* import { openaiVideo } from '@tanstack/ai-openai'
*
* const result = await getVideoJobStatus({
* adapter: openaiVideo('sora-2'),
* jobId: 'job-123'
* })
*
* console.log('Status:', result.status)
* console.log('Progress:', result.progress)
* if (result.url) {
* console.log('Video URL:', result.url)
* }
* ```
*
* @example Submit and poll one persisted run
* ```ts
* import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
* import { withGenerationPersistence } from '@tanstack/ai-persistence'
* import { openaiVideo } from '@tanstack/ai-openai'
*
* const adapter = openaiVideo('sora-2')
* const middleware = [withGenerationPersistence(persistence)]
*
* // Opens the run (status `running`, jobId recorded). Its run id is derived
* // from the provider job, so nothing has to be stored to resume it.
* const { jobId } = await generateVideo({
* adapter,
* prompt: 'A cat chasing a dog in a sunny park',
* threadId,
* middleware,
* })
*
* // Completes the SAME run once the job settles — this is what writes the
* // video, its artifacts, and the terminal status. Works from a different
* // request or process: the jobId is the only correlation.
* const status = await getVideoJobStatus({
* adapter,
* jobId,
* threadId,
* middleware,
* })
* ```
*/
export async function getVideoJobStatus<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
>(options: VideoJobStatusOptions<TAdapter>): Promise<VideoJobStatusResult> {
const { adapter, jobId, middleware } = options
const requestId = createId('video-status')
const startTime = Date.now()
// Built per call but only USED on a terminal poll — `onStart` is what
// registers the result transforms, so it has to run in the same call that
// applies them.
const terminalContext = (): GenerationMiddlewareContext =>
createGenerationContext({
requestId,
activity: 'video',
provider: adapter.name,
model: adapter.model,
threadId: options.threadId,
// Recomputed, never passed in: the submitting call derived the same id
// from the same provider + job, so the two halves agree without the
// caller carrying anything but the jobId they must already have.
runId: videoRunIdForJob(adapter.name, jobId),
// Deliberately no `artifactInputs`: the submission already persisted any
// prompt inputs under this run, and passing them again would store a
// second copy of every input image.
createId,
})
aiEventClient.emit('video:request:started', {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: 'status',
jobId,
timestamp: startTime,
})
// Get status first
const statusResult = await adapter.getVideoStatus(jobId)
// If completed, also get the URL
if (statusResult.status === 'completed') {
let urlResult: VideoUrlResult
// Scoped tightly to the provider call: a middleware hook that throws must
// surface as itself, not be relabelled "failed to get video URL" and then
// re-reported to the very middleware that threw.
try {
urlResult = await adapter.getVideoUrl(jobId)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get video URL'
aiEventClient.emit('video:request:completed', {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: 'status',
jobId,
status: 'failed',
progress: statusResult.progress,
error: errorMessage,
duration: Date.now() - startTime,
timestamp: Date.now(),
})
// Provider reported completed but result fetch failed — treat as failed,
// and fail the run with it: the job is terminal, so nothing later will.
await runGenerationError(middleware, terminalContext(), {
error,
duration: Date.now() - startTime,
})
return {
jobId,
status: 'failed' as const,
progress: statusResult.progress,
error: errorMessage,
}
}
aiEventClient.emit('video:request:completed', {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: 'status',
jobId,
status: statusResult.status,
progress: statusResult.progress,
url: urlResult.url,
duration: Date.now() - startTime,
timestamp: Date.now(),
})
if (urlResult.usage) {
aiEventClient.emit('video:usage', {
requestId,
model: adapter.model,
usage: urlResult.usage,
timestamp: Date.now(),
})
}
const mwCtx = terminalContext()
await runGenerationStart(middleware, mwCtx)
const result = await applyGenerationResultTransforms<VideoJobStatusResult>(
mwCtx,
{
jobId,
status: 'completed',
...(statusResult.progress !== undefined
? { progress: statusResult.progress }
: {}),
url: urlResult.url,
...(urlResult.expiresAt ? { expiresAt: urlResult.expiresAt } : {}),
...(urlResult.usage ? { usage: urlResult.usage } : {}),
},
)
if (urlResult.usage)
await runGenerationUsage(middleware, mwCtx, urlResult.usage)
await runGenerationFinish(middleware, mwCtx, {
duration: Date.now() - startTime,
usage: urlResult.usage,
})
return result
}
aiEventClient.emit('video:request:completed', {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: 'status',
jobId,
status: statusResult.status,
progress: statusResult.progress,
error: statusResult.error,
duration: Date.now() - startTime,
timestamp: Date.now(),
})
// A failed job is terminal for the run too: without this the record would sit
// at `running` forever, indistinguishable from a job still being worked on.
if (statusResult.status === 'failed') {
await runGenerationError(middleware, terminalContext(), {
error: new Error(statusResult.error || 'Video generation failed'),
duration: Date.now() - startTime,
})
}
// Return status for non-completed jobs
return {
jobId,
status: statusResult.status,
progress: statusResult.progress,
error: statusResult.error,
}
}
// ===========================
// Options Factory
// ===========================
/**
* Create typed options for the generateVideo() function without executing.
*/
export function createVideoOptions<
TAdapter extends VideoAdapter<string, any, any, any, any, any>,
TStream extends boolean = false,
>(
options: VideoCreateOptions<TAdapter, TStream>,
): VideoCreateOptions<TAdapter, TStream> {
return options
}
// Re-export adapter types
export type {
VideoAdapter,
VideoAdapterConfig,
AnyVideoAdapter,
} from './adapter'
export { BaseVideoAdapter } from './adapter'