DEV Community

miao cunhui
miao cunhui

Posted on

Building an AI Video Job Queue in TypeScript

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:

  1. Validate prompt and model settings.
  2. Validate uploaded or referenced assets.
  3. Create an internal job record.
  4. Submit the job to a provider.
  5. Poll until the output is ready or failed.
  6. 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";
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)