Media and uploads
Upload files, list your media library, download social videos, trim clips, burn captions and composite images and videos from TypeScript with the Nodaro SDK.
client.uploads stores your files in Nodaro, client.library lists the media you already have, and client.media prepares and edits media: it downloads social videos, trims clips, burns captions, and composites images and videos. Most client.media methods start a job and return its jobId, which you poll with client.jobs.getStatus(). The methods call the endpoints of the Uploads and Voice and media REST APIs.
Methods
| Method | What it does |
|---|---|
uploads.upload(file) | Upload one file and get its public URL |
library.list(params?) | List your stored media |
media.downloadVideo(input) | Download a social video into your storage |
media.downloadVideoProgress(downloadId, opts?) | Follow a download's progress |
media.videoMetadata(input) | Read a social video's duration and size without downloading it |
media.saveToStorage(input) | Copy a media URL into your storage |
media.process(input) | Cut or crop a stored file, for free |
media.trimVideo(input) | Trim a video to a range |
media.trimAudio(input) | Trim audio, or extract it from a video |
media.addCaptions(input) | Burn captions into a video |
media.stillToVideo(input) | Turn an image and an audio track into a video |
media.slideshow(input) | Turn images and optional audio into a slideshow |
media.videoOverlay(input) | Place timed image layers over a video |
media.imageCollage(input) | Combine 2 to 30 images into one collage |
media.imageOverlay(input) | Place image, text, QR and shape layers on an image |
media.suggestOverlayPlacement(input) | Ask a vision model where a layer should go |
client.uploads
uploads.upload(file)
Uploads one file (POST /v1/upload, multipart) and returns its public URL and storage details. The SDK sends the file as form data and lets the runtime set the multipart boundary.
upload(file: File): Promise<UploadResult>Prop
Type
const result = await client.uploads.upload(file)
const clip = await client.nodes.runAndWait("generate-video", {
prompt: "The camera slowly pushes in",
imageUrl: result.url,
})| Field | Type | Description |
|---|---|---|
url | string | The public URL of the stored file. Pass it as imageUrl, videoUrl or audioUrl. |
assetId | string | null | The id of the stored item, or null for an anonymous upload. |
thumbnailUrl | string | null | A thumbnail for images and video, or null. |
category | string | image, video or audio. |
filename | string | The display file name. |
mimeType | string | The media type the server settled on. It can differ from the one the browser declared, for example video/mp4 for a .mp4 file sent as application/octet-stream. |
sizeBytes | number | The stored size. |
r2Key | string | The storage key of the file. |
Throws StorageExceededError when your storage is full. The Upload Image, Upload Video and Upload Audio nodes use the same storage.
client.library
library.list(params?)
Lists your stored media with a cursor (GET /v1/library). By default it returns the items saved to your library plus items shared with you, as the editor's media picker shows them.
list(params?: ListLibraryParams): Promise<{ data: LibraryAsset[]; nextCursor: string | null; totalCount?: number }>Prop
Type
const { data: videos, nextCursor } = await client.library.list({ type: "video", limit: 20 })
for (const asset of videos) console.log(asset.filename, asset.url)Each LibraryAsset has id, type, filename, mimeType, sizeBytes, url, thumbnailUrl, metadata, isLibraryItem, uploadSource, source, sourceDetail and createdAt. totalCount is present on the first page only.
client.media: import and prepare
media.downloadVideo(input)
Downloads a video from YouTube, TikTok, Instagram, X or Facebook into your storage (POST /v1/download-video). It returns a downloadId, not a job id. Follow it with downloadVideoProgress(). The finished file lands in your library.
downloadVideo(input: {
url: string
maxHeight?: number
sectionStartSec?: number
sectionEndSec?: number
requireAudio?: boolean
}): Promise<{ downloadId: string }>Prop
Type
const { downloadId } = await client.media.downloadVideo({
url: "https://youtu.be/dQw4w9WgXcQ",
maxHeight: 720,
})media.downloadVideoProgress(downloadId, opts?)
Streams the live progress of a download as an async iterator (GET /v1/download-video/progress/:id, server-sent events). It yields an event about every 500 ms until the download completes or fails, then ends.
downloadVideoProgress(downloadId: string, opts?: { signal?: AbortSignal }): AsyncGenerator<DownloadVideoProgress>Prop
Type
for await (const event of client.media.downloadVideoProgress(downloadId)) {
console.log(`${event.phase} ${event.percent}%`)
if (event.phase === "completed") console.log("Stored at", event.videoUrl)
if (event.phase === "failed") console.error(event.error)
}Each event is { phase, percent, videoUrl?, thumbnailUrl?, error? }. Start iterating soon after downloadVideo() returns, because the progress record expires shortly after the download ends. The client's timeoutMs does not apply, because large downloads take minutes.
media.videoMetadata(input)
Reads a social video's duration, dimensions, title and live status without downloading it (POST /v1/video-metadata). It answers directly, without a job. Use it to decide whether to fetch only a section.
videoMetadata(input: { url: string }): Promise<VideoMetadata>Prop
Type
const meta = await client.media.videoMetadata({ url: "https://youtu.be/dQw4w9WgXcQ" })The fields are best-effort: a platform may not report all of them.
media.saveToStorage(input)
Copies a media file from a URL into your Nodaro storage (POST /v1/save-to-storage). The server fetches the file, so nothing passes through your client.
saveToStorage(input: { mediaUrl: string; filename?: string; mediaType?: "image" | "video" | "audio" }): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.saveToStorage({ mediaUrl: "https://example.com/intro.mp4", mediaType: "video" })The Save to Storage node does the same inside a workflow.
media.process(input)
Cuts or crops a file that is already in your storage (POST /v1/media/process). It answers directly and costs nothing. Use it to prepare a source before a paid step.
process(input: {
sourceUrl: string
type: "video" | "audio"
trim?: { startTime: number; endTime: number }
crop?: { x: number; y: number; width: number; height: number }
format?: "mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"
deleteSource?: boolean
}): Promise<{ data: MediaProcessResult }>Prop
Type
const { data } = await client.media.process({
sourceUrl: stored.url,
type: "video",
trim: { startTime: 12, endTime: 42 },
})
console.log(data.url, data.sizeBytes)The result has url, thumbnailUrl, assetId, sizeBytes, mimeType and metadata.
media.trimVideo(input)
Trims a video to a range (POST /v1/trim-video), as the Trim Video node does. Give the range in whichever unit fits.
trimVideo(input: {
videoUrl: string
startTime?: number
endTime?: number
trimStartFrames?: number
trimEndFrames?: number
trimStartSeconds?: number
trimEndSeconds?: number
keepFirstSeconds?: number
keepLastSeconds?: number
}): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.trimVideo({ videoUrl, keepFirstSeconds: 15 })media.trimAudio(input)
Trims audio to a range, or extracts it from a video (POST /v1/trim-audio), as the Trim Audio node does.
trimAudio(input: {
videoUrl?: string
audioUrl?: string
audioFormat?: "mp3" | "wav" | "aac"
startTime?: number
endTime?: number
}): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.trimAudio({ videoUrl, startTime: 5, endTime: 35 })client.media: captions
media.addCaptions(input)
Burns captions into a video (POST /v1/add-captions), as the Add Captions node does. Give the words as text, give word-timed captions, or let Nodaro transcribe the speech, which is the default.
addCaptions(input: AddCaptionsInput): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.addCaptions({
videoUrl: "https://example.com/talk.mp4",
style: "word-highlight",
maxWordsPerLine: 2,
})Where the words come from. When a call carries several sources, word-timed captions win, then text on the subtitle style, then transcription. On subtitle, text is the caption: it is burned as one static block for the whole video and never replaced by a transcript. Use \n in it to force a line break. On a kinetic style, text is only a fallback. It is used when transcription finds nothing or autoTranscribe is false, and its words are then spread evenly across the video.
Styles and looks. On the kinetic styles, an unset look renders as outline. On subtitle, it renders as clean. The explicit levers override single fields of the look. A segment that names its own look starts from that preset and does not inherit the top-level levers.
Levers on subtitle. The styling levers (look, fontFamily, fontWeight, strokeColor, strokeWidth, uppercase, positionY and maxWordsPerLine) also work on subtitle. A subtitle with any of them bills at the kinetic price, and a plain-text subtitle bills at the lower price. highlightColor and animate are kinetic only and are refused with a 400 on subtitle. An auto-transcribed subtitle also bills at the kinetic price.
Lines. word-highlight, karaoke and bouncy show one line at a time. A line ends at a sentence end, at a pause of 0.5 seconds or more, when it fills about 85% of the frame width, or at maxWordsPerLine words. word-pop shows one word at a time, so maxWordsPerLine has no effect there. A tiktok-words page never crosses a sentence end or a pause. Both stay on screen for at most 1.5 seconds after the last spoken word. A word's startMs and endMs time its effect, not how long it stays visible.
Transcription engines. A kinetic style needs word timings, so transcribeProvider must be incredibly-fast-whisper, the default here, or elevenlabs-stt. whisper has no word timings. It is refused with 400 validation_error only when transcription is the only possible source of words. On subtitle, any engine works, because a subtitle needs only phrase timing.
Frame rate. A styled render keeps the source video's frame rate, rounded to a whole number between 15 and 60. A variable-frame-rate source, or a clip too long for the frame limit, renders at 30 fps.
Transcribe, correct, then burn
The words of a client.audio.transcribe() job have exactly the shape captions takes. Correct a word, then burn the captions without a second transcription:
import type { TranscribeJobOutput } from "@nodaro/sdk"
const { jobId } = await client.audio.transcribe({
audioUrl: "https://example.com/talk.mp3",
provider: "elevenlabs-stt", // always returns word timings
})
// ...poll until the job completes, then:
const { data: job } = await client.jobs.get(jobId)
const { words = [] } = job.output_data as TranscribeJobOutput
const corrected = words.map((w, i) => (i === 7 ? { ...w, text: "Nodaro" } : w))
await client.media.addCaptions({
videoUrl: "https://example.com/talk.mp4",
captions: corrected,
autoTranscribe: false,
style: "word-highlight",
})client.media: render and composite
media.stillToVideo(input)
Turns one still image and one audio track into an MP4 (POST /v1/still-to-video), as the Still to Video node does. It is rendered on the server without an AI model and costs no credits. The video lasts as long as the audio; there is no duration field.
stillToVideo(input: {
imageUrl: string
audioUrl: string
motion?: "none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.stillToVideo({ imageUrl: coverUrl, audioUrl: podcastUrl, motion: "ken-burns" })media.slideshow(input)
Turns 2 to 100 images and an optional audio track into an MP4 slideshow (POST /v1/slideshow), as the Slideshow node does. It costs no credits. For a single image, use stillToVideo().
slideshow(input: {
imageUrls: string[]
audioUrl?: string
imageDurations?: Array<number | null>
perImageDuration?: number
transition?: string
transitionDuration?: number
motion?: "none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.slideshow({ imageUrls: frames, audioUrl: musicUrl, motion: "alternate" })With audio, the time is split equally unless imageDurations pins some images. Without audio, the video lasts the number of images times perImageDuration, and it is silent.
media.videoOverlay(input)
Places 1 to 20 timed image layers over a video in one pass (POST /v1/video-overlay), as the Video Overlay node does. It costs 20 credits per run, whatever the number of layers or the length. The video's own audio is kept as it is.
videoOverlay(input: VideoOverlayRequest): Promise<{ jobId: string }>Prop
Type
Each layer takes imageUrl and start, in seconds from 0 to 3,600, and an optional end; without end it stays until the video ends. preset is card, corner-badge or full-frame. The box fields x and y run from -100 to 100 and width and height from 1 to 100, all in percent of the output frame. An explicit box field overrides the preset. A layer with neither is a corner badge, at the bottom right or at the corner it names. opacity runs from 0 to 1, animate is on by default, and zIndex runs from 0 to 100.
const { jobId } = await client.media.videoOverlay({
videoUrl,
layers: [
{ imageUrl: logoUrl, start: 0, preset: "corner-badge", corner: "top-right" },
{ imageUrl: offerCardUrl, start: 8, end: 14, preset: "card" },
],
})The finished job's output has videoUrl, thumbnailUrl, width, height, durationSec and warnings. Each warning is { layer?, slot?, code, detail }, with a code such as clipped, skipped, animated_first_frame or audio_reencoded.
media.imageCollage(input)
Combines 2 to 30 images into one large 2K or 4K collage (POST /v1/image-collage), as the Image Collage node does.
imageCollage(input: {
imageUrls: string[]
imageSizes?: Array<0 | 1 | 2 | 3>
numbered?: boolean
imageLabels?: Array<string | null>
badgePosition?: "top-left" | "top-right"
layout?: "smart" | "grid"
resolution?: "2K" | "4K"
aspectRatio?: string
gap?: number
backgroundColor?: string
}): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.media.imageCollage({
imageUrls: shotUrls,
numbered: true,
imageLabels: ["Wide", "Medium", "Close-up"],
})Numbers and labels never change the layout, the output size or the price. A label too long for its image is shortened with an ellipsis.
media.imageOverlay(input)
Places 1 to 12 layers on a base image, pixel-exactly (POST /v1/image-overlay), as the Image Overlay node does. It is a composite without an AI model. It costs 10 credits, plus 2 credits per extra platform size in variants.
imageOverlay(input: {
imageUrl: string
layers: Array<{
kind?: "image" | "text" | "qr" | "shape"
imageUrl?: string
anchor?: "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right"
x?: number
y?: number
width?: number
height?: number
opacity?: number
rotation?: number
blend?: "over" | "multiply" | "screen"
fit?: "contain" | "cover" | "stretch"
shadow?: { blur: number; offsetX: number; offsetY: number; color: string; opacity: number }
roundedCorners?: number
zIndex?: number
text?: OverlayTextStyle
qr?: OverlayQrStyle
shape?: OverlayShapeStyle
effects?: OverlayImageEffects
}>
canvas?: { width: number; height: number; backgroundColor?: string }
baseFit?: "contain" | "cover"
outputFormat?: "png" | "jpg" | "webp"
variants?: string[]
maskMode?: "none" | "layers" | "around" | "outside"
maskSpread?: number
qrText?: string
}): Promise<{ jobId: string }>Prop
Type
Layers. A layer has one of four kinds:
- A picture:
kind: "image", the default, withimageUrl. - Real text:
kind: "text"with atextobject. It sets the content, font, weight, color, alignment, outline and background box, and a size in percent of the base height. - A QR code:
kind: "qr"withqr: { text }. - A flat shape:
kind: "shape"withshape: { shape, color }.
Positions are percentages of the base image, so the same call works on a 1K preview and a 4K render:
anchoris one of nine positions,centerby default.xandymove the layer from the anchor, in percent of the base width and height. On a right or bottom anchor, a negative value moves it inward.widthis in percent of the base width, 25 by default. The height follows the layer's aspect unless you setheight, and thenfitdecides how the layer fills the box.opacityruns from 0 to 1,rotationis in degrees around the layer's center, andblendisover(the default),multiplyorscreen.shadowadds a soft shadow,roundedCornersrounds the corners in pixels, andzIndexsets the stacking order.
const { jobId } = await client.media.imageOverlay({
imageUrl: productShotUrl,
layers: [{ imageUrl: logoUrl, anchor: "bottom-right", x: -4, y: -6, width: 12, opacity: 0.95 }],
variants: ["youtube-thumbnail"],
})The finished job's output has imageUrl, its width and height, maskUrl, and variants, each { id, label, width, height, url }. SVG layers are drawn crisp at the target size. A QR layer with qr.fromInput: true takes its payload from qrText, and a run without qrText is refused with a 400 that names it.
media.suggestOverlayPlacement(input)
Asks a vision model where one layer should sit on a base image (POST /v1/image-overlay/suggest-placement). The model keeps the layer off faces, the main subject and busy textures. It answers directly, without a job to poll, and is billed as one Describe Image call. Nothing is composited: you apply the placement.
suggestOverlayPlacement(input: {
imageUrl: string
layerAspect?: number
intent?: string
safeArea?: { x: number; y: number; w: number; h: number }
llmModel?: string
}): Promise<{ jobId: string; placement: OverlayPlacement }>Prop
Type
const { placement } = await client.media.suggestOverlayPlacement({
imageUrl: baseUrl,
intent: "a logo",
layerAspect: 2.5,
})
const { reason, ...box } = placement // anchor, x, y and width, in imageOverlay's units
await client.media.imageOverlay({ imageUrl: baseUrl, layers: [{ imageUrl: logoUrl, ...box }] })placement has anchor, x, y and width in imageOverlay's percent units, plus a one-sentence reason you can show to a user. jobId is the billing record.
Frequently asked questions
Related
Voices and audio
Jobs and executions
Uploads
Voice and media
Add Captions
Last updated on
LLM and Reduce
Get validated JSON from a language model with client.llm, and choose the best of many results, vote, join or merge them with client.reduce.
Voices and audio
Browse voices, change and recast voices, design new voices, dub video, and separate, mix and transcribe audio from TypeScript with the Nodaro SDK.