Technology

From Nano Banana to Wan 3.0: How to Stop the Image-to-Video Race Condition

An image-to-video chain has two remote jobs. Nano Banana returns a task identifier before the image exists. Wan 3.0 must not receive its first-frame request until that task is complete...
Published:
8 MIN READ
Nano Banana

An image-to-video chain has two remote jobs. Nano Banana returns a task identifier before the image exists. Wan 3.0 must not receive its first-frame request until that task is complete and its output address is saved.

The race appears when automation treats “image POST succeeded” as “image exists.” The video step then receives an empty address or a submission object. A fixed delay makes the bug less frequent; it does not guarantee order.

Use a durable state machine: one owner per transition, bounded polling, and restart from saved task identifiers.

The Invariant That Prevents the Race

Write this rule at the top of the workflow:

A Wan 3.0 submission is legal only when the image state is image_complete and a non-empty image_url has been committed to storage.

A successful POST or an image_task_id is insufficient. The ID makes the job pollable; it does not populate output.image_urls.

Likewise, a Wan submission returns an ID; a later completed task carries output.video_urls.

Use States That Describe Evidence

Avoid one vague status such as running. The following states say exactly what is known:

State Durable evidence Permitted next action
image_ready prompt and local pipeline key saved claim the image submission lease
image_submitting one worker owns the POST attempt submit once; do not let another worker enter
image_submit_unknown request may have arrived, but no task ID was saved reconcile or review; never auto-submit
image_processing image task ID saved poll that ID
image_complete image URL saved prepare the Wan request
video_ready image URL and motion prompt saved claim the video submission lease
video_submitting one worker owns the POST attempt submit once
video_submit_unknown Wan may have accepted the request reconcile or review; never auto-submit
video_processing video task ID saved poll that ID
complete video URL saved publish or archive according to policy
failed terminal error saved show the error; retry only under an explicit rule

If a POST arrives remotely but its response is lost, the client cannot know if a task exists. An automatic repeat may create a second job, which is why submit_unknown is distinct.

Freeze a Regression Fixture First

Approve one Nano Banana still, its prompt, one five-second Wan 3.0 motion prompt, the ratio and first-frame checksum. Assemble that fixture in ClipDance and freeze it while testing orchestration, so prompt revisions cannot be mistaken for state failures.

The fixture answers four operational questions: Was one image task created? Did video wait? Did restart reuse both IDs? Was one final video recorded?

Store the Workflow Before the First POST

Create the local row first. A unique pipeline_key makes duplicate events resolve to one workflow.

Field Purpose
pipeline_key stable business key, unique
state one of the states above
recipe_version freezes prompts and generation settings
image_payload_hash detects a changed image request
image_task_id enables image polling after restart
image_url gates the video submission
video_payload_hash detects a changed video request
video_task_id enables video polling after restart
video_url final result
lease_until stops concurrent submitters
last_error preserves a terminal or ambiguous failure
updated_at supports monitoring and stale-job alerts

Derive the key from an order ID, asset ID and recipe version—not the current time. A new random key on retry defeats deduplication.

Submit Once, Then Poll by ID

Send both jobs through reAPI, save each POST’s returned id, and poll the task route until completed or failed.

This reference skeleton uses the documented model IDs and response fields at publication. Its store methods must be transactional. Run contract tests against current documentation before production use; this is not a claimed live test.

The image example uses a Nano Banana 2 route documented by reAPI at publication. Before running it, read the exact image and video IDs returned for the API key by /v1/models; keep those IDs in the recipe instead of substituting a marketing family name.

const API_BASE = process.env.REAPI_API_BASE;

const API_KEY = process.env.REAPI_API_KEY;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function request(path, options = {}) {

  const response = await fetch(`${API_BASE}${path}`, {

    …options,

    headers: {

      Authorization: `Bearer ${API_KEY}`,

      ‘Content-Type’: ‘application/json’,

      …options.headers,

    },

  });

  const body = await response.json().catch(() => ({}));

  if (!response.ok) {

    const error = new Error(`Request rejected with ${response.status}`);

    error.responseReceived = true;

    error.status = response.status;

    error.details = body;

    throw error;

  }

  return body;

}

async function submit(path, payload) {

  const task = await request(path, {

    method: ‘POST’,

    body: JSON.stringify(payload),

  });

  if (!task.id) throw new Error(‘Successful response did not contain id’);

  return task.id;

}

async function pollBounded(taskId, maxWaitMs = 120_000) {

  const deadline = Date.now() + maxWaitMs;

  let delayMs = 2_500;

  while (Date.now() < deadline) {

    try {

      const task = await request(`/api/v1/tasks/${taskId}`);

      if (task.status === ‘completed’ || task.status === ‘failed’) return task;

    } catch (error) {

      const retryable =

        !error.responseReceived || error.status === 429 || error.status >= 500;

      if (!retryable) throw error;

    }

    await sleep(Math.min(delayMs, Math.max(0, deadline – Date.now())));

    delayMs = Math.min(Math.round(delayMs * 1.5), 10_000);

  }

  return { id: taskId, status: ‘processing’ };

}

async function advance(jobId, store) {

  let job = await store.get(jobId);

  if (job.state === ‘image_ready’) {

    job = await store.claim(jobId, ‘image_ready’, ‘image_submitting’);

    if (!job) return;

    try {

      const id = await submit(‘/api/v1/images/generations’, {

        model: ‘gemini-3.1-flash-image-preview’,

        prompt: job.imagePrompt,

        size: job.size,

        resolution: job.imageResolution,

        n: 1,

      });

      await store.saveTask(jobId, ‘image’, id, ‘image_processing’);

    } catch (error) {

      if (error.responseReceived && error.status < 500) {

        await store.fail(jobId, error.details);

      } else {

        await store.markUnknown(jobId, ‘image’, String(error));

      }

    }

    return;

  }

  if (job.state === ‘image_processing’) {

    const task = await pollBounded(job.imageTaskId);

    if (task.status === ‘processing’) return;

    if (task.status === ‘failed’) return store.fail(jobId, task.error);

    const imageUrl = task.output?.image_urls?.[0];

    if (!imageUrl) return store.fail(jobId, ‘Completed image has no URL’);

    await store.saveImage(jobId, imageUrl, ‘video_ready’);

    return;

  }

  if (job.state === ‘video_ready’) {

    job = await store.claim(jobId, ‘video_ready’, ‘video_submitting’);

    if (!job) return;

    try {

      const id = await submit(‘/api/v1/videos/generations’, {

        model: ‘wan3.0-video’,

        prompt: job.motionPrompt,

        image_with_roles: [{ url: job.imageUrl, role: ‘first_frame’ }],

        size: job.size,

        resolution: job.videoResolution,

        duration: 5,

      });

      await store.saveTask(jobId, ‘video’, id, ‘video_processing’);

    } catch (error) {

      if (error.responseReceived && error.status < 500) {

        await store.fail(jobId, error.details);

      } else {

        await store.markUnknown(jobId, ‘video’, String(error));

      }

    }

    return;

  }

  if (job.state === ‘video_processing’) {

    const task = await pollBounded(job.videoTaskId);

    if (task.status === ‘processing’) return;

    if (task.status === ‘failed’) return store.fail(jobId, task.error);

    const videoUrl = task.output?.video_urls?.[0];

    if (!videoUrl) return store.fail(jobId, ‘Completed video has no URL’);

    await store.saveVideo(jobId, videoUrl, ‘complete’);

  }

}

A response without an id is uncertain because remote acceptance cannot be proved. Classify it as submit_unknown, not as permission for another POST.

Bound Polling Without Turning a Timeout Into Failure

A polling deadline limits one worker invocation; it does not cancel the job. On processing, keep the state and schedule another worker with the same ID.

Retry temporary read errors with capped backoff and jitter. Alert on authentication errors or a confirmed missing task. Never send a polling timeout to image_ready or video_ready, because both permit a POST.

If a worker dies in image_submitting, returning it to image_ready may duplicate an accepted request. Move it to image_submit_unknown. Without documented remote idempotency or client-key lookup, a lost response prevents an exactly-once guarantee; reconcile or review it.

Test the Failure Paths

Run these orchestration tests with a mocked transport before using generation credits:

  1. Two workers claim the same image_ready row; only one obtains the lease.
  2. The image POST succeeds and its task ID is saved; a restart resumes polling.
  3. The connection drops during an image POST; the row becomes image_submit_unknown.
  4. Image polling reaches its local deadline; no second image POST occurs.
  5. An image completes without image_urls; the video POST never runs.
  6. The image URL is saved, then the process dies; the next worker submits video once.
  7. Video polling returns failed; the error is stored and no output is published.
  8. The same incoming event arrives twice; the unique pipeline key returns one row.

Archive completed assets under the project’s retention policy; an output address is only a delivery field.

Frequently Asked Questions

Why not solve the race with a 60-second delay?

Generation time varies, so a delay can be too short or wasteful. Polling a saved ID checks actual state and survives restarts.

Can the worker automatically retry every failed POST?

Only when non-acceptance is certain. A timeout or dropped connection may hide a created task; use submit_unknown instead of risking a duplicate.

What makes resume different from retry?

Resume polls an existing task ID. Retry creates a new job. Replace only after a terminal failure and an explicit policy decision.

Emily Grace
WRITTEN BY

Emily Grace

557 ARTICLES

Hi, I’m Emily Grace, a blogger with over 4 years of experience in sharing thoughts about blessings, prayers, and mindful living. I love writing words that inspire peace, faith, and positivity in everyday life.

SHARE THIS ARTICLE

READ NEXT

Leave a Comment