Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
TypeScript SDK

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

MethodWhat 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,
})
FieldTypeDescription
urlstringThe public URL of the stored file. Pass it as imageUrl, videoUrl or audioUrl.
assetIdstring | nullThe id of the stored item, or null for an anonymous upload.
thumbnailUrlstring | nullA thumbnail for images and video, or null.
categorystringimage, video or audio.
filenamestringThe display file name.
mimeTypestringThe 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.
sizeBytesnumberThe stored size.
r2KeystringThe 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, with imageUrl.
  • Real text: kind: "text" with a text object. 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" with qr: { text }.
  • A flat shape: kind: "shape" with shape: { shape, color }.

Positions are percentages of the base image, so the same call works on a 1K preview and a 4K render:

  • anchor is one of nine positions, center by default.
  • x and y move 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.
  • width is in percent of the base width, 25 by default. The height follows the layer's aspect unless you set height, and then fit decides how the layer fills the box.
  • opacity runs from 0 to 1, rotation is in degrees around the layer's center, and blend is over (the default), multiply or screen.
  • shadow adds a soft shadow, roundedCorners rounds the corners in pixels, and zIndex sets 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

Last updated on

On this page