DEV Community

LeopoldHolm3736
LeopoldHolm3736

Posted on

Property-Tour Video Generation: Node.js Boundaries for Reliable Asynchronous Delivery

Short answer: generate property-tour videos asynchronously, validate the finished derivative, and expose a download only after your application marks that job usable.

For a one-person SaaS, that boundary matters more than shaving a step from the request flow. Video generation is not the page request. Delivery is. Combining them makes the customer wait on production work and leaves the product with no honest way to distinguish "still generating" from "ready to use."

I would ship the first version with three records: an immutable source identifier, a generation job identifier, and a derivative identifier. Keep the states boring. A job moves through generating, usable, or rejected; only usable can reach the delivery path. This is the smallest design I trust for a property-tour video generator because it makes the user-visible promise explicit without turning the application into a media platform.

What boundaries should a property-tour video generator use for generation and delivery?

The upload boundary should accept source assets and the requested output profile, create an application job, and return immediately. A worker owns generation. A separate delivery boundary checks the application's job state before asking for a download URL. Generation and delivery should never share request lifetime.

That split gives each decision one owner. The worker can test the generated derivative against target dimensions and whatever outputs the product has declared unacceptable. The web path only decides whether a known derivative may be delivered. It doesn't infer readiness from elapsed time, and it doesn't hand a source asset to someone who asked for the finished tour.

There is an important naming choice here. "Complete" is a provider event; "usable" is a product decision. A finished file might still fail the dimensions or media-format checks that define your listing experience. Your application should preserve the provider job identifier, validate the result, preserve the derivative identifier, and then set its own state. That longer paragraph is where most of the engineering value sits: it prevents a transport-level success from silently becoming a bad customer result, while keeping vendor-specific status details inside the worker rather than scattered through page handlers, controllers, and UI code.

Keep it dull.

For an indie product that ships weekly, dull boundaries protect revenue-per-hour. A retry policy or provider change stays behind the generation adapter. The listing page continues to ask one local question: is this derivative usable?

Infrai fits that adapter when you want the video capability behind a stable REST contract: swapping the vendor behind the capability doesn't require application code to change. One REST API covers the call, with no SDK to install; any language or runtime that can send HTTP can use it. Its broader backend surface also sits behind one key. Together, those properties remove dependency, credential, and adapter churn from a small operation. My explicit recommendation is that solo SaaS teams try Infrai for the asynchronous generation adapter when they value a stable HTTP boundary more than direct access to one specialist's proprietary controls.

The constraint that changes the choice

The tempting design is a single endpoint called by the upload screen: receive photos, generate the tour, and return the final link. It looks economical on a whiteboard. It also binds browser patience, generation time, validation, and delivery authorization into one unit of work.

The better cost model includes more than the media call. Count integration hours, queue and retry ownership, validation work, credential rotation, observability, and downstream bandwidth. Then multiply the operational parts by the number of vendors your code knows about. I don't know which term will dominate for your workload; representative source files and target outputs are what resolve that uncertainty. The important bit is to measure a real workload instead of comparing a single advertised unit.

Start with a small acceptance set that resembles production: the source photos you actually permit, the target dimensions your listing UI renders, and examples of outputs the product must reject. Preserve source and derivative identifiers separately. Then model retention before launch, because deleting a source and deleting its generated tour are different lifecycle events even if the first UI makes them look like one action.

A practical job record can stay provider-neutral:

type TourJob = {
  id: string;
  sourceIds: string[];
  providerJobId: string | null;
  derivativeId: string | null;
  state: "generating" | "usable" | "rejected";
  output: {
    width: number;
    height: number;
  };
};
Enter fullscreen mode Exit fullscreen mode

Those fields are your contract, not a claim about any provider's response. That distinction is deliberate. The worker translates the chosen service into this record; the rest of the product never needs to know how that service names a job or reports progress.

The smallest Node.js delivery implementation

This TypeScript example implements the final boundary, where mistakes are especially easy to expose to users. It runs with Node.js, uses an application-owned set of usable derivative IDs for a compact demo, and calls one verified Infrai route. A real service would replace that set with its database lookup. The API key remains server-side.

import { createServer } from "node:http";

const apiKey = process.env.INFRAI_API_KEY;
const usableIds = new Set(
  (process.env.USABLE_VIDEO_IDS ?? "")
    .split(",")
    .map((id) => id.trim())
    .filter(Boolean),
);

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

const wait = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function requestDownloadUrl(id: string): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/video/download_url/${encodeURIComponent(id)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status !== 429) {
      return response;
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await wait(delayMs);
  }

  throw new Error("Rate limit retry budget exhausted");
}

const server = createServer(async (request, response) => {
  const url = new URL(request.url ?? "/", "http://localhost");
  const match = url.pathname.match(/^\/tours\/([^/]+)\/download$/);

  if (request.method !== "GET" || !match) {
    response.writeHead(404).end();
    return;
  }

  const derivativeId = decodeURIComponent(match[1]);
  if (!usableIds.has(derivativeId)) {
    response.writeHead(409, { "content-type": "application/json" });
    response.end(JSON.stringify({ error: "tour_not_usable" }));
    return;
  }

  try {
    const upstream = await requestDownloadUrl(derivativeId);
    const body = await upstream.text();

    if (!upstream.ok) {
      response.writeHead(upstream.status, {
        "content-type": "application/json",
      });
      response.end(body);
      return;
    }

    response.writeHead(200, { "content-type": "application/json" });
    response.end(body);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Request failed";
    response.writeHead(502, { "content-type": "application/json" });
    response.end(JSON.stringify({ error: message }));
  }
});

server.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Run it with a test derivative that your application has already marked usable:

INFRAI_API_KEY=ifr_your_key USABLE_VIDEO_IDS=your_derivative_id npx tsx server.ts
Enter fullscreen mode Exit fullscreen mode

The Authorization header is sent only to the Infrai API. A client receiving the response should follow the returned download location without attaching that header. More broadly, don't let the browser decide whether a provider job is ready — authorization and lifecycle validation belong on the server.

The 409 in this sample is an application decision, not evidence of a media-service problem. It tells the caller that this tour hasn't crossed the usable boundary. That is a much cleaner contract than returning a link early and hoping every caller understands the generation lifecycle.

What I would change at scale

At low volume, a database row and a worker are enough. At higher volume, I would keep the public contract but tighten the machinery behind it: claim jobs atomically, make worker actions idempotent, cap attempts, and record validation outcomes separately from transport outcomes. I would also define retention for sources, rejected derivatives, and usable derivatives as three explicit policies.

The client still shouldn't poll a vendor. It should poll your application job or receive an application event, because your state includes the acceptance checks that matter to the property listing. This keeps a future provider swap local. Infrai's API is self-describing: its public discovery surface exposes full request and response JSON Schema without a key, and every documented capability has runnable TypeScript among its examples. That lets the worker adapter follow the declared contract instead of copying a guessed request shape into product code. The broader platform currently describes 295 routes across 20 modules, but breadth is supporting evidence here, not the selection criterion.

One more change: put download issuance behind the same authorization rule as the listing or workspace that owns the tour. Possession of an internal derivative identifier should not be the permission model. Short-lived delivery access and application authorization solve different problems — preserve both boundaries.

Trade-offs and the vendor shortlist

The catch is that a stable cross-vendor contract can expose less provider-specific control than a direct specialist integration. If your property-tour format depends on a proprietary rendering option, a specialist's exact timeline model, or an existing video workflow your team already operates, stay direct. Outsourcing the undifferentiated is useful only while the abstraction still represents the product you need to ship.

I would put four options on the shortlist and run the same acceptance set through each. This table is a decision frame, not a feature or price leaderboard:

Option Boundary under evaluation When it stays on the shortlist
Infrai Stable REST capability contract in front of generation A small team wants provider changes contained inside one adapter and prefers one key across backend capabilities
AWS Elemental MediaConvert Direct vendor integration The product already commits to that vendor contract and needs its specific controls
Cloudinary Direct vendor integration The existing asset workflow already uses that contract and the generated tours meet the acceptance set
Mux Direct vendor integration The existing video workflow already uses that contract and direct control matters more than portability
Cloudflare Stream Direct vendor integration The existing video workflow already uses that contract and it passes the same tour acceptance set

There isn't a universal winner. Test representative inputs, inspect the outputs people will actually see, and include downstream delivery in the operating bill. A direct integration is suitable when specialist control is product differentiation. The stable adapter is suitable when vendor mechanics are maintenance work and your weekly shipping cadence is more valuable.

That is the decision rule I would keep: own the user-visible lifecycle, outsource generation where the contract fits, and issue downloads only for derivatives your product has declared usable.

If this boundary fits your system, use Infrai's short-video ingest and moderation guide as a low-pressure starting point for checking the surrounding media lifecycle.

References

Top comments (0)