<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: fei gao</title>
    <description>The latest articles on DEV Community by fei gao (@fei_gao_599260a6621676332).</description>
    <link>https://dev.to/fei_gao_599260a6621676332</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4091971%2F40514a81-50ab-4ac8-8196-2b6c1d5b5e63.png</url>
      <title>DEV Community: fei gao</title>
      <link>https://dev.to/fei_gao_599260a6621676332</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fei_gao_599260a6621676332"/>
    <language>en</language>
    <item>
      <title>Building Wan 3.0: The Hard Parts of an AI Video Workspace</title>
      <dc:creator>fei gao</dc:creator>
      <pubDate>Mon, 24 Aug 2026 09:00:12 +0000</pubDate>
      <link>https://dev.to/fei_gao_599260a6621676332/building-wan-30-the-hard-parts-of-an-ai-video-workspace-44a4</link>
      <guid>https://dev.to/fei_gao_599260a6621676332/building-wan-30-the-hard-parts-of-an-ai-video-workspace-44a4</guid>
      <description>&lt;p&gt;A prompt box makes an AI product look simple. The user writes a sentence,&lt;br&gt;
clicks &lt;strong&gt;Generate&lt;/strong&gt;, and waits for a video.&lt;/p&gt;

&lt;p&gt;The real system is less tidy. A request can outlive the browser tab, a provider&lt;br&gt;
can accept a job and fail later, and a retry can accidentally create a second&lt;br&gt;
billable task. Add multiple models, several input modes, and usage-based&lt;br&gt;
pricing, and the prompt box becomes the smallest part of the product.&lt;/p&gt;

&lt;p&gt;These are some of the engineering decisions behind&lt;br&gt;
&lt;a href="https://wan3.io" rel="noopener noreferrer"&gt;Wan 3.0&lt;/a&gt;, the AI video workspace we have been building for&lt;br&gt;
text, image, frame, and reference-based generation. This is not a launch post&lt;br&gt;
disguised as a tutorial. It is a practical look at the parts that took more&lt;br&gt;
thought than the interface suggests.&lt;/p&gt;
&lt;h2&gt;
  
  
  One form is not one generation workflow
&lt;/h2&gt;

&lt;p&gt;Text-to-video and image-to-video may end with the same file type, but they do&lt;br&gt;
not begin with the same contract.&lt;/p&gt;

&lt;p&gt;A text request needs a prompt, aspect ratio, resolution, and duration. An&lt;br&gt;
image-to-video request also needs an uploaded asset. A frame transition needs&lt;br&gt;
two ordered images, while reference-based generation may accept a clip or a&lt;br&gt;
set of visual references. Model support differs as well.&lt;/p&gt;

&lt;p&gt;We represent those paths as explicit scenes rather than stretching one loose&lt;br&gt;
payload across every model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VideoScene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-to-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image-to-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frames-to-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reference-to-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video-edit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video-extend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video-upscale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each model declares the scenes and fields it supports. The UI can then adapt&lt;br&gt;
to the chosen workflow, and the server can reject combinations that do not&lt;br&gt;
make sense before contacting a provider.&lt;/p&gt;

&lt;p&gt;That early validation matters. An upstream API error is slower, harder to&lt;br&gt;
explain, and sometimes more expensive than a local validation error.&lt;/p&gt;
&lt;h2&gt;
  
  
  The AI video workspace needs a stable provider boundary
&lt;/h2&gt;

&lt;p&gt;Provider APIs disagree about nearly everything: parameter names, callback&lt;br&gt;
formats, status values, result shapes, and whether polling or webhooks are the&lt;br&gt;
preferred completion path.&lt;/p&gt;

&lt;p&gt;Letting those differences leak into the product would couple every form and&lt;br&gt;
task screen to a specific vendor. Instead, Wan 3.0 puts a small adapter around&lt;br&gt;
each provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AIProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;supportsWebhook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AIGenerateParams&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AIProviderResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;providerTaskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AIProviderResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AIProviderResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;providerTaskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the application works with one internal result shape and a small&lt;br&gt;
set of task states. Provider-specific code stays at the edge.&lt;/p&gt;

&lt;p&gt;This does not make providers interchangeable. Models still have different&lt;br&gt;
inputs and capabilities. It does, however, give the application one place to&lt;br&gt;
translate those differences instead of scattering conditional logic across&lt;br&gt;
the codebase.&lt;/p&gt;
&lt;h2&gt;
  
  
  An AI request is a durable task, not a long HTTP call
&lt;/h2&gt;

&lt;p&gt;Video generation is asynchronous by nature. Treating it like a normal request&lt;br&gt;
and keeping the browser waiting creates fragile behavior for both the user and&lt;br&gt;
the server.&lt;/p&gt;

&lt;p&gt;Our task lifecycle uses five states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending -&amp;gt; processing -&amp;gt; succeeded
                      -&amp;gt; failed
                      -&amp;gt; canceled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Providers can complete synchronously, through polling, or by webhook. Those&lt;br&gt;
transport details are normalized into the same task record and result format.&lt;br&gt;
The browser can leave, return later, and read the current state from history.&lt;/p&gt;

&lt;p&gt;The simplified flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scene + model + inputs
        |
        v
validation and credit estimate
        |
        v
task creation + credit reservation
        |
        v
provider adapter
        |
        v
webhook or polling
        |
        v
result history or automatic refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persisting the task also gives us a useful audit trail: selected model,&lt;br&gt;
provider, normalized input, pricing snapshot, cost, timestamps, and terminal&lt;br&gt;
result all belong to the same operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency protects users from double generation
&lt;/h2&gt;

&lt;p&gt;Retries are normal. A user can double-click, the network can time out after&lt;br&gt;
the server accepts a request, or a client can retry because it never received&lt;br&gt;
the first response.&lt;/p&gt;

&lt;p&gt;For a paid generation, “probably only once” is not good enough.&lt;/p&gt;

&lt;p&gt;Every create request carries an idempotency key. We also calculate a&lt;br&gt;
fingerprint from the model, scene, and validated parameters. If the same key&lt;br&gt;
returns with the same fingerprint, the existing task is reused. If that key&lt;br&gt;
appears with different input, the request is rejected.&lt;/p&gt;

&lt;p&gt;The fingerprint check closes an easy-to-miss gap: an idempotency key should&lt;br&gt;
identify one operation, not become a container for whichever payload arrives&lt;br&gt;
last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing must be part of task creation
&lt;/h2&gt;

&lt;p&gt;Usage-based products should show the cost before the expensive operation&lt;br&gt;
starts. The harder requirement is making sure the displayed estimate and the&lt;br&gt;
recorded charge use the same calculation.&lt;/p&gt;

&lt;p&gt;Wan 3.0 calculates credits from the selected model and validated settings.&lt;br&gt;
Depending on the model, duration, resolution, and other options can change the&lt;br&gt;
result. The task stores both the calculated cost and a pricing snapshot, so a&lt;br&gt;
later configuration change does not rewrite the meaning of an older task.&lt;/p&gt;

&lt;p&gt;Task insertion and credit reservation happen in one database transaction. If&lt;br&gt;
either step fails, neither should survive on its own. That keeps us away from&lt;br&gt;
two awkward states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a provider job exists, but no usage was recorded;&lt;/li&gt;
&lt;li&gt;credits were deducted, but no task exists for the user to inspect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI benefit is straightforward: the number shown before submission is tied&lt;br&gt;
to the task the user sees afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failed jobs need an exact refund path
&lt;/h2&gt;

&lt;p&gt;External generation can fail after a provider has accepted the request. It can&lt;br&gt;
also time out or send the same callback more than once. A refund handler must&lt;br&gt;
therefore be safe to repeat.&lt;/p&gt;

&lt;p&gt;Our refund path locks the task record, checks whether the task is already in a&lt;br&gt;
terminal or refunded state, writes a refund transaction, restores the balance,&lt;br&gt;
and marks the task as refunded. All of that happens inside a transaction.&lt;/p&gt;

&lt;p&gt;The goal is not “try to refund.” It is a narrower invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A failed generation that reserved credits can restore them once, even if the&lt;br&gt;
failure signal is processed more than once.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is one of those backend details that becomes a product feature. The user&lt;br&gt;
does not need to know about row locks or duplicate webhooks. They only need to&lt;br&gt;
see that a failed render did not consume their balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would design first next time
&lt;/h2&gt;

&lt;p&gt;If we started another asynchronous AI product tomorrow, we would define four&lt;br&gt;
things before polishing the generation form:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The task state machine.&lt;/strong&gt; Decide which states are terminal and which
transitions are allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provider contract.&lt;/strong&gt; Normalize behavior and results, while letting
model capabilities remain explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The billing invariant.&lt;/strong&gt; Tie task creation, cost snapshots, reservations,
and refunds together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retry contract.&lt;/strong&gt; Add idempotency before the first duplicate request
reaches production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these decisions produces a dramatic screenshot. Together, they make&lt;br&gt;
the simple screenshot honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Wan 3.0?
&lt;/h3&gt;

&lt;p&gt;Wan 3.0 is a browser-based workspace for generating AI videos and images. Its&lt;br&gt;
video workflows include text, image, frame-pair, and reference inputs where&lt;br&gt;
the selected model supports them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does every model support every input mode?
&lt;/h3&gt;

&lt;p&gt;No. Models declare their supported scenes and parameters. The interface and&lt;br&gt;
server validation use that configuration to prevent unsupported combinations.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a generation fails?
&lt;/h3&gt;

&lt;p&gt;If a task reserved credits and later fails, the refund flow restores those&lt;br&gt;
credits once and records the refund against the task.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why use both an idempotency key and a request fingerprint?
&lt;/h3&gt;

&lt;p&gt;The key identifies the operation. The fingerprint confirms that repeated uses&lt;br&gt;
of the key contain the same model, scene, and input. Together, they prevent a&lt;br&gt;
retry from silently becoming a different generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building an AI video workspace is mostly an exercise in managing uncertainty:&lt;br&gt;
slow jobs, changing providers, variable costs, duplicate requests, and partial&lt;br&gt;
failures. A clean prompt box is valuable, but it only stays clean when the&lt;br&gt;
task, provider, and credit systems underneath it have clear contracts.&lt;/p&gt;

&lt;p&gt;You can try &lt;a href="https://wan3.io" rel="noopener noreferrer"&gt;Wan 3.0 at wan3.io&lt;/a&gt;. If you are building an&lt;br&gt;
asynchronous AI product, I would be interested to hear how you handle provider&lt;br&gt;
drift, retries, and usage reconciliation.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
