Node: stream a Sume video to disk with pipeline and a short-file check
Download a finished Sume video from /v1/videos/{id}/content in Node without loading it into memory, and fail if the file is shorter than content-length.

To save a finished Sume video from Node without holding it in memory, fetch GET /v1/videos/{jobId}/content?index=0 with your bearer key, wrap res.body with Readable.fromWeb, and pipe it into createWriteStream with stream/promises pipeline. Then compare the size on disk with the content-length header and throw if they differ. The script is 18 lines and has no dependencies on Node 20 or later.
The endpoint
When a /v1/videos job is completed, the poll response lists unsigned_urls; each one points at the content endpoint of the job. The index query parameter defaults to 0 and only matters when a model returns more than one video output. The Sume docs' curl example sends the Authorization header on the download, so the script does too.
| Item | Value |
|---|---|
| Content route | GET /v1/videos/{jobId}/content?index=0 |
| Ready when | Poll status is completed |
| Index | Defaults to 0; use it for multi-output models |
| Auth | Authorization: Bearer $SUME_API_KEY |
| Failure shape | Non-2xx with the JSON error envelope and a request_id |
Why pipeline and not arrayBuffer
response.arrayBuffer() would hold the whole file in memory. A finished clip can be large, which is fine once and a problem when 20 downloads run together. pipeline moves chunks from the network to the file with backpressure, and it rejects if either side errors, which also closes the file handle.
Readable.fromWeb converts the web ReadableStream that fetch returns into a Node stream. The script checks res.ok and res.body first, because an error body is JSON text you would otherwise write into clip.mp4.
import { createWriteStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const [jobId, out = "clip.mp4"] = process.argv.slice(2);
if (!jobId) throw new Error("usage: node download.mjs <job id> [out.mp4]");
const res = await fetch(`https://api.sume.com/v1/videos/${jobId}/content?index=0`, {
headers: { Authorization: `Bearer ${process.env.SUME_API_KEY}` },
});
if (!res.ok || !res.body) throw new Error(`content ${res.status}: ${await res.text()}`);
await pipeline(Readable.fromWeb(res.body), createWriteStream(out));
const expected = Number(res.headers.get("content-length") ?? 0);
const { size } = await stat(out);
if (expected && size !== expected) throw new Error(`short file: ${size} of ${expected} bytes`);
console.log(out, size, "bytes");The short-file check
A connection that drops mid-download can end the stream without an error in some environments, leaving a truncated file that still has a .mp4 name. The script reads content-length, stats the file, and throws if the numbers differ. If the server sends a chunked response with no content-length, the check is skipped, so in that case verify with a probe, for example ffprobe, before you publish the file.
Keep the job id and the output path in your own records. If the download fails, run the script again for the same job.
A few operational notes. Run downloads with a small concurrency limit, because each open stream holds a connection and a file handle. Name the output after the job id so a repeated run overwrites the same file instead of creating duplicates. And do the check even when the connection was fast: a truncated file is silent, and the first sign is a player that stops early. Use a timeout on the download request itself; a stalled stream with no timeout will hold a worker until the process is killed.
- Write to a temporary name and rename after the check passes.
- Retry the download, not the generation.
- Do not log the full URL if you use a signed one.
Sources
Related posts
More in Developers
- Omni Flash 1.1: a 3-second probe before the $3.75 4K clip
Probe a Gemini Omni Flash 1.1 prompt for $0.1125 at 360p, then spend $3.75 on the 10-second 4K clip. Price table and the max_spend_usd for each step.
- Omni reference tokens follow list order: swap list, swap meaning
<IMAGE_REF_0> is the first URL in reference_image_urls, so reordering the list rewires the prompt. A check function and an 8 s body, $1.00 at 720p on Sume.
- Omni reference videos: read Sume capabilities, not Google's note
Google's Omni page says multi-video reference is unsupported; Sume's docs list up to three reference clips. Read the catalog entry before you send them.
- Omni takes JPEG and PNG: gate each reference image URL in Python
Google lists JPEG and PNG for Gemini Omni image input. Check each reference_image_urls entry with a HEAD request, then submit the clean list through Sume.
Written by Sume