Save a 30-second Wan 3.0 clip to Cloudflare R2 from Node
Fetch a finished Wan 3.0 render from Sume and write it to Cloudflare R2 with the AWS S3 client. Shows the R2 endpoint config and the 30 s price at three tiers.

To store a finished Wan 3.0 clip in Cloudflare R2, download it from Sume's /v1/videos/{id}/content endpoint and write it with PutObjectCommand from @aws-sdk/client-s3, pointed at your R2 endpoint. R2 speaks the S3 API, so the only changes from an AWS upload are the region, the endpoint, and the credentials.
Wan 3.0 renders up to 30 seconds on Sume (wan-3.0 accepts 2 to 30 seconds), and a 30-second MP4 is large enough that you should keep it out of your database and out of your API response bodies. Object storage is the right home.
R2 client configuration
Cloudflare's own example for the JavaScript v3 SDK sets region to "auto", uses the endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com, and passes an R2 access key id and secret from an R2 API token (Cloudflare docs, read 2026-10-05). It uploads with PutObjectCommand and a Body.
Create a bucket-scoped R2 token and keep it separate from your Sume key. The Sume key reads the render; the R2 token only writes the copy.
The script
Run this as an ES module on a current Node release after the job is completed. It buffers the clip in memory, which is fine for a single 30-second MP4 and keeps the example short.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const { SUME_API_KEY, SUME_JOB_ID, R2_ACCOUNT_ID, R2_KEY_ID,
R2_SECRET, R2_BUCKET } = process.env;
if (!SUME_API_KEY || !SUME_JOB_ID || !R2_BUCKET) throw new Error("env");
const r2 = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2_KEY_ID, secretAccessKey: R2_SECRET },
});
const res = await fetch(
`https://api.sume.com/v1/videos/${SUME_JOB_ID}/content?index=0`,
{ headers: { Authorization: `Bearer ${SUME_API_KEY}` } },
);
if (!res.ok) throw new Error(`sume ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
await r2.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: `clips/${SUME_JOB_ID}.mp4`,
Body: body,
ContentType: "video/mp4",
}));
console.log("saved", SUME_JOB_ID, body.length, "bytes");Do not resubmit to retry a copy
The copy is a read of a finished job. If R2 returns an error, rerun the script with the same job id. Submitting the render again would create and bill a second job.
Use the job id in the object key so a rerun overwrites the same object.
What the 30-second clip costs on Sume
Sume bills provider list x 1.25. Wan 3.0 lists at $0.05, $0.10 and $0.20 per second at 480p, 720p and 1080p, so the Sume rates are $0.0625, $0.125 and $0.25 per second.
| Resolution | Per second | 30 s clip |
|---|---|---|
| 480p | $0.0625 | $1.875 |
| 720p | $0.125 | $3.75 |
| 1080p | $0.25 | $7.50 |
When to switch to streaming
If you copy many clips in parallel, buffering each one multiplies memory use. Move to a streamed upload with the S3 SDK's multipart helper at that point, and bound how many copies run at once. For a single clip or a short queue, the buffered version above is the easier thing to get right.
Serving the file afterwards
R2 objects are private until you decide otherwise. For a product page, put the bucket behind your own domain or hand out short-lived links from your server. Do not paste the Sume content URL into a page, because it needs your API key and would leak it.
Keep the Sume job id next to the object key in your database. If a customer asks where a clip came from, the job id lets you read the job events and its recorded cost without guessing.
Checking the copy
After the upload, compare body.length with the size R2 reports for the object using a HeadObjectCommand. A mismatch means the copy is truncated, and the fix is to run it again from the same finished job, which costs nothing on the Sume side.
Sources
Related posts
More in Developers
- script_run for one TTS clip per sentence: limits to set first
Fan out one tts_create per sentence inside Sume script_run, with timeout_seconds, max_calls and max_paid_calls set, then wait on the child jobs with jobs_wait.
- script_run error script_tool_forbidden: discovery calls belong outside
script_tool_forbidden means a Sume script called a discovery tool (tools_list, tools_schema, mcp_health, search_tools) or script_run. Call them from the turn.
- script_text, words, cues or segments: which caption input to send
Sume captions take only one of script_text, words, cues, segments. Your pick decides whether speech-to-text runs and what happens on a silent clip.
- SDK waitForJob reads per minute: the 2-second floor, and 10 jobs
The Sume SDK polls a job at least every 2 seconds, and a longer next_poll_after_seconds wins. That is up to 30 reads a minute per job. A webhook removes them.
Written by Sume