Media and uploads
Upload files, list your media library, import videos from links, 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.gallery browses the public gallery and your favorites. client.media prepares and edits media: it downloads videos from links, 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 |
uploads.uploadJson(json) | Store a JSON document and get its URL |
uploads.importUrl(url, options?) | Copy a picture from a web address into your storage |
library.list(params?) | List and search your stored media |
gallery.browse(params?) | Browse the public gallery |
gallery.toggleFavorite(jobId) | Favorite a generation, or remove the favorite |
gallery.favorites() | List your favorited job ids |
contentRights.declaration() | Read the content rights declaration |
contentRights.confirm(input) | Confirm the declaration for one link before it is imported |
contentRights.authorization() | Read the standing authorization for imports through keys and agents |
contentRights.authorize(input) | Authorize imports through keys and agents, from the person's own session |
contentRights.revokeAuthorization() | Revoke that authorization |
media.downloadVideo(input) | Download the video behind a link 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.discard(input) | Throw away a file you just made and no longer need |
media.trimVideo(input) | Trim a video to a range |
media.trimAudio(input) | Trim audio, or extract it from a video |
media.splitMedia(input) | Cut a video or audio file into parts between a minimum and a maximum length, or a video into one part per shot |
media.shotCuts(input) | Find where every shot of a video starts and ends |
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 or video 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.splitIntoLayers(input, options?) | Split one image into a base picture and separate layers |
media.videoMask(input) | Track a subject described in text and get a mask video |
media.maskComposite(input) | Place one video over another through a mask video, or pixelate or blur the masked area |
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.
uploads.uploadJson(json)
Stores a JSON document of up to 25 MB (POST /v1/upload-json) and returns { url }, its public URL. Use it, for example, for an image editor's design state, which you pass to jobs.fromUpload as editStateUrl so the edit can be opened again.
uploadJson(json: Record<string, unknown> | readonly unknown[]): Promise<UploadJsonResult>The URL is hard to guess but public, so never store a secret in it. It creates no library item.
uploads.importUrl(url, options?)
Copies a picture from a web address into your storage and returns it (POST /v1/media/import-url, with the body { url, confirmationId? }). The stored copy is an upload of yours, served so that a browser canvas can read its pixels, for example to crop it. A picture on another site does not allow that.
importUrl(url: string, options?: { confirmationId?: string }): Promise<ImportUrlResult>Prop
Type
The platform keeps the file only if it decodes as an image: JPEG, PNG, WebP or GIF. A HEIC, HEIF or AVIF picture is converted to JPEG or PNG.
try {
const picture = await client.uploads.importUrl("https://example.org/photo.jpg")
console.log(picture.url, picture.width, picture.height)
} catch (err) {
if (err instanceof StorageExceededError && err.reason === "file_too_large") {
// one picture over 20 MB, not a full storage
}
}| Field | Type | Description |
|---|---|---|
url | string | The public URL of the stored picture. |
thumbnailUrl | string | null | A thumbnail, or null when it could not be made. |
assetId | string | null | The id of the stored item, or null when none was written. |
mimeType | string | The final media type. A HEIC, HEIF or AVIF source is stored as JPEG or PNG. |
sizeBytes | number | The stored size. |
filename | string | The last part of the address's path, or imported-<id>.<ext>. |
width | number | The width in pixels, or 0 when it could not be read. |
height | number | The height in pixels, or 0 when it could not be read. |
| Status | Error | When |
|---|---|---|
| 400 | NodaroError with code validation_error | The address is not http or https, or it is a private or reserved IP address. The file is not a decodable image, or its format is not accepted, such as SVG or TIFF. |
| 422 | NodaroError with code fetch_failed | The address could not be reached, or it answered with an error. A host that resolves to a private address is included. |
| 403 | ImportRefusedError, a ForbiddenError | Importing links is blocked on your account, or it is closed, under the deployment's repeat-infringer policy (imports_blocked), the deployment does not allow the site (import_source_disabled), the deployment turned link imports off (link_imports_disabled: upload the file instead), or the content rights confirmation is missing while required, for another link or account, stale, expired or already used (a rights_confirmation_* code). |
| 413 | StorageExceededError with reason set to file_too_large | The picture is over 20 MB. |
| 413 | StorageExceededError with reason set to storage_limit_exceeded | Your storage is full. details carries usedBytes, quotaBytes, remainingBytes and tier. |
The Upload Image node uses the same import when you paste an image link. To throw away an imported picture you no longer need, call media.discard().
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)const { data, nextCursor } = await client.library.list({ type: "image", q: "poster" })A library row whose stored file is gone is not listed and not counted in totalCount, so every row's url is a string. Each LibraryAsset has id, type, filename, mimeType, sizeBytes, url, thumbnailUrl, metadata, isLibraryItem, uploadSource, source, sourceDetail, jobId, title and createdAt. jobId is the job that made the file, or null for an upload. title is the name its owner gave that job with client.jobs.rename(), or null when it is unnamed or there is no job. It is not the file's filename, which stays the download name. Older servers leave both fields out. totalCount is present on the first page only.
client.gallery
The public gallery of completed generations, and your favorites. A favorite is a bookmark on a generation: one of your own, or anyone's public one. Your jobs carry it as Job.favorited, and client.jobs.list({ favorited: true }) lists them. A deployment that hides the gallery answers 404 on these routes.
gallery.browse(params?)
Reads a page of the public gallery, newest first (GET /v1/gallery). No token is needed.
browse(params?: BrowseGalleryParams): Promise<GalleryPage>Prop
Type
const { data: items, nextCursor } = await client.gallery.browse({ type: "image", limit: 20 })The page is { data: GalleryItem[], nextCursor, totalCount? }, with totalCount on the first page only. Each item is { id, type, jobName, outputUrl, thumbnailUrl, createdAt, prompt, model, title?, preview? }, where id is the job id. title is the owner's own name for the job, from client.jobs.rename(), or null until it is named. It is public beside the prompt. favoritesOnly works only with your own userId, from client.me(). Without a token it throws UnauthorizedError. With another person's userId, or none, it throws ForbiddenError.
A deployment that does not allow public work answers every view with an empty page, except your own: your own userId with your token still lists your work.
gallery.toggleFavorite(jobId)
Favorites the job, or removes the favorite when the job already is one (POST /v1/gallery/favorite). favorited is the job's state after the call.
toggleFavorite(jobId: string): Promise<{ favorited: boolean }>const { favorited } = await client.gallery.toggleFavorite(jobId)You can favorite your own jobs and public ones. Another person's private job throws NotFoundError. OAuth apps need the assets:write scope.
gallery.favorites()
Returns your favorited job ids, newest first (GET /v1/gallery/favorites). OAuth apps need the assets:read or jobs:read scope.
favorites(): Promise<string[]>const ids = await client.gallery.favorites()client.contentRights
client.contentRights reads the declaration a person confirms before a link is imported, and records the confirmation that the import then carries (/v1/content-rights). See Content rights for the rules.
contentRights.declaration()
declaration(): Promise<{ version: number; text: string }>Returns the current declaration (GET /v1/content-rights/declaration). Show text as it is, with a checkbox that starts unticked. A confirmation names the version. The call needs no credential.
contentRights.confirm(input)
confirm(input: { url: string; declarationVersion: number }): Promise<{
id: string
declarationVersion: number
expiresAt: string
}>Records that the signed-in account confirmed the declaration for one link (POST /v1/content-rights/confirm). Any http(s) link that an import takes can be confirmed: a video's page, a file, a picture, or a page that the dubbing service reads. Pass the answered id as confirmationId to media.downloadVideo(), uploads.importUrl(), or the import that fetches the link, such as voices.dub() and audio.transcribe(). One confirmation opens one download of that link, by the same account, under the current declaration, within one hour. A 409 rights_confirmation_stale means that the declaration changed after you read it: read it again and ask again.
const { version, text } = await client.contentRights.declaration()
// Show `text` with a checkbox. Once it is ticked:
const { id } = await client.contentRights.confirm({ url, declarationVersion: version })
const { downloadId } = await client.media.downloadVideo({ url, maxHeight: 1080, confirmationId: id })Whether a download without a confirmation is refused is a setting of the deployment. Once it is on, such a download is refused with 403 rights_confirmation_required. Always send a confirmation.
contentRights.authorization()
authorization(): Promise<{ id: string; declarationVersion: number; createdAt: string } | null>Returns the account's standing authorization for imports through its API keys, connected apps and AI agents (GET /v1/content-rights/authorization). It returns null when there is none: never given, revoked, or given for an older declaration.
contentRights.authorize(input)
authorize(input: { declarationVersion: number }): Promise<{
id: string
declarationVersion: number
createdAt: string
}>Records that the person authorized imports through their keys, apps and agents, for the declaration version they saw (POST /v1/content-rights/authorization). Call it from the person's own signed-in session. A key, an app or an agent gets 403 forbidden. A 409 rights_confirmation_stale means that the declaration changed: read it again and ask again.
contentRights.revokeAuthorization()
revokeAuthorization(): Promise<{ revoked: boolean }>Revokes the standing authorization (DELETE /v1/content-rights/authorization). The record is kept. revoked is false when there was nothing to revoke. Call it from the person's own session.
client.media: import and prepare
media.downloadVideo(input)
Downloads the video behind a link, the video's page or a direct link to a video file, 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
exactSection?: boolean
requireAudio?: boolean
confirmationId?: string
}): 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
}, options?: { timeoutMs?: number; signal?: AbortSignal }): 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.discard(input)
Throws away a file you just made and no longer need (POST /v1/media/discard). Examples are a link download you replaced before using it, a cut replaced by a newer one, and a picture you imported and then cancelled.
discard(input: { url: string }): Promise<{ data: { discarded: boolean } }>Prop
Type
discard() is not a general delete. The file is removed only when all of these are true:
- The file is your own.
- The file was made by
downloadVideo(),process()or a URL import, such asuploads.importUrl(). - The file is less than 24 hours old.
- No job of yours uses the file, as an output or as an input, such as a run's
videoUrl.
The answer is then { data: { discarded: true } }. Otherwise nothing is touched, and the call fails with a 409 error whose code is not_discardable. discarded: false means there was nothing of yours at that address: the file is already gone, or it is not yours.
await client.media.discard({ url: oldCut.url })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 })media.splitMedia(input)
Cuts a video, an audio file or both into consecutive parts (POST /v1/split-media), as the Split into Chunks node does. Each input is split on its own.
splitMedia(input:
| {
videoUrl?: string
audioUrl?: string
mode?: "duration"
chunkDuration: number
minPartDuration?: number
audioFormat?: "mp3" | "wav" | "aac"
}
| { videoUrl: string; mode: "shots" }
): Promise<{ jobId: string }>Prop
Type
A run costs 22 credits, whatever the number of parts. The finished job's output has videoUrls and audioUrls, the parts in order, chunkCount, and partDurationsSec, the exact length of each part. When both inputs were split, audioPartDurationsSec lists the audio parts' lengths. A file can be up to 3 hours long and make at most 200 parts.
const { jobId } = await client.media.splitMedia({ videoUrl, chunkDuration: 60, minPartDuration: 30 })With mode: "shots", a video is cut into one part per shot instead, at the cuts measured from the file. It takes no part length and a video only: a separate audioUrl is a 400. Each part keeps the video's own sound. Videos can be up to 30 minutes long, with at most 200 shots, and the price is the same. The output then adds shotCount and shots, each { index, startSec, endSec, durationSec }.
const { jobId } = await client.media.splitMedia({ videoUrl: "https://example.com/ad.mp4", mode: "shots" })media.shotCuts(input)
Finds where every shot of a video starts and ends (POST /v1/shot-cuts), as the Shot Cuts node does. The cuts are measured from the file: every decoded frame is scored, and each hard cut starts a new shot. Dissolves and fades are not cuts.
shotCuts(input: { videoUrl: string; frames?: boolean }): Promise<{ jobId: string }>Prop
Type
A run has one flat price, with or without frames. Poll the job. The finished job's output_data is a ShotCutsOutput:
interface ShotCutsOutput {
json: {
shotCount: number
cutsSec: number[] // where each new shot starts: ascending, never 0; [] for one shot
durationSec: number // the video's length
shots: Array<{
index: number
startSec: number // seconds from the first frame, to the millisecond
endSec: number // the next shot's start, or the video's end
durationSec: number
firstFrameUrl?: string // with frames: the shot's first frame (PNG)
lastFrameUrl?: string // with frames: its last frame
}>
}
firstFrames?: string[] // with frames: each shot's first frame, in shot order
lastFrames?: string[] // with frames: each shot's last frame, in shot order
}const { jobId } = await client.media.shotCuts({ videoUrl: "https://example.com/ad.mp4", frames: true })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 or video layers over a video in one pass (POST /v1/video-overlay), as the Video Overlay node does. The render is local, with no AI model.
videoOverlay(input: VideoOverlayRequest): Promise<{ jobId: string }>Prop
Type
Each layer takes imageUrl or videoUrl, and start, in seconds from 0 to 3,600. An optional end ends the layer; 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. Without height, the box follows the image's or the video's aspect. 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, and zIndex runs from 0 to 100. animate is on by default for an image layer and off for a video layer.
Video layers and layouts
A video layer plays from its first frame at its start. It stops at its end or at the end of the base video. If the layer video runs out first, its last frame stays on screen.
A video layer is silent unless it has sound: { volume }, with a volume from 0 to 1. Its audio is then mixed with the base audio during its window, under a limiter that keeps peaks at or below -1 dBFS. When no layer has sound, the base audio is kept as it is.
layout places the base video and the first video layer:
layout | Result |
|---|---|
video-bottom, video-top | The whole base video, full width, against the bottom or top edge |
stack-whole | The whole base video directly above the first video layer, the pair centred |
stack-filled | The base fills the top half and the video layer the bottom half, both cropped |
pip | The first video layer, 30% of the frame wide, 4% in from its corner |
Every layout except pip needs outputAspect. The two stacks and pip need a video layer. A stack takes no baseBox.
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" },
],
})Video overlay price
A run with image layers only costs 22 credits, whatever the number of layers or the length. A run with any video layer is priced per started 5 seconds of the base video. Such a run takes a base video of up to 60 seconds and at most 2 video layers of up to 200 MB each. The base is measured before anything is charged. A base over 60 seconds fails with video_overlay_too_long, and an unreadable one with invalid_media_duration. When every layer starts after the end of the base, the run fails with video_overlay_starts_after_end.
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, audio_reencoded or layer_no_audio. layer_no_audio means a video layer with sound had no audio track, so it was drawn silent.
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 11 credits, and each extra platform size in variants adds to the price.
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.splitIntoLayers(input, options?)
Splits one image into a base picture and separate layers (POST /v1/split-into-layers), each element on its own transparent PNG, with its name, description, stacking order and the box it sits in on the base.
splitIntoLayers(
input: {
imageUrl: string
maxLayers?: number // 1–99, default 9
size?: "1K" | "1.5K" | "2K" | "auto" // default "1K"
prompt?: string // what to separate, up to 2,000 characters
sessionId?: string // one of your sessions: the job is filed in it
origin?: string // your app's slug, stored on the job
},
options?: {
idempotencyKey?: string // sent as the Idempotency-Key header (8+ characters)
signal?: AbortSignal
timeoutMs?: number
},
): Promise<{ jobId: string; deduped?: true }>maxLayerscaps the layers. A picture with more elements gets them merged into fewer layers, and none is dropped.sizeis each image's pixel budget:1Kis about one megapixel, andautokeeps the picture's own size, between 1K and 2K.promptnames which elements to split out. It may carry<bbox>x1 y1 x2 y2</bbox>marks, from 0 to 1000, to point at a region. Left empty, the main elements are found automatically.- A malformed body is a
400 validation_errorbefore any credits are held. A session that is not yours is a404, before anything is created or charged. - With
options.idempotencyKey, a retry of the same request answers the job it already started (deduped: true), instead of starting and charging a second one.
On Nodaro Cloud a run holds the price of the most images it may return, maxLayers + 1 with the base, at its size. It is charged only for the images it really returns. 1K and 1.5K are priced alike, and so are 2K and auto. A run that finds no element completes with the base picture only, at the one-image price. client.nodes.quote("split-into-layers", input) states a run's hold before it starts.
Poll jobs.get(jobId). The completed job's output_data is a SplitIntoLayersOutput:
interface SplitIntoLayersOutput {
imageUrl: string // the base picture: an opaque PNG
layers: string[] // each layer's transparent PNG, bottom to top
layerData: Array<{ // one per layer, in the same order
name?: string
description?: string
zIndex: number // stacking order: 1 sits on the base, higher is drawn above
width?: number // the layer FILE's own pixels, not the base's
height?: number
box?: { x1: number; y1: number; x2: number; y2: number } // where it sits on the base, in the base's pixels
boxNormalized?: { x1: number; y1: number; x2: number; y2: number } // the same box on a 0–1000 scale of the base
url: string
baseUrl?: string // the base picture this layer was cut from: the same as imageUrl
}>
layerCount: number // layers saved
layersNotSaved?: number // layers that could not be saved; they are not charged
libraryImages?: Array<{ url: string; name?: string }> // each layer as filed in the Library, under its name
}A layer is not in the base's pixels: it is cropped to its element, at about the size setting's pixel budget. To put a layer back in place, scale it to its box, where x1 and y1 are the top-left corner and x2 and y2 the bottom-right.
const { jobId } = await client.media.splitIntoLayers({ imageUrl: "https://example.com/poster.png", maxLayers: 5 })Full reference: Split into Layers.
media.videoMask(input)
Tracks a subject described in plain text through a video and returns a black-and-white mask video, white where the subject is (POST /v1/video-mask), as the Video Mask node does.
videoMask(input: {
videoUrl: string
prompt: string
threshold?: number
provider?: "sam-3.1" | "sam-3"
holdLastMask?: boolean
minSubjectHeightPct?: number
maxSubjectHeightPct?: number
zone?: { leftPct?: number; rightPct?: number; topPct?: number; bottomPct?: number } | null
perShot?: boolean
redetectEverySec?: number
}): Promise<{ jobId: string }>Prop
Type
The price depends on the clip's length, from 55 to 3,108 credits on SAM 3.1. A clip faster than 30 fps counts proportionally longer. The finished job's output has videoUrl (the mask), width, height, frames, chunks, shots, coverage, kept and recipe. chunks counts the requests sent to the model. shots is 1 unless perShot found cuts. recipe is the version of the masking method, which changes whenever the same clip and settings would give a different mask. kept is false only when the size and zone filters left nothing on any frame. Read kept rather than coverage, which is rounded to 4 decimals.
media.maskComposite(input)
Places a foreground video over a background video through a mask video (POST /v1/mask-composite), as the Mask Composite node does. White in the mask shows the foreground. With fill: "pixelate" or "blur", the masked area shows the background itself, pixelated or blurred, and no foreground is needed.
maskComposite(input: {
foregroundUrl?: string
backgroundUrl: string
maskUrl: string
invertMask?: boolean
feather?: number
grow?: number
opacity?: number
fill?: "video" | "pixelate" | "blur"
pixelSizePct?: number
blurStrengthPct?: number
backgroundFit?: "cover" | "contain" | "stretch"
audioSource?: "foreground" | "background" | "mix" | "none"
}): Promise<{ jobId: string }>Prop
Type
A run costs 11 credits for every started 5 seconds of the result.
const { jobId: maskJobId } = await client.media.videoMask({ videoUrl: talkUrl, prompt: "the person" })
// Poll the mask job. Its output videoUrl is the mask.
const { jobId } = await client.media.maskComposite({ foregroundUrl: talkUrl, backgroundUrl: beachUrl, maskUrl })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
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. an image-only videoOverlay costs 22 credits per run, one with a video layer is priced per started 5 seconds of the base, and imageOverlay costs 11 credits, and each extra platform size adds to the price.
How do I favorite a generation from code?
Call client.gallery.toggleFavorite(jobId). It favorites the job, or removes the favorite when the job already is one. List your favorites with client.gallery.favorites() or client.jobs.list({ favorited: true }).
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.
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.