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.

5 min readSume
All posts

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.

Wan 3.0, 30-second clip on Sume (Sume pricing tables, read 2026-10-05)
ResolutionPer second30 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

All Developers posts

Written by Sume