AI video generation is not a normal HTTP request. A user submits a prompt, model settings, and maybe a reference image, but the result may take minutes. The backend has to validate input, create a job, submit it to a provider, poll for completion, handle failures, and show a useful status to the user.
This post walks through a small TypeScript structure for an AI video job queue. The goal is not to build a complete production system in one article. The goal is to define the core contracts clearly enough that provider-specific APIs do not leak through the entire app.
The Problem
A basic AI video workflow has at least six steps:
- Validate prompt and model settings.
- Validate uploaded or referenced assets.
- Create an internal job record.
- Submit the job to a provider.
- Poll until the output is ready or failed.
- Normalize the result for the UI.
If the app supports more than one model or provider, the backend should not let each provider shape the rest of the product. A queue and adapter layer helps keep the system stable.
Define the Job
Start with an internal job type:
type JobStatus =
| "queued"
| "validating"
| "running"
| "delayed"
| "succeeded"
| "failed";
type VideoJob = {
id: string;
userId: string;
model: string;
prompt: string;
aspectRatio: "16:9" | "9:16" | "1:1";
durationSeconds: number;
status: JobStatus;
providerTaskId?: string;
outputUrl?: string;
errorCode?: VideoJobErrorCode;
};
type VideoJobErrorCode =
| "validation_failed"
| "moderation_rejected"
| "provider_timeout"
| "provider_rate_limited"
| "asset_fetch_failed"
| "output_missing"
| "unknown_provider_error";
This type is the product contract. Provider-specific payloads should be translated into and out of it.
Create a Provider Adapter
Each provider can have its own adapter, but the queue should talk to one interface.
interface VideoProvider {
submit(job: VideoJob): Promise<{ providerTaskId: string }>;
poll(providerTaskId: string): Promise<
| { status: "running" }
| { status: "succeeded"; outputUrl: string }
| { status: "failed"; error: unknown }
>;
normalizeError(error: unknown): VideoJobErrorCode;
}
This keeps provider differences out of the queue worker, billing logic, and UI.
Validate Before Dispatch
Video jobs can be slow and expensive. Do the cheap checks first.
type ValidationResult =
| { ok: true }
| { ok: false; code: VideoJobErrorCode; message: string };
function validateVideoJob(job: VideoJob): ValidationResult {
if (!job.prompt.trim()) {
return {
ok: false,
code: "validation_failed",
message: "Prompt is required.",
};
}
if (job.durationSeconds < 1 || job.durationSeconds > 10) {
return {
ok: false,
code: "validation_failed",
message: "Duration must be between 1 and 10 seconds.",
};
}
return { ok: true };
}
In a real app, validation would also check model availability, uploaded file limits, aspect ratio support, user credits, and safety rules.
Process the Job
Here is a small worker function:
async function processVideoJob(job: VideoJob, provider: VideoProvider) {
await markStatus(job.id, "validating");
const validation = validateVideoJob(job);
if (!validation.ok) {
return markFailed(job.id, validation.code);
}
await markStatus(job.id, "running");
const { providerTaskId } = await provider.submit(job);
await saveProviderTaskId(job.id, providerTaskId);
for (const delay of [5000, 10000, 15000, 30000, 60000]) {
await sleep(delay);
const result = await provider.poll(providerTaskId);
if (result.status === "succeeded") {
return markSucceeded(job.id, result.outputUrl);
}
if (result.status === "failed") {
return markFailed(job.id, provider.normalizeError(result.error));
}
}
return markStatus(job.id, "delayed");
}
The important behavior is the hard transition to delayed. The job should not remain running forever. A separate recovery worker can continue polling delayed jobs at a slower cadence.
Stub the Persistence Functions
The worker above assumes a database layer. For a minimal demo, those functions might look like this:
async function markStatus(jobId: string, status: JobStatus) {
console.log("markStatus", { jobId, status });
}
async function saveProviderTaskId(jobId: string, providerTaskId: string) {
console.log("saveProviderTaskId", { jobId, providerTaskId });
}
async function markSucceeded(jobId: string, outputUrl: string) {
console.log("markSucceeded", { jobId, outputUrl });
}
async function markFailed(jobId: string, errorCode: VideoJobErrorCode) {
console.log("markFailed", { jobId, errorCode });
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
In production, these functions should update durable storage and be safe to retry.
UI Status Messages
The UI should not expose raw provider errors. It should map internal status and normalized errors to clear messages:
| Status or error | User-facing message |
|---|---|
queued |
Your video is waiting to start. |
running |
Your video is being generated. |
delayed |
This is taking longer than usual. We are still checking. |
moderation_rejected |
This request could not be processed. Try changing the prompt or assets. |
asset_fetch_failed |
We could not read the input asset. Please upload it again. |
provider_timeout |
The provider did not return a result in time. |
Predictable status messages reduce support tickets and make failures less confusing.
Where Documentation Helps
If you are working with Seedance-style video APIs, document duration limits, aspect ratios, polling behavior, input asset requirements, and moderation behavior clearly. LumiYing's Seedance API guide is an example of the kind of user-facing documentation this workflow needs:
https://lumiying.com/resource/seedance-2-api
Conclusion
AI video generation should be treated as a job system. A stable queue, provider adapter interface, polling strategy, error taxonomy, and user-readable status layer make the product easier to maintain.
The backend should not hide every provider difference. It should normalize the parts that need to be stable and expose the constraints that users need to understand.
Top comments (0)