# 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.

Source: https://nodaro.ai/docs/developers/sdk/media-and-uploads

**`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()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions). The methods call the endpoints of the [Uploads](https://nodaro.ai/docs/developers/api/uploads) and [Voice and media](https://nodaro.ai/docs/developers/api/voice-and-media) REST APIs.

## Methods

| Method | What it does |
| --- | --- |
| [`uploads.upload(file)`](#uploadsuploadfile) | Upload one file and get its public URL |
| [`library.list(params?)`](#librarylistparams) | List your stored media |
| [`media.downloadVideo(input)`](#mediadownloadvideoinput) | Download a social video into your storage |
| [`media.downloadVideoProgress(downloadId, opts?)`](#mediadownloadvideoprogressdownloadid-opts) | Follow a download's progress |
| [`media.videoMetadata(input)`](#mediavideometadatainput) | Read a social video's duration and size without downloading it |
| [`media.saveToStorage(input)`](#mediasavetostorageinput) | Copy a media URL into your storage |
| [`media.process(input)`](#mediaprocessinput) | Cut or crop a stored file, for free |
| [`media.trimVideo(input)`](#mediatrimvideoinput) | Trim a video to a range |
| [`media.trimAudio(input)`](#mediatrimaudioinput) | Trim audio, or extract it from a video |
| [`media.addCaptions(input)`](#mediaaddcaptionsinput) | Burn captions into a video |
| [`media.stillToVideo(input)`](#mediastilltovideoinput) | Turn an image and an audio track into a video |
| [`media.slideshow(input)`](#mediaslideshowinput) | Turn images and optional audio into a slideshow |
| [`media.videoOverlay(input)`](#mediavideooverlayinput) | Place timed image layers over a video |
| [`media.imageCollage(input)`](#mediaimagecollageinput) | Combine 2 to 30 images into one collage |
| [`media.imageOverlay(input)`](#mediaimageoverlayinput) | Place image, text, QR and shape layers on an image |
| [`media.suggestOverlayPlacement(input)`](#mediasuggestoverlayplacementinput) | 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.

```ts
upload(file: File): Promise<UploadResult>
```

<TypeTable
type={{
file: { type: 'File', required: true, description: "The file to upload: an image, a video or an audio file. File is global in browsers and in Node.js 20 and newer." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/image/upload-image), [Upload Video](https://nodaro.ai/docs/nodes/video/upload-video) and [Upload Audio](https://nodaro.ai/docs/nodes/audio/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.

```ts
list(params?: ListLibraryParams): Promise<{ data: LibraryAsset[]; nextCursor: string | null; totalCount?: number }>
```

<TypeTable
type={{
type: { type: '"all" | "image" | "video" | "audio"', default: '"all"', description: "Only one kind of media." },
search: { type: 'string', description: "Only files whose name contains this text, ignoring case." },
limit: { type: 'number', default: '40', description: "The page size, 1 to 100." },
cursor: { type: 'string', description: "The nextCursor of the previous page." },
owned: { type: 'boolean', default: 'false', description: "true returns every file you own, uploads and generations, whether or not it is saved to the library." },
source: { type: 'string', description: "Only media that came from one surface: internal, mcp, app, cli, sdk, extension, web or api. Older media without a source never matches." },
}}
/>

```ts
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.

```ts
downloadVideo(input: {
url: string
maxHeight?: number
sectionStartSec?: number
sectionEndSec?: number
requireAudio?: boolean
}): Promise<{ downloadId: string }>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "The link to the social video." },
maxHeight: { type: 'number', description: "The highest resolution to fetch, in pixels of height, such as 720. Omit it for the best available." },
sectionStartSec: { type: 'number', description: "Fetch only the part that starts here, in seconds. Give both section fields or neither." },
sectionEndSec: { type: 'number', description: "The end of that part, in seconds." },
requireAudio: { type: 'boolean', default: 'true', description: "By default, a download without sound fails, because that usually means the source answered badly, and other routes are tried first. Set false to accept a clip that really is silent." },
}}
/>

```ts
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.

```ts
downloadVideoProgress(downloadId: string, opts?: { signal?: AbortSignal }): AsyncGenerator<DownloadVideoProgress>
```

<TypeTable
type={{
downloadId: { type: 'string', required: true, description: "The id downloadVideo() returned." },
signal: { type: 'AbortSignal', description: "Stops following the progress." },
}}
/>

```ts
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.

```ts
videoMetadata(input: { url: string }): Promise<VideoMetadata>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "The link to the social video." },
}}
/>

```ts
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.

```ts
saveToStorage(input: { mediaUrl: string; filename?: string; mediaType?: "image" | "video" | "audio" }): Promise<{ jobId: string }>
```

<TypeTable
type={{
mediaUrl: { type: 'string', required: true, description: "The URL of the file to copy." },
filename: { type: 'string', description: "The file name to store it under." },
mediaType: { type: '"image" | "video" | "audio"', description: "The kind of media." },
}}
/>

```ts
const { jobId } = await client.media.saveToStorage({ mediaUrl: "https://example.com/intro.mp4", mediaType: "video" })
```

The [Save to Storage](https://nodaro.ai/docs/nodes/publish/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.

```ts
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 }>
```

<TypeTable
type={{
sourceUrl: { type: 'string', required: true, description: "The stored file to process." },
type: { type: '"video" | "audio"', required: true, description: "The kind of file." },
trim: { type: '{ startTime: number; endTime: number }', description: "Keep only this range, in seconds." },
crop: { type: '{ x, y, width, height }', description: "Keep only this rectangle of the frame, in pixels." },
format: { type: '"mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"', description: "The output format." },
deleteSource: { type: 'boolean', default: 'false', description: "Delete the source afterwards, when it is yours and nothing else uses it." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/video/trim-video) node does. Give the range in whichever unit fits.

```ts
trimVideo(input: {
videoUrl: string
startTime?: number
endTime?: number
trimStartFrames?: number
trimEndFrames?: number
trimStartSeconds?: number
trimEndSeconds?: number
keepFirstSeconds?: number
keepLastSeconds?: number
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "The video to trim." },
startTime: { type: 'number', description: "Where the kept part starts, in seconds." },
endTime: { type: 'number', description: "Where the kept part ends, in seconds." },
trimStartFrames: { type: 'number', description: "Frames to cut from the start." },
trimEndFrames: { type: 'number', description: "Frames to cut from the end." },
trimStartSeconds: { type: 'number', description: "Seconds to cut from the start." },
trimEndSeconds: { type: 'number', description: "Seconds to cut from the end." },
keepFirstSeconds: { type: 'number', description: "Keep only the first seconds." },
keepLastSeconds: { type: 'number', description: "Keep only the last seconds." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/audio/trim-audio) node does.

```ts
trimAudio(input: {
videoUrl?: string
audioUrl?: string
audioFormat?: "mp3" | "wav" | "aac"
startTime?: number
endTime?: number
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', description: "A video to take the audio from. Give videoUrl or audioUrl." },
audioUrl: { type: 'string', description: "An audio file to trim." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "The output format." },
startTime: { type: 'number', description: "The start of the range, in seconds." },
endTime: { type: 'number', description: "The end of the range, in seconds." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/video/add-captions) node does. Give the words as `text`, give word-timed `captions`, or let Nodaro transcribe the speech, which is the default.

```ts
addCaptions(input: AddCaptionsInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "The video to caption." },
style: { type: '"subtitle" | "word-highlight" | "karaoke" | "tiktok-words" | "word-pop" | "bouncy"', description: "subtitle is a static block. The others are kinetic styles that animate word by word." },
text: { type: 'string', description: "On subtitle, the caption itself, shown as one block for the whole video. On a kinetic style, only a fallback when transcription finds nothing." },
captions: { type: '{ text, startMs, endMs }[]', description: "Word-timed captions, one entry per word for the kinetic styles. With them, no transcription runs." },
autoTranscribe: { type: 'boolean', default: 'true', description: "Transcribe the speech when nothing else supplies the words." },
transcribeProvider: { type: '"whisper" | "incredibly-fast-whisper" | "elevenlabs-stt"', default: '"incredibly-fast-whisper"', description: "The transcription engine. Kinetic styles need one with word timings." },
position: { type: '"bottom" | "top" | "center"', description: "Where the captions sit." },
positionY: { type: 'number', description: "The caption's vertical center, in percent of the height. Overrides position." },
fontSize: { type: 'number', description: "The font size." },
color: { type: 'string', description: "The text color." },
backgroundColor: { type: 'string', description: "The background color behind the text." },
look: { type: '"outline" | "clean"', description: "A styling preset. outline is heavy uppercase type with a black outline and a yellow spoken word." },
fontFamily: { type: 'string', description: "One of the supported fonts." },
fontWeight: { type: 'number', description: "100 to 900, in steps of 100." },
strokeColor: { type: 'string', description: "The outline color." },
strokeWidth: { type: 'number', description: "The outline width." },
uppercase: { type: 'boolean', description: "Show the text in capitals." },
maxWordsPerLine: { type: 'number', description: "At most this many words per line, 1 to 20." },
highlightColor: { type: 'string', description: "Kinetic styles only: the color of the word being spoken." },
animate: { type: 'boolean', default: 'true', description: "Kinetic styles only: false freezes the per-word motion." },
segments: { type: 'CaptionSegmentInput[]', description: "Different treatments for separate time ranges. Each segment has startMs, endMs and its own style, look, words and levers." },
}}
/>

```ts
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()`](https://nodaro.ai/docs/developers/sdk/voices-and-audio) job have exactly the shape `captions` takes. Correct a word, then burn the captions without a second transcription:

```ts

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](https://nodaro.ai/docs/nodes/video/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.

```ts
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 }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "The still image." },
audioUrl: { type: 'string', required: true, description: "The audio track. Its length sets the video's length." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"', default: '"none"', description: "How the camera moves over the image." },
intensity: { type: 'number', description: "How strong the motion is, 1 to 10." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "The output resolution." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "The output shape." },
fps: { type: '24 | 30', description: "The frame rate." },
fit: { type: '"cover" | "contain"', description: "cover crops the image to fill the frame. contain shows all of it, with bars in padColor." },
padColor: { type: 'string', description: "The color of the bars when fit is contain." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/video/slideshow) node does. It costs no credits. For a single image, use `stillToVideo()`.

```ts
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 }>
```

<TypeTable
type={{
imageUrls: { type: 'string[]', required: true, description: "2 to 100 images, in order." },
audioUrl: { type: 'string', description: "An audio track. With it, the video lasts as long as the audio." },
imageDurations: { type: 'Array<number | null>', description: "Seconds per image, in the same order. null means automatic. With audio, pinned values that do not add up are scaled to fit." },
perImageDuration: { type: 'number', description: "Seconds per image when there is no audio." },
transition: { type: 'string', description: "The transition between images." },
transitionDuration: { type: 'number', description: "The length of each transition, in seconds." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"', description: "How the camera moves over each image." },
intensity: { type: 'number', description: "How strong the motion is." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "The output resolution." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "The output shape." },
fps: { type: '24 | 30', description: "The frame rate." },
fit: { type: '"cover" | "contain"', description: "Crop to fill, or show all of each image with bars." },
padColor: { type: 'string', description: "The color of the bars." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/video/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.

```ts
videoOverlay(input: VideoOverlayRequest): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "The base video." },
layers: { type: 'Array<{ imageUrl, start, end?, ... }>', required: true, description: "1 to 20 layers. Each has imageUrl and start, and optional end, preset, corner, anchor, x, y, width, height, fit, opacity, animate and zIndex." },
outputAspect: { type: '"16:9" | "9:16" | "1:1" | "4:5"', description: "Reframe the output to this shape. Omit it to keep the video's own size and frame rate." },
baseFit: { type: '"contain" | "cover"', default: '"cover"', description: "How the video fills the new shape, with outputAspect." },
backgroundColor: { type: 'string', default: '"#000000"', description: "The background color, as #RRGGBB, with outputAspect." },
}}
/>

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.

```ts
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](https://nodaro.ai/docs/nodes/image/image-collage) node does.

```ts
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 }>
```

<TypeTable
type={{
imageUrls: { type: 'string[]', required: true, description: "2 to 30 images, in order." },
layout: { type: '"smart" | "grid"', default: '"smart"', description: "smart builds rows at each image's own aspect ratio, without cropping, so the height varies. grid uses equal cells with bars." },
imageSizes: { type: 'Array<0 | 1 | 2 | 3>', description: "Relative sizes for the smart layout, in the same order as imageUrls: 0 automatic, 1 big, 2 medium, 3 small. The grid layout ignores them." },
numbered: { type: 'boolean', default: 'false', description: "Stamp a sequence number on each image, as on a storyboard." },
imageLabels: { type: 'Array<string | null>', description: "A caption per image, up to 80 characters, shown after the number. null or an empty string means none." },
badgePosition: { type: '"top-left" | "top-right"', default: '"top-left"', description: "The corner for numbers and labels." },
resolution: { type: '"2K" | "4K"', description: "The collage size." },
aspectRatio: { type: 'string', description: "The collage shape." },
gap: { type: 'number', description: "The space between images." },
backgroundColor: { type: 'string', description: "The background color." },
}}
/>

```ts
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](https://nodaro.ai/docs/nodes/image/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`.

```ts
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 }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "The base image." },
layers: { type: 'Array<{ kind?, imageUrl?, anchor?, ... }>', required: true, description: "1 to 12 layers. See the layer fields below." },
canvas: { type: '{ width: number; height: number; backgroundColor?: string }', description: "An output size. Without it, the output keeps the base image's pixel size." },
baseFit: { type: '"contain" | "cover"', description: "How the base image fills the canvas." },
outputFormat: { type: '"png" | "jpg" | "webp"', default: '"png"', description: "The output format. png keeps transparency." },
variants: { type: 'string[]', description: "Extra platform sizes to render in the same run, by platform id, such as youtube-thumbnail. Any of the 12 platforms." },
maskMode: { type: '"around" | "layers" | "outside" | "none"', description: "What the mask output shows: a ring around the layers (the default in the editor), the layers, everything except the layers, or no mask." },
maskSpread: { type: 'number', description: "The width of the ring, in pixels, for maskMode around." },
qrText: { type: 'string', description: "The payload for a QR layer that sets qr.fromInput." },
}}
/>

**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.

```ts
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.

```ts
suggestOverlayPlacement(input: {
imageUrl: string
layerAspect?: number
intent?: string
safeArea?: { x: number; y: number; w: number; h: number }
llmModel?: string
}): Promise<{ jobId: string; placement: OverlayPlacement }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "The base image." },
layerAspect: { type: 'number', default: '1', description: "The layer's width divided by its height, so the proposed box keeps its proportions." },
intent: { type: 'string', description: "What the layer is, such as a logo or a price badge." },
safeArea: { type: '{ x, y, w, h }', description: "The region that is always visible, as fractions of the canvas. The placement stays inside it." },
llmModel: { type: 'string', description: "The vision model to use." },
}}
/>

```ts
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

### How do I upload a file with the Nodaro SDK?

Call client.uploads.upload(file) with a File. It returns a public url you can pass to any node that takes an image, a video or an audio URL.

### How do I add captions to a video from code?

Call client.media.addCaptions with the videoUrl and a style. Without text or captions, Nodaro transcribes the speech. The call returns a jobId; the finished job holds the captioned video.

### Which media methods cost no credits?

stillToVideo and slideshow are rendered without an AI model and cost no credits, and media.process is free. videoOverlay costs 20 credits per run and imageOverlay costs 10 credits plus 2 per extra platform size.

### How do I track a video download?

client.media.downloadVideo returns a downloadId. Iterate client.media.downloadVideoProgress(downloadId) to receive the phase and percent until the download completes or fails.
