@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
472 lines (471 loc) • 15.2 kB
JavaScript
import { toRunErrorPayload } from "../error-payload.js";
import { resolveDebugOption } from "../../logger/resolve.js";
import { applyGenerationResultTransforms, createGenerationContext, runGenerationAbort, runGenerationError, runGenerationFinish, runGenerationStart, runGenerationUsage } from "../middleware/run.js";
import "./adapter.js";
import { aiEventClient } from "@tanstack/ai-event-client";
//#region src/activities/generateVideo/index.ts
/**
* 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.
*/
/** The adapter kind this activity handles */
var kind = "video";
function createId(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* 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)
* ```
*/
function generateVideo(options) {
if (options.stream) return runStreamingVideoGeneration(options);
return runCreateVideoJob(options);
}
/**
* 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, jobId) {
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(options) {
const { adapter, prompt, size, duration, modelOptions, middleware } = options;
const model = adapter.model;
const requestId = createId("video");
const startTime = Date.now();
const logger = resolveDebugOption(options.debug);
const providerName = adapter.provider ?? adapter.name ?? "unknown";
const contextFor = (runId) => createGenerationContext({
requestId,
activity: "video",
provider: adapter.name,
model,
modelOptions,
threadId: options.threadId,
runId,
artifactInputs: { prompt },
createId
});
logger.request(`activity=generateVideo provider=${providerName}`, {
provider: providerName,
model
});
let jobResult;
try {
jobResult = await adapter.createVideoJob({
model,
prompt,
size,
duration,
modelOptions,
logger
});
} catch (error) {
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);
return await applyGenerationResultTransforms(mwCtx, jobResult);
}
function sleep(ms) {
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(options) {
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 ?? 2e3;
const maxDuration = options.maxDuration ?? 6e5;
const logger = resolveDebugOption(options.debug);
const providerName = adapter.provider ?? adapter.name ?? "unknown";
const wireThreadId = options.threadId ?? createId("thread");
yield {
type: "RUN_STARTED",
runId,
threadId: wireThreadId,
timestamp: Date.now()
};
const mwCtx = createGenerationContext({
requestId,
activity: "video",
provider: adapter.name,
model,
modelOptions,
threadId: options.threadId,
runId,
artifactInputs: { prompt },
createId
});
await runGenerationStart(middleware, mwCtx);
logger.request(`activity=generateVideo provider=${providerName} stream=true`, {
provider: providerName,
model
});
let settled = false;
try {
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()
};
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
});
const result = await applyGenerationResultTransforms(mwCtx, {
jobId: jobResult.jobId,
status: "completed",
url: urlResult.url,
expiresAt: urlResult.expiresAt,
...urlResult.usage ? { usage: urlResult.usage } : {}
});
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()
};
return;
}
if (statusResult.status === "failed") throw new Error(statusResult.error || "Video generation failed");
}
throw new Error("Video generation timed out");
} catch (error) {
const payload = toRunErrorPayload(error, "Video generation failed");
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()
};
} finally {
if (!settled) await runGenerationAbort(middleware, mwCtx, {
reason: "Video generation stream abandoned before completion",
duration: Date.now() - obsStartTime
});
}
}
/**
* 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,
* })
* ```
*/
async function getVideoJobStatus(options) {
const { adapter, jobId, middleware } = options;
const requestId = createId("video-status");
const startTime = Date.now();
const terminalContext = () => createGenerationContext({
requestId,
activity: "video",
provider: adapter.name,
model: adapter.model,
threadId: options.threadId,
runId: videoRunIdForJob(adapter.name, jobId),
createId
});
aiEventClient.emit("video:request:started", {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: "status",
jobId,
timestamp: startTime
});
const statusResult = await adapter.getVideoStatus(jobId);
if (statusResult.status === "completed") {
let urlResult;
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()
});
await runGenerationError(middleware, terminalContext(), {
error,
duration: Date.now() - startTime
});
return {
jobId,
status: "failed",
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(mwCtx, {
jobId,
status: "completed",
...statusResult.progress !== void 0 ? { 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()
});
if (statusResult.status === "failed") await runGenerationError(middleware, terminalContext(), {
error: new Error(statusResult.error || "Video generation failed"),
duration: Date.now() - startTime
});
return {
jobId,
status: statusResult.status,
progress: statusResult.progress,
error: statusResult.error
};
}
/**
* Create typed options for the generateVideo() function without executing.
*/
function createVideoOptions(options) {
return options;
}
//#endregion
export { createVideoOptions, generateVideo, getVideoJobStatus, kind };
//# sourceMappingURL=index.js.map