<?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: SpicyAPI</title>
    <description>The latest articles on DEV Community by SpicyAPI (@spicyapi_ai).</description>
    <link>https://dev.to/spicyapi_ai</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%2F4175210%2Fd9d3218b-e314-4ddb-8e42-8f9350e3f42d.png</url>
      <title>DEV Community: SpicyAPI</title>
      <link>https://dev.to/spicyapi_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/spicyapi_ai"/>
    <language>en</language>
    <item>
      <title>A Timeout Is Not a Failed AI Job: Designing Safe Retries for Paid Generation APIs</title>
      <dc:creator>SpicyAPI</dc:creator>
      <pubDate>Sun, 25 Oct 2026 04:59:28 +0000</pubDate>
      <link>https://dev.to/spicyapi_ai/a-timeout-is-not-a-failed-ai-job-designing-safe-retries-for-paid-generation-apis-166b</link>
      <guid>https://dev.to/spicyapi_ai/a-timeout-is-not-a-failed-ai-job-designing-safe-retries-for-paid-generation-apis-166b</guid>
      <description>&lt;p&gt;A customer clicks Generate. Your server sends the request, then the connection times out. The tempting fix is to send another request. The expensive possibility is that the first job is already running.&lt;/p&gt;

&lt;p&gt;This is a distributed-systems problem with a visible price tag. The client knows that it did not receive an answer; it does not know whether the server accepted the work.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://spicyapi.ai" rel="noopener noreferrer"&gt;SpicyAPI&lt;/a&gt;, so the API examples below use our media task protocol. The application design applies to other asynchronous services too, but each service has its own replay guarantees. Check those guarantees before sharing a retry helper across providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate a user's intent from an HTTP attempt
&lt;/h2&gt;

&lt;p&gt;Give each intentional generation an operation record in your own database. An operation might be “make take 2 for shot 7.” Several HTTP attempts can belong to that one operation.&lt;/p&gt;

&lt;p&gt;An intentional third take is a different operation, even if the prompt happens to be identical. A network retry of take 2 is still take 2.&lt;/p&gt;

&lt;p&gt;A useful record contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your operation ID and the owning customer or project.&lt;/li&gt;
&lt;li&gt;A stable idempotency key generated once for that operation.&lt;/li&gt;
&lt;li&gt;The exact request body and a reference to the API credential that will send it.&lt;/li&gt;
&lt;li&gt;The creation time and an application recovery deadline.&lt;/li&gt;
&lt;li&gt;The remote task ID once you obtain one.&lt;/li&gt;
&lt;li&gt;A state that can represent uncertainty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store sensitive input in a protected application record; do not copy the full body or credential into ordinary logs. Create the record transactionally before making the network call. Generating a UUID in memory is insufficient if a worker can crash before saving it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add an “unknown” state
&lt;/h2&gt;

&lt;p&gt;A small application state machine can be enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prepared -&amp;gt; submission_unknown -&amp;gt; accepted -&amp;gt; terminal
          \-&amp;gt; rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;“Submission unknown” means that the next action is recovery. It should not be presented as proof that the model failed.&lt;/p&gt;

&lt;p&gt;An operation can enter that state when a socket closes, a client deadline expires, or a response cannot be parsed. Even a successful server response can be lost before your application commits the task ID.&lt;/p&gt;

&lt;p&gt;Once the remote task ID is durable, stop resubmitting. Follow that task through callbacks or status reads. These are two separate problems: recovering acceptance and waiting for completion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freeze the submission while recovering it
&lt;/h2&gt;

&lt;p&gt;For SpicyAPI media tasks, reuse the same &lt;code&gt;Idempotency-Key&lt;/code&gt;, API key and request semantics to recover an accepted submission. The replay window lasts 24 hours from acceptance; it is not a permanent deduplication service. Model, input or callback changes make it a different request. See the &lt;a href="https://docs.spicyapi.ai/docs/idempotency" rel="noopener noreferrer"&gt;idempotency contract&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That has practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not regenerate a key inside a retry loop.&lt;/li&gt;
&lt;li&gt;Do not reconstruct a prompt from mutable project settings during recovery.&lt;/li&gt;
&lt;li&gt;Do not switch credentials and assume the old task will be visible.&lt;/li&gt;
&lt;li&gt;Do not keep an automatic recovery queue running beyond the service's replay window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use an application deadline much shorter than that window. If recovery is still unresolved, flag the operation for inspection instead of quietly submitting a fresh job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep one HTTP attempt small
&lt;/h2&gt;

&lt;p&gt;Here is a deliberately limited Node.js helper. It makes one attempt and reports what the caller actually knows. A durable worker, rather than this function, decides when to try the saved operation again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;operation.requestBody&lt;/code&gt; is a saved JSON string. Build it from the current model schema and your confirmed request before calling this function. The example does not select a model or start a generation by itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendSavedOperation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.spicyapi.ai/api/v1/jobs/createTask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accepted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// This is a response to classify, not permission to create another job.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response_to_review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;businessCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;request_id&lt;/span&gt;&lt;span class="p"&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 helper intentionally does not label every non-success response “safe to retry.” Invalid input, authentication failures, spend limits and conflicting keys need different handling. Rate limits require bounded backoff; follow the service's retry guidance. HTTP status alone is often insufficient when an API also has business error codes.&lt;/p&gt;

&lt;p&gt;After an &lt;code&gt;accepted&lt;/code&gt; result, commit the task ID before acknowledging your own queue message. After &lt;code&gt;unknown&lt;/code&gt;, retain the same saved operation for recovery. Do not catch every exception and create a new operation.&lt;/p&gt;

&lt;p&gt;For multiple workers, claim or lease the operation atomically. Idempotency at the remote API does not prevent two local workers from sending duplicate emails or writing two output records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not confuse acceptance, completion and payment
&lt;/h2&gt;

&lt;p&gt;In this protocol, an HTTP 202 response acknowledges a submitted task. It does not say that generation succeeded. The task's &lt;code&gt;state&lt;/code&gt; describes the generation outcome, while &lt;code&gt;settled&lt;/code&gt; tells you whether its reported cost is final. A local timeout does not cancel accepted work. The &lt;a href="https://docs.spicyapi.ai/docs/production-integration" rel="noopener noreferrer"&gt;production integration guide&lt;/a&gt; describes those boundaries and cost confirmation.&lt;/p&gt;

&lt;p&gt;Your interface can reflect this without exposing protocol jargon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Checking whether your generation started…” during uncertain submission.&lt;/li&gt;
&lt;li&gt;“Generating…” after acceptance.&lt;/li&gt;
&lt;li&gt;A completed or failed result only after reading a terminal task state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An explicit “Generate another take” action should create another operation with a new key. A “Check progress” action should keep following the existing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the lost response, not only a failed connection
&lt;/h2&gt;

&lt;p&gt;A mock that always throws before accepting anything misses the difficult case. Make your fake server remember the operation, create one task, and then drop the first response. Send the saved operation again.&lt;/p&gt;

&lt;p&gt;The useful assertions are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Two HTTP attempts carry the same key and body.&lt;/li&gt;
&lt;li&gt;The fake server creates exactly one remote task.&lt;/li&gt;
&lt;li&gt;The recovered task ID is committed locally.&lt;/li&gt;
&lt;li&gt;A process restart reads the old operation instead of generating another key.&lt;/li&gt;
&lt;li&gt;Two callbacks for that task produce one application-side effect.&lt;/li&gt;
&lt;li&gt;An operation beyond its recovery deadline is escalated rather than resubmitted automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also test a changed body with the old key. A conflict should be visible to your operator, not “fixed” by silently inventing a new key and spending money.&lt;/p&gt;

&lt;p&gt;The key question for each failure path is simple: what do we know, and which durable record lets the next worker continue from there?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the SpicyAPI Team. Examples describe our media task API; streaming and chat endpoints have different replay behavior.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>api</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>Keep an AI Character Consistent Through an API: One Saved Reference, Many Shots</title>
      <dc:creator>SpicyAPI</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:18:01 +0000</pubDate>
      <link>https://dev.to/spicyapi_ai/keep-an-ai-character-consistent-through-an-api-one-saved-reference-many-shots-306b</link>
      <guid>https://dev.to/spicyapi_ai/keep-an-ai-character-consistent-through-an-api-one-saved-reference-many-shots-306b</guid>
      <description>&lt;p&gt;You can get a great-looking character out of an image model in one prompt. Getting the &lt;em&gt;same&lt;/em&gt; character in the next ten images is the hard part. Describe her again in words and the glasses change shape, the hair drifts a shade, and by the tenth post nobody can say what she looks like.&lt;/p&gt;

&lt;p&gt;The fix is procedural rather than clever prompting: pick one image, store it, and make every later generation start from that stored image instead of from text. This post builds that pipeline against an HTTP API in about 100 lines of plain Node.js: candidates, a saved reference, a photo set, and a short clip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Candidates.&lt;/strong&gt; Generate a few head-and-shoulders portraits on a plain background with a text-to-image model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reference.&lt;/strong&gt; Save the one you like to a persistent asset library. You get back a stable URI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo set.&lt;/strong&gt; Call an image &lt;em&gt;edit&lt;/em&gt; model with that URI as the reference image and a new scene in the prompt. Repeat per scene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clip.&lt;/strong&gt; Save one of those photos too and pass it as the opening frame of an image-to-video model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important rule is in step 3: every new picture starts from the saved reference, never from your latest result. Chaining result-to-result is how small changes pile up.&lt;/p&gt;

&lt;p&gt;Models used below (prices at the time of writing, October 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Candidates&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bytedance/seedream-5.0-pro/text-to-image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.036 per image at 1k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo set&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bytedance/seedream-5.0-pro/edit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.036 per image at 1k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clip&lt;/td&gt;
&lt;td&gt;&lt;code&gt;alibaba/wan-3.0/image-to-video&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.09 per second at 720p&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four candidates, three photos and one five-second clip come to about $0.70.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny client
&lt;/h2&gt;

&lt;p&gt;No SDK needed. The API wraps every response in &lt;code&gt;{ code, msg, data, request_id }&lt;/code&gt;, tasks are asynchronous, and writes accept an &lt;code&gt;Idempotency-Key&lt;/code&gt; so a retried request does not create (or bill) a second task.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// spicy.mjs: a tiny helper around the SpicyAPI task API (Node 18+, no dependencies)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.spicyapi.ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SPICY_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (request_id &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// What a request would cost, without running it&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1/jobs/quote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Create a task, wait up to 60 s on the first call, then poll until it finishes&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1/jobs/createTask?wait=60&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`/api/v1/jobs/recordInfo?taskId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorMessage&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Copy one output of a finished task into the asset library and get a stable spicy://a/ URI&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saveAsset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1/assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;task_output&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s2"&gt;`save-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;?wait=60&lt;/code&gt; holds the first request open for up to 60 seconds. Image jobs often come back finished in that one call, and you only poll when they don't. If you disconnect, the task keeps running.&lt;/li&gt;
&lt;li&gt;The save call uses a deterministic idempotency key (&lt;code&gt;save-&amp;lt;task&amp;gt;-&amp;lt;index&amp;gt;&lt;/code&gt;), so running the script twice does not store the same image twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// character.mjs: candidates -&amp;gt; saved reference -&amp;gt; photo set -&amp;gt; one short clip&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saveAsset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./spicy.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;T2I&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bytedance/seedream-5.0-pro/text-to-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EDIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bytedance/seedream-5.0-pro/edit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;I2V&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alibaba/wan-3.0/image-to-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mara&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mara, a fictional plant-care host in her early thirties: copper hair in a low bun, &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;round tortoiseshell glasses, mustard-yellow linen apron over a white T-shirt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Four candidates for the face, on a plain background&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pickTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PICK_TASK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pickTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;T2I&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Head-and-shoulders portrait of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mara&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Plain white studio background, soft even light, natural skin texture.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;aspect_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;4:5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rerun with PICK_TASK=&amp;lt;taskId&amp;gt; of the face you like.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Keep the one you like as the permanent reference&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pickTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mara reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reference:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// spicy://a/ast_...&lt;/span&gt;

&lt;span class="c1"&gt;// 3. A photo set: every shot starts from the saved reference, not from text&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scenes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;repotting a monstera at a sunny kitchen table&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;misting a row of ferns on a rainy windowsill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reading a seed catalogue on a small balcony at dusk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scene&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;scenes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The woman from the reference image, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Keep her face, hair, glasses and apron exactly the same. Natural light, 35mm photo.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image_urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;aspect_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;4:5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;estimatedCost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EDIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EDIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;estimatedCost&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Animate the first photo for five seconds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;still&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mara repotting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;I2V&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;still&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;She presses the soil down around the plant, then looks up with a small smile. Camera holds steady at eye level. Warm morning light.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;720p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clip:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it twice. &lt;code&gt;SPICY_API_KEY=sk-spicy-... node character.mjs&lt;/code&gt; generates the four candidates and prints each task ID with its image URL, then stops. Open the URLs, choose a face, and run it again with &lt;code&gt;PICK_TASK=&amp;lt;that task ID&amp;gt;&lt;/code&gt; to save the reference and make the photo set and the clip. Picking by task ID rather than by position matters: a second run would otherwise generate four new faces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the saved URI matters
&lt;/h2&gt;

&lt;p&gt;Output URLs in &lt;code&gt;output.assets[].url&lt;/code&gt; are signed links that expire after about 20 minutes, and results are kept for 14 days. That is fine for downloading, not for a character you will use for months.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /api/v1/assets&lt;/code&gt; copies the file into a persistent library and returns an asset with a &lt;code&gt;uri&lt;/code&gt; like &lt;code&gt;spicy://a/ast_...&lt;/code&gt;. That URI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;works in any media field of a model input (&lt;code&gt;image_urls&lt;/code&gt;, &lt;code&gt;image_url&lt;/code&gt;, and so on), so you never re-upload the reference;&lt;/li&gt;
&lt;li&gt;only resolves for API keys of the same workspace, so it is not a public link and the library cannot be used as file hosting;&lt;/li&gt;
&lt;li&gt;stays valid until you delete the asset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the &lt;code&gt;uri&lt;/code&gt; in your own database next to the character, not the preview URL.&lt;/p&gt;

&lt;p&gt;The library unlocks with your account's lifetime top-ups (at the time of writing, $10 in total gives 500 MB and up to 200 saved assets). Without that, the save call returns 400 with &lt;code&gt;data.reason&lt;/code&gt; set to &lt;code&gt;asset_library_locked&lt;/code&gt;, which the helper above prints. &lt;code&gt;GET /api/v1/assets/usage&lt;/code&gt; shows the current plan and limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompting the edit model
&lt;/h2&gt;

&lt;p&gt;The reference image carries the face; the prompt should carry the change. A few things that help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name what must stay the same.&lt;/strong&gt; "Keep her face, hair, glasses and apron exactly the same" does more than repeating the full description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change one thing per shot&lt;/strong&gt;: the place, the activity or the outfit, not all three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the signature details visible.&lt;/strong&gt; Pick two or three (the glasses, the copper hair, the yellow apron) and frame shots so they show. They are what makes a thumbnail recognisable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare side by side.&lt;/strong&gt; Every few weeks put six recent images next to the reference. If a stranger would not call them the same person, go back to the reference instead of the latest image.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quote before you spend
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /api/v1/jobs/quote&lt;/code&gt; takes the same body as &lt;code&gt;createTask&lt;/code&gt; and returns &lt;code&gt;estimatedCost&lt;/code&gt; without running anything. The script prints it per photo. In a real tool you would show it to the user before the button does anything, or refuse requests above a budget. When a task is created, &lt;code&gt;estimatedCost&lt;/code&gt; is held and the final charge never goes above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it without code
&lt;/h2&gt;

&lt;p&gt;If you just want to try the idea before wiring it into anything, the same flow exists as a page: &lt;a href="https://spicyapi.ai/create/ai-influencer" rel="noopener noreferrer"&gt;an AI influencer builder&lt;/a&gt; where you pick traits from picture cards, keep a close-up and a full-length photo, save the character, and then make photo sets of 4, 8 or 12 pictures, short videos or lip-synced talking clips from it. Under the hood it is the reference-first approach described above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running a persona responsibly
&lt;/h2&gt;

&lt;p&gt;A consistent fictional character is a reasonable thing to run as a brand mascot or a channel host, with a few rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Label it.&lt;/strong&gt; Many social platforms have AI-content labels and some require them. Say in the profile that the character is AI-generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not imitate a real person&lt;/strong&gt;, and only start from a photo of someone if you have the right to use that face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the character a fictional adult&lt;/strong&gt;, and write that into your persona notes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Swap the models: any image edit model that accepts reference images works for step 3, and the &lt;a href="https://docs.spicyapi.ai" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; list what each one accepts.&lt;/li&gt;
&lt;li&gt;Add a talking clip: generate speech for a line of dialogue, then run a talking-avatar model on one of the saved photos.&lt;/li&gt;
&lt;li&gt;Read the model page for &lt;a href="https://spicyapi.ai/models/seedream-5-0-pro" rel="noopener noreferrer"&gt;Seedream 5.0 Pro&lt;/a&gt; for its resolution tiers and reference-image limits (up to 10 per request).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole trick is one habit: store the reference once, and point every later request at it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Extend an AI-Generated Video Clip with an API (Last-Frame Continuation, Joined for You)</title>
      <dc:creator>SpicyAPI</dc:creator>
      <pubDate>Tue, 13 Oct 2026 04:16:35 +0000</pubDate>
      <link>https://dev.to/spicyapi_ai/how-to-extend-an-ai-generated-video-clip-with-an-api-last-frame-continuation-joined-for-you-1k2m</link>
      <guid>https://dev.to/spicyapi_ai/how-to-extend-an-ai-generated-video-clip-with-an-api-last-frame-continuation-joined-for-you-1k2m</guid>
      <description>&lt;p&gt;You generated a nice 5 to 10 second clip with an AI video model, and now you need 20 seconds of it. Most video APIs cap a single generation at a few seconds, so "just make it longer" is not an option. The standard workaround is to continue the clip from where it ended, and the details of doing that cleanly are where most of the time goes. This post walks through the three common approaches, what you have to handle if you build the stitching yourself, and how to get the same result from one API request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways to extend a clip
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Native continuation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The model itself accepts a video and keeps going&lt;/td&gt;
&lt;td&gt;Best motion and scene continuity, no stitching on your side&lt;/td&gt;
&lt;td&gt;Only a few models offer it; you still get a separate file unless the provider joins it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Last frame as first frame&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grab the final frame of your clip, feed it to an image-to-video model, then concatenate the two files&lt;/td&gt;
&lt;td&gt;Works with any image-to-video model&lt;/td&gt;
&lt;td&gt;Visible seams unless you handle frames, sound, color and format; motion direction is not remembered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reference tail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pass the last few seconds of your clip to a model that accepts reference video, then concatenate&lt;/td&gt;
&lt;td&gt;Carries over motion and some sound context&lt;/td&gt;
&lt;td&gt;Needs a model with reference-video input and a clip-length limit; still needs joining&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Native continuation is the nicest when it exists, but it is rare. Last-frame continuation is the one you can do with almost anything, so it is the one worth getting right. The rest of this post is about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What last-frame stitching actually involves
&lt;/h2&gt;

&lt;p&gt;The idea fits in one sentence. The practice is a checklist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Extract the last frame.&lt;/strong&gt; With ffmpeg that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-sseof&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; clip.mp4 &lt;span class="nt"&gt;-update&lt;/span&gt; 1 &lt;span class="nt"&gt;-q&lt;/span&gt;:v 1 last.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-sseof -1&lt;/code&gt; seeks one second before the end, and &lt;code&gt;-update 1&lt;/code&gt; keeps overwriting the output so the file you end up with is the final decoded frame. Mind the rotation metadata on phone footage and the color range of the source, otherwise the frame you send to the model looks slightly different from what the viewer sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Generate the continuation&lt;/strong&gt; from that frame with your image-to-video model of choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Drop the duplicate frame.&lt;/strong&gt; The new segment starts on the frame your clip ended on. If you concatenate naively, the viewer sees a one-frame stall at the join. Some models also start with a few static frames or a brightness flash, so check the first handful of frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Match the format.&lt;/strong&gt; Concatenation only works cleanly when both pieces agree on resolution, aspect ratio, frame rate and pixel format. Models often return a different frame rate than your source (24 fps against 30 fps is common) and sometimes a slightly different frame size. You have to scale, crop or pad, and choose which one you are willing to distort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Fix the sound seam.&lt;/strong&gt; If both pieces have audio, a hard cut produces a click and often a jump in loudness. You want a short crossfade and some loudness matching. If one piece is silent, pad it with silence so the audio track does not end early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Re-encode or stream copy?&lt;/strong&gt; Stream copy (&lt;code&gt;-c copy&lt;/code&gt;) is fast and lossless, but it only works when both files share codec, profile, timebase and parameter sets. Otherwise you must re-encode, which costs time and a little quality. In practice you end up with both paths in your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Watch the color.&lt;/strong&gt; The new segment's first frame can be a shade brighter or darker than your clip's last frame. A slight shift is noticeable exactly at the cut.&lt;/p&gt;

&lt;p&gt;None of this is hard in isolation. Together it is a few hundred lines of ffmpeg glue plus a worker to run it, and every model you add behaves a little differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing with one request
&lt;/h2&gt;

&lt;p&gt;SpicyAPI exposes this as a &lt;code&gt;video-extend&lt;/code&gt; endpoint on 31 video model families. The examples below use Wan 3.0. You send the clip you want to continue, an optional prompt describing what happens next, and a length for the new segment. The last frame of your clip opens the new Wan 3.0 image-to-video shot, and the platform joins the pieces: it removes the duplicate frame, aligns the frame rate, crossfades the sound and returns the result.&lt;/p&gt;

&lt;p&gt;The model id is &lt;code&gt;alibaba/wan-3.0/video-extend&lt;/code&gt;. The input fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required. The clip to continue: MP4, MOV or WebM, up to 60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional. What happens next: action, camera move, sound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duration_seconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Length of the new segment, 2 to 30 (default 5), or &lt;code&gt;-1&lt;/code&gt; to let the model choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolution&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;480p&lt;/code&gt;, &lt;code&gt;720p&lt;/code&gt; (default) or &lt;code&gt;1080p&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generate_audio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default &lt;code&gt;true&lt;/code&gt;. Your source keeps its own sound either way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_image_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional target end frame for the new segment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  With curl
&lt;/h3&gt;

&lt;p&gt;The API is asynchronous: you create a task, then poll it. Set &lt;code&gt;SPICY_API_KEY&lt;/code&gt; first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://api.spicyapi.ai/api/v1/jobs/createTask &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SPICY_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: extend-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "alibaba/wan-3.0/video-extend",
    "input": {
      "video_url": "https://example.com/my-clip.mp4",
      "prompt": "The camera keeps tracking the cyclist as she turns into the sunlit street, long shadows, soft ambient city sound.",
      "duration_seconds": 5,
      "resolution": "720p"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response uses the usual &lt;code&gt;{code, msg, data, request_id}&lt;/code&gt; envelope, and &lt;code&gt;data.taskId&lt;/code&gt; is what you poll:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=&lt;/span&gt;&lt;span class="nv"&gt;$TASK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SPICY_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task moves through &lt;code&gt;queued&lt;/code&gt; and &lt;code&gt;running&lt;/code&gt; to &lt;code&gt;succeeded&lt;/code&gt;. If you want to know the price before you commit, &lt;code&gt;POST /api/v1/jobs/quote&lt;/code&gt; takes the same body and starts nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What comes back
&lt;/h3&gt;

&lt;p&gt;A finished task has two entries in &lt;code&gt;output.assets&lt;/code&gt;, in a fixed order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;&lt;code&gt;role&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your original clip and the continuation joined into one file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;segment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The new segment on its own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each asset carries a signed &lt;code&gt;url&lt;/code&gt;, plus &lt;code&gt;durationSeconds&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt; and &lt;code&gt;mime&lt;/code&gt;. Download them promptly: the signed URL is valid for about 20 minutes, and results are kept for up to 14 days. Having the standalone segment is handy when you want to re-edit the join yourself or chain another extension from the new footage.&lt;/p&gt;

&lt;h3&gt;
  
  
  With the Python SDK
&lt;/h3&gt;

&lt;p&gt;If you prefer Python, the &lt;code&gt;spicyapi&lt;/code&gt; package (&lt;code&gt;pip install spicyapi&lt;/code&gt;) wraps the same calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;spicyapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SpicyClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_assets&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpicyClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# reads SPICY_API_KEY
&lt;/span&gt;
&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-clip.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# returns a spicy:// uri
&lt;/span&gt;
&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alibaba/wan-3.0/video-extend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The camera keeps tracking the cyclist as she turns into the sunlit street.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duration_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;720p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_terminal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;output_assets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;durationSeconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reuse the same idempotency key if you resend after a timeout, so a dropped connection never turns into a second paid task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chaining extensions
&lt;/h2&gt;

&lt;p&gt;Because the first asset is a complete, normal MP4, you can feed it straight back in as the next &lt;code&gt;video_url&lt;/code&gt;. A 5 second clip becomes 10, then 15 and so on, until the joined clip reaches the source limit (60 seconds on Wan 3.0). The model only sees the last frame each time, so very long chains can drift: characters and lighting slowly change. The optional &lt;code&gt;last_image_url&lt;/code&gt; helps when you need the final segment to land on a specific composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Billing is per second of the &lt;em&gt;new&lt;/em&gt; segment, at the resolution you choose. The source clip you send is not billed. At the time of writing (2026-10-13), the public catalog lists Wan 3.0 video extend at $0.045 per second at 480p, $0.09 at 720p and $0.18 at 1080p, so a 5 second extension at 720p is $0.45. Prices change, so check the model page or request a quote before you build a budget around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Match &lt;code&gt;resolution&lt;/code&gt; to your source to avoid a visible step in sharpness at the seam.&lt;/li&gt;
&lt;li&gt;Write the prompt as what happens &lt;em&gt;next&lt;/em&gt;, not a description of the clip you already have.&lt;/li&gt;
&lt;li&gt;Keep the source clip's last second stable. If it ends mid-blur, the continuation starts from a blurry frame.&lt;/li&gt;
&lt;li&gt;Extending a clip does not fix a bad ending. If the last frame has artifacts, trim the source first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The Wan 3.0 model page, with the current price table and examples, is at &lt;a href="https://spicyapi.ai/models/wan-3-0" rel="noopener noreferrer"&gt;spicyapi.ai/models/wan-3-0&lt;/a&gt;, and the API reference is at &lt;a href="https://docs.spicyapi.ai" rel="noopener noreferrer"&gt;docs.spicyapi.ai&lt;/a&gt;. If you would rather not write code, there is also a browser version at &lt;a href="https://spicyapi.ai/create/ai-video-extender" rel="noopener noreferrer"&gt;spicyapi.ai/create/ai-video-extender&lt;/a&gt;. Questions about the endpoint or the seam handling are welcome in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>video</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to run a Civitai or Hugging Face LoRA through an API (no GPU, no upload)</title>
      <dc:creator>SpicyAPI</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:10:07 +0000</pubDate>
      <link>https://dev.to/spicyapi_ai/how-to-run-a-civitai-or-hugging-face-lora-through-an-api-no-gpu-no-upload-5a43</link>
      <guid>https://dev.to/spicyapi_ai/how-to-run-a-civitai-or-hugging-face-lora-through-an-api-no-gpu-no-upload-5a43</guid>
      <description>&lt;p&gt;You found a LoRA you like on Civitai or Hugging Face. Running it usually means renting a GPU, installing ComfyUI or a diffusers script, downloading the base checkpoint and keeping all of it alive. If all you want is images or short clips from that LoRA inside your own app, that is a lot of machinery.&lt;/p&gt;

&lt;p&gt;This post shows the other route: pass the LoRA's download link in an API request and let a hosted model load it for that one task. The examples use &lt;a href="https://spicyapi.ai" rel="noopener noreferrer"&gt;SpicyAPI&lt;/a&gt;, which exposes a &lt;code&gt;loras&lt;/code&gt; field on 60+ image and video endpoints (FLUX, Z-Image, Qwen-Image, Wan 2.2, LTX-2, MiniMax H3 and more). The platform adds no content filter of its own, so what the LoRA and the base model can produce is what you get back. The same ideas apply to any host that takes a LoRA URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pick a LoRA trained for the base you will call
&lt;/h2&gt;

&lt;p&gt;A LoRA only works on the base model it was trained on. A FLUX.1 [dev] LoRA does nothing useful on Z-Image Turbo, and most hosts will not tell you: the task succeeds and the LoRA is silently ignored. So start from the base:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On Civitai, each file version lists its "Base Model" in the details panel.&lt;/li&gt;
&lt;li&gt;On Hugging Face, it is usually in the model card and the repo tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also note the &lt;strong&gt;trigger word&lt;/strong&gt; and the &lt;strong&gt;recommended strength&lt;/strong&gt; from the card. You will need both.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://spicyapi.ai/lora" rel="noopener noreferrer"&gt;LoRA overview page&lt;/a&gt; lists which LoRA families map to which hosted base, so you can match them before you spend anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Get a direct link to the .safetensors file
&lt;/h2&gt;

&lt;p&gt;The API needs a URL that downloads the weights file itself, not the page about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hugging Face&lt;/strong&gt; — use &lt;code&gt;resolve&lt;/code&gt;, not &lt;code&gt;blob&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://huggingface.co/OWNER/REPO/resolve/main/FILE.safetensors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;blob&lt;/code&gt; link returns an HTML page. Only public repos work; a gated repo answers the download with 401 and the task fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Civitai&lt;/strong&gt; — use the API download link with the &lt;em&gt;version&lt;/em&gt; ID (the number behind the Download button), not the model ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://civitai.com/api/download/models/VERSION_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our tests these links loaded without a token, including files Civitai marks as sign-in only. If you ever get a 401, append &lt;code&gt;?token=YOUR_CIVITAI_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own files&lt;/strong&gt; — any public HTTPS link to a &lt;code&gt;.safetensors&lt;/code&gt; file works, for example a file in your own S3 or R2 bucket.&lt;/p&gt;

&lt;p&gt;Nothing is uploaded ahead of time. The weights are fetched for the task, and there is no separate LoRA fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Send the request
&lt;/h2&gt;

&lt;p&gt;Every model has its own input schema, but the LoRA part looks the same: a &lt;code&gt;loras&lt;/code&gt; array of &lt;code&gt;{ path, scale }&lt;/code&gt; objects. Here is Z-Image Turbo LoRA, which takes up to three LoRAs at a scale from 0 to 4 (1 is neutral):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.spicyapi.ai/api/v1/jobs/createTask?wait=60"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SPICY_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: lora-demo-001"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "alibaba/z-image-turbo-lora/text-to-image",
    "input": {
      "prompt": "TRIGGERWORD, a woman reading by a rain-streaked window at dusk, warm lamp light on her face, city lights behind",
      "aspect_ratio": "3:4",
      "seed": 42,
      "loras": [
        { "path": "https://huggingface.co/OWNER/REPO/resolve/main/FILE.safetensors", "scale": 0.9 }
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that save time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;?wait=60&lt;/code&gt; holds the connection for up to 60 seconds. If the task finishes in time you get the final record (HTTP 200) straight away; otherwise you get the usual 202 with a &lt;code&gt;taskId&lt;/code&gt; and poll.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Idempotency-Key&lt;/code&gt; makes a retry of the same request return the same task instead of creating and charging a second one.&lt;/li&gt;
&lt;li&gt;Fix the &lt;code&gt;seed&lt;/code&gt; while you are tuning. It is the only way to tell whether a change came from the LoRA or from luck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you got a 202, poll until &lt;code&gt;state&lt;/code&gt; is terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=TASK_ID"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SPICY_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"taskId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"job_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"succeeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.012"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"settled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/jpeg"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asset URL is a signed link valid for about 20 minutes; poll again for a fresh one, or download the file. Results are kept for 14 days.&lt;/p&gt;

&lt;p&gt;Want the price first? Send the same body to &lt;code&gt;POST /api/v1/jobs/quote&lt;/code&gt;. It returns the estimated charge and the maximum you can be billed, and creates nothing. Image LoRA endpoints are mostly a flat price per image (Z-Image Turbo LoRA is $0.012); video endpoints bill per second of output, so quoting first is worth it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check that the LoRA is actually doing something
&lt;/h2&gt;

&lt;p&gt;Because a mismatched or broken LoRA often fails silently, do one A/B run before you scale up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the request once with the LoRA.&lt;/li&gt;
&lt;li&gt;Run it again with the same seed and &lt;code&gt;"loras": []&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the two images look alike, something is off: wrong base, missing trigger word, or a scale too low to matter. Fix that before generating a batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Tuning strength and stacking
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start at the card's recommended scale&lt;/strong&gt;, often 0.7 to 1.0. Far above 1 tends to burn in artefacts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack carefully.&lt;/strong&gt; Two style LoRAs at full strength usually fight. Keep the main one near 1 and bring the second in around 0.5, moving one scale by about 0.2 at a time with the seed fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger word first&lt;/strong&gt; in the prompt, then the subject, the setting and the light. Concrete lighting ("a warm lamp on the face, neon behind") moves a realism LoRA further than adjectives like "cinematic".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video models can have more than one slot.&lt;/strong&gt; Wan 2.2 uses a two-stage sampler, so its LoRAs come as separate HIGH-noise and LOW-noise files; that endpoint takes them in &lt;code&gt;high_noise_loras&lt;/code&gt; and &lt;code&gt;low_noise_loras&lt;/code&gt;, plus a plain &lt;code&gt;loras&lt;/code&gt; field. Put each file in the slot it was trained for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Limits differ by model (how many LoRAs, what scale range), so read the &lt;code&gt;loras&lt;/code&gt; part of the model's &lt;code&gt;inputSchema&lt;/code&gt; from the catalog before you hard-code anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this route makes sense
&lt;/h2&gt;

&lt;p&gt;Hosted LoRA calls are a good fit when you want results inside your own app without running GPUs, when you switch LoRAs often, or when you are testing many community LoRAs before committing to one. If you generate around the clock on a single fixed LoRA, your own GPU may work out cheaper; at a few cents per image, most side projects and prototypes never get there.&lt;/p&gt;

&lt;p&gt;The full field reference, SDKs (TypeScript, Python, Go, PHP, Java) and a CLI are in the &lt;a href="https://docs.spicyapi.ai" rel="noopener noreferrer"&gt;docs&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ai</category>
      <category>api</category>
      <category>tutorial</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
