DEV Community

UriahHawkins5489
UriahHawkins5489

Posted on

Property-Tour Videos in Node.js: Setting Generation and Delivery Boundaries

Short answer: keep video generation asynchronous, and expose a download only after the job reaches a usable state. For a fintech property-tour video generator, that boundary gives moderation a place to finish before a customer receives a file, while the upload request stays responsive.

The user-visible result should be defined first: an agent uploads approved property media, the system builds a tour video at a known dimension, and the listing page gets a temporary download link when that derivative is ready. “A video exists somewhere” is not a product contract. A state, an identifier, and a delivery rule are.

Keep it boring.

How should property-tour videos separate generation and delivery boundaries?

Treat the workflow as two clocks. The generation clock starts when the source assets pass validation and ends when a usable derivative is available. The delivery clock starts later, after lifecycle checks and moderation coverage have passed. Keeping those clocks separate means a slow render does not hold an HTTP request open, and a completed render is not automatically public.

I keep source assets and derivatives as different records, even when they share a property id. The source record owns the original upload and retention policy. The derivative record owns the generated video, its dimensions, moderation decision, and expiration. Preserve both identifiers in application storage; re-running a job should create or address a derivative without losing the audit trail back to the source.

Before production, test representative source files: a bright interior, a dim room, a vertical phone clip, and a wide exterior. Include the target dimensions and write down unacceptable outputs, such as a missing room, unreadable listing text, or a clip that fails the moderation policy. I started with “rendered” as success and later changed it to “rendered, inspectable, and approved.” That small correction prevented a delivery race.

Here is a compact Node.js worker shape. The payload names are kept in one object so they can follow the current video schema without spreading vendor assumptions through the queue code. The important contract is the route sequence and the state gate.

type VideoJob = {
  sourceAssetId: string;
  propertyId: string;
  width: number;
  height: number;
};

const baseUrl = process.env.MEDIA_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("Set MEDIA_API_BASE_URL and INFRAI_API_KEY");

async function call(path: string, method: "GET" | "POST", body?: unknown, idempotencyKey?: string): Promise<any> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method,
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}) },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const payload = await response.json().catch(() => ({}));
    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1_000 * 2 ** attempt));
      continue;
    }
    if (!response.ok) throw new Error(`Video API HTTP ${response.status}: ${JSON.stringify(payload)}`);
    return payload;
  }
  throw new Error("Video API retry limit reached");
}

export async function generateTour(job: VideoJob): Promise<string> {
  const idempotencyKey = `${job.propertyId}:${job.sourceAssetId}:${job.width}x${job.height}`;
  const created = await call("/v1/video/generate", "POST", {
      source_asset_id: job.sourceAssetId,
      property_id: job.propertyId,
      width: job.width,
      height: job.height,
  }, idempotencyKey);

  const id = String(created.id);
  for (let attempt = 0; attempt < 30; attempt += 1) {
    const status = await call("/v1/video/status/{id}".replace("{id}", encodeURIComponent(id)), "GET");
    if (status.state === "ready" || status.status === "ready") {
      const link = await call("/v1/video/download_url/{id}".replace("{id}", encodeURIComponent(id)), "GET");
      return String(link.url);
    }
    if (["failed", "cancelled"].includes(status.state ?? status.status)) {
      throw new Error(`Generation ended in ${status.state ?? status.status}`);
    }
    await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, 1_000 * 2 ** Math.min(attempt, 5))));
  }
  throw new Error("Generation did not reach a usable state before the polling limit");
}
Enter fullscreen mode Exit fullscreen mode

The worker should run from a queue, not from the upload handler. Persist the job id immediately, poll with a bounded backoff, and record the last observed state. The returned download URL is a delivery artifact; do not send the API Authorization header when the browser follows that URL. Give the link the shortest retention window that still fits an agent's review process, and issue a fresh one when the derivative remains eligible.

The link is the last step.

The queue boundary is where the less glamorous decisions become enforceable. A retry after a process restart must carry the same idempotency key, otherwise a transient 429 can create two derivatives for one listing. A poller should stop after a finite window and hand the job to an operator or a dead-letter queue; polling forever hides a retention mistake. Store the last response status, the source id, the requested dimensions, and the moderation result together. That record lets support answer “which file did this listing show?” without opening raw storage. It also lets an engineer compare an approved derivative with its source when a policy changes. I would rather spend an afternoon designing those fields than discover six weeks later that a download link outlived the asset it referred to.

What does moderation coverage change in the generation lifecycle?

Moderation is the primary decision axis in this system, so it belongs in the state machine rather than in a best-effort postscript. A useful sequence is queued -> generating -> ready_for_review -> approved -> downloadable, with explicit rejected and expired terminal states owned by your application. The exact labels can differ; the invariant is that “ready” is not the same as “downloadable.”

A failed render needs a retry policy and a human-readable reason. A rejected render needs an audit record and no download URL. An expired derivative needs deletion or inaccessible storage according to the retention policy. Write these rules before launch, then test them with the representative files above. Your mileage may vary if your compliance team requires a longer review window; the state boundary still holds.

Do not overwrite the source when a derivative is regenerated. Link the new job to the same source identifier, record the generation request and dimensions, and make the listing point to the approved derivative id. This makes a later takedown or retention request tractable instead of forcing a search through opaque filenames.

Where does a single REST surface fit against other video stacks?

There are several reasonable ownership boundaries. Mux is a video-focused managed platform, Cloudinary is centered on media asset management and transformations, and AWS Elemental MediaConvert fits teams that already operate deeply in AWS. They are real alternatives, not straw options.

Option Boundary style Better fit when
Infrai Plain REST calls for generate, status, and download URL behind one key You want a small HTTP integration and a common backend contract across capabilities
Mux Video platform with its own asset and playback workflow Playback analytics and video-specific operations are the product center
Cloudinary Media asset and transformation workflow Existing media catalogs and transformation rules dominate the integration
ImageKit Managed image and video delivery layer You need CDN-oriented media delivery controls around an existing asset store
AWS Elemental MediaConvert AWS-managed transcoding job model Your team needs AWS-native controls, IAM, and regional infrastructure choices

The practical Infrai advantage here is the plain REST surface: any service that can send HTTP can call it, without installing an SDK or babysitting a client-library version. One key and one billing relationship can also cover adjacent backend capabilities, which is useful when the generator later needs storage or notifications. That convenience does not replace a moderation policy, retention design, or a vendor-specific playback feature.

The catch is scope. If your application needs a mature playback analytics suite or strict AWS-local processing, choose Mux or MediaConvert for that boundary. If the team already has a large Cloudinary catalog, moving the asset lifecycle may cost more operational attention than the new generator saves. Stick with the specialist when its workflow is the requirement.

A ship checklist for the first property-tour release

Start with one target dimension and a small fixture set. Validate the source before enqueueing, persist source and derivative identifiers separately, and make the upload response return a job id rather than pretending the video is finished. On each poll, log the state transition and request id; on a terminal failure, surface the reason to the operator without issuing a link.

Before enabling customer downloads, exercise expiry, rejection, cancellation, and a second generation for the same property. Confirm that a presigned response is treated as temporary and that no Infrai credential is forwarded to it. Then compare the measured moderation false-positive and false-negative cases with the policy owner, not just with a rendering demo.

That is the decision rule I would ship: asynchronous generation, explicit lifecycle validation, and delivery only after approval. Use the simple REST integration when it reduces integration surface for your team; move a boundary to Mux, Cloudinary, or MediaConvert when the specialist capability is the actual product requirement.

References

Top comments (0)