# Mídia e uploads

> Envie arquivos ao Nodaro, liste sua biblioteca, baixe vídeos de redes sociais, corte clipes, embuta legendas e componha imagens e vídeos em TypeScript.

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

**`client.uploads`** armazena os seus arquivos no Nodaro, **`client.library`** lista as mídias que você já tem, e **`client.media`** prepara e edita mídias: baixa vídeos de redes sociais, corta clipes, embute legendas e compõe imagens e vídeos. A maioria dos métodos de `client.media` inicia um job e retorna o `jobId` dele, que você consulta periodicamente com [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions). Os métodos chamam os endpoints das APIs REST de [Envio de arquivos](https://nodaro.ai/docs/developers/api/uploads) e de [Voz e mídia](https://nodaro.ai/docs/developers/api/voice-and-media).

## Métodos
| Método | O que faz |
| --- | --- |
| [`uploads.upload(file)`](#uploadsuploadfile) | Envia um arquivo e obtém a URL pública dele |
| [`library.list(params?)`](#librarylistparams) | Lista as suas mídias armazenadas |
| [`media.downloadVideo(input)`](#mediadownloadvideoinput) | Baixa um vídeo de rede social para o seu armazenamento |
| [`media.downloadVideoProgress(downloadId, opts?)`](#mediadownloadvideoprogressdownloadid-opts) | Acompanha o progresso de um download |
| [`media.videoMetadata(input)`](#mediavideometadatainput) | Lê a duração e o tamanho de um vídeo de rede social sem baixá-lo |
| [`media.saveToStorage(input)`](#mediasavetostorageinput) | Copia a URL de uma mídia para o seu armazenamento |
| [`media.process(input)`](#mediaprocessinput) | Corta ou recorta um arquivo armazenado, sem custo |
| [`media.trimVideo(input)`](#mediatrimvideoinput) | Corta um vídeo, mantendo um intervalo |
| [`media.trimAudio(input)`](#mediatrimaudioinput) | Corta um áudio ou o extrai de um vídeo |
| [`media.addCaptions(input)`](#mediaaddcaptionsinput) | Embute legendas em um vídeo |
| [`media.stillToVideo(input)`](#mediastilltovideoinput) | Transforma uma imagem e uma faixa de áudio em um vídeo |
| [`media.slideshow(input)`](#mediaslideshowinput) | Transforma imagens e um áudio opcional em uma apresentação de slides |
| [`media.videoOverlay(input)`](#mediavideooverlayinput) | Posiciona camadas de imagem temporizadas sobre um vídeo |
| [`media.imageCollage(input)`](#mediaimagecollageinput) | Combina de 2 a 30 imagens em uma única colagem |
| [`media.imageOverlay(input)`](#mediaimageoverlayinput) | Posiciona camadas de imagem, texto, QR e forma sobre uma imagem |
| [`media.suggestOverlayPlacement(input)`](#mediasuggestoverlayplacementinput) | Pergunta a um modelo de visão onde uma camada deve ficar |

## client.uploads
### uploads.upload(file)
Envia um arquivo (`POST /v1/upload`, multipart) e retorna a URL pública e os detalhes de armazenamento dele. O SDK envia o arquivo como form data e deixa o runtime definir o boundary do multipart.

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

<TypeTable
type={{
file: { type: 'File', required: true, description: "O arquivo a enviar: uma imagem, um vídeo ou um arquivo de áudio. File é global nos navegadores e no Node.js 20 ou mais recente." },
}}
/>

```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,
})
```

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `url` | `string` | A URL pública do arquivo armazenado. Passe-a como `imageUrl`, `videoUrl` ou `audioUrl`. |
| `assetId` | `string \| null` | O ID do item armazenado, ou `null` para um upload anônimo. |
| `thumbnailUrl` | `string \| null` | Uma miniatura para imagens e vídeo, ou `null`. |
| `category` | `string` | `image`, `video` ou `audio`. |
| `filename` | `string` | O nome de arquivo exibido. |
| `mimeType` | `string` | O tipo de mídia que o servidor definiu. Pode ser diferente do que o navegador declarou, por exemplo `video/mp4` para um arquivo `.mp4` enviado como `application/octet-stream`. |
| `sizeBytes` | `number` | O tamanho armazenado. |
| `r2Key` | `string` | A chave de armazenamento do arquivo. |

Lança `StorageExceededError` quando o seu armazenamento está cheio. Os nós [**Enviar imagem** (Upload Image)](https://nodaro.ai/docs/nodes/image/upload-image), [**Enviar vídeo** (Upload Video)](https://nodaro.ai/docs/nodes/video/upload-video) e [**Enviar áudio** (Upload Audio)](https://nodaro.ai/docs/nodes/audio/upload-audio) usam o mesmo armazenamento.

## client.library
### library.list(params?)
Lista as suas mídias armazenadas com um cursor (`GET /v1/library`). Por padrão, retorna os itens salvos na sua biblioteca mais os itens compartilhados com você, como o seletor de mídia do editor os mostra.

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

<TypeTable
type={{
type: { type: '"all" | "image" | "video" | "audio"', default: '"all"', description: "Apenas um tipo de mídia." },
search: { type: 'string', description: "Apenas arquivos cujo nome contém este texto, sem diferenciar maiúsculas de minúsculas." },
limit: { type: 'number', default: '40', description: "O tamanho da página, de 1 a 100." },
cursor: { type: 'string', description: "O nextCursor da página anterior." },
owned: { type: 'boolean', default: 'false', description: "true retorna todos os arquivos que são seus, uploads e gerações, estejam ou não salvos na biblioteca." },
source: { type: 'string', description: "Apenas mídias vindas de uma destas origens: internal, mcp, app, cli, sdk, extension, web ou api. As mídias mais antigas, sem origem, nunca correspondem ao filtro." },
}}
/>

```ts
const { data: videos, nextCursor } = await client.library.list({ type: "video", limit: 20 })
for (const asset of videos) console.log(asset.filename, asset.url)
```

Cada `LibraryAsset` tem `id`, `type`, `filename`, `mimeType`, `sizeBytes`, `url`, `thumbnailUrl`, `metadata`, `isLibraryItem`, `uploadSource`, `source`, `sourceDetail` e `createdAt`. `totalCount` só aparece na primeira página.

## client.media: importar e preparar
### media.downloadVideo(input)
Baixa um vídeo do YouTube, TikTok, Instagram, X ou Facebook para o seu armazenamento (`POST /v1/download-video`). Retorna um `downloadId`, não um ID de job. Acompanhe-o com `downloadVideoProgress()`. O arquivo final vai para a sua biblioteca.

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

<TypeTable
type={{
url: { type: 'string', required: true, description: "O link do vídeo na rede social." },
maxHeight: { type: 'number', description: "A maior resolução a buscar, em pixels de altura, como 720. Omita-o para obter a melhor disponível." },
sectionStartSec: { type: 'number', description: "Busca apenas a parte que começa aqui, em segundos. A parte vem com cerca de 3 segundos a mais em cada ponta, porque o corte cai nos quadros-chave: corte-a para ter um corte exato. Informe os dois campos de trecho ou nenhum." },
sectionEndSec: { type: 'number', description: "O fim dessa parte, em segundos." },
requireAudio: { type: 'boolean', default: 'true', description: "Por padrão, um download sem som falha, porque em geral isso indica que a fonte respondeu mal, e outras rotas são tentadas antes. Defina false para aceitar um clipe que é realmente mudo." },
}}
/>

```ts
const { downloadId } = await client.media.downloadVideo({
url: "https://youtu.be/dQw4w9WgXcQ",
maxHeight: 720,
})
```

### media.downloadVideoProgress(downloadId, opts?)
Transmite ao vivo o progresso de um download como um iterador assíncrono (`GET /v1/download-video/progress/:id`, server-sent events). Emite um evento a cada 500 ms, aproximadamente, até o download terminar ou falhar, e então se encerra.

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

<TypeTable
type={{
downloadId: { type: 'string', required: true, description: "O ID que downloadVideo() retornou." },
signal: { type: 'AbortSignal', description: "Para de acompanhar o progresso." },
}}
/>

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

Cada evento é `{ phase, percent, videoUrl?, thumbnailUrl?, error? }`. Comece a iterar logo depois que `downloadVideo()` retornar, porque o registro de progresso expira pouco depois do fim do download. O `timeoutMs` do cliente não se aplica, porque downloads grandes levam minutos.

### media.videoMetadata(input)
Lê a duração, as dimensões, o título e o status de transmissão ao vivo de um vídeo de rede social **sem** baixá-lo (`POST /v1/video-metadata`). Responde diretamente, sem job. Use-o para decidir se vale buscar apenas um trecho.

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

<TypeTable
type={{
url: { type: 'string', required: true, description: "O link do vídeo na rede social." },
}}
/>

```ts
const meta = await client.media.videoMetadata({ url: "https://youtu.be/dQw4w9WgXcQ" })
```

Os campos são preenchidos na medida do possível: uma plataforma pode não informar todos.

### media.saveToStorage(input)
Copia um arquivo de mídia de uma URL para o seu armazenamento no Nodaro (`POST /v1/save-to-storage`). O servidor busca o arquivo, então nada passa pelo seu cliente.

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

<TypeTable
type={{
mediaUrl: { type: 'string', required: true, description: "A URL do arquivo a copiar." },
filename: { type: 'string', description: "O nome de arquivo com que armazená-lo." },
mediaType: { type: '"image" | "video" | "audio"', description: "O tipo de mídia." },
}}
/>

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

O nó [**Salvar no armazenamento** (Save to Storage)](https://nodaro.ai/docs/nodes/publish/save-to-storage) faz o mesmo dentro de um workflow.

### media.process(input)
Corta ou recorta um arquivo que já está no seu armazenamento (`POST /v1/media/process`). Responde diretamente e não custa nada. Use-o para preparar uma fonte antes de uma etapa paga.

```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: "O arquivo armazenado a processar." },
type: { type: '"video" | "audio"', required: true, description: "O tipo de arquivo." },
trim: { type: '{ startTime: number; endTime: number }', description: "Mantém apenas este intervalo, em segundos." },
crop: { type: '{ x, y, width, height }', description: "Mantém apenas este retângulo do quadro, em pixels." },
format: { type: '"mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"', description: "O formato de saída." },
deleteSource: { type: 'boolean', default: 'false', description: "Exclui a fonte depois, quando ela é sua e nada mais a usa." },
}}
/>

```ts
const { data } = await client.media.process({
sourceUrl: stored.url,
type: "video",
trim: { startTime: 12, endTime: 42 },
})
console.log(data.url, data.sizeBytes)
```

O resultado tem `url`, `thumbnailUrl`, `assetId`, `sizeBytes`, `mimeType` e `metadata`.

### media.trimVideo(input)
Corta um vídeo, mantendo um intervalo (`POST /v1/trim-video`), como faz o nó [**Cortar vídeo** (Trim Video)](https://nodaro.ai/docs/nodes/video/trim-video). Informe o intervalo na unidade que for mais conveniente.

```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: "O vídeo a cortar." },
startTime: { type: 'number', description: "Onde começa a parte mantida, em segundos." },
endTime: { type: 'number', description: "Onde termina a parte mantida, em segundos." },
trimStartFrames: { type: 'number', description: "Quadros a cortar do início." },
trimEndFrames: { type: 'number', description: "Quadros a cortar do fim." },
trimStartSeconds: { type: 'number', description: "Segundos a cortar do início." },
trimEndSeconds: { type: 'number', description: "Segundos a cortar do fim." },
keepFirstSeconds: { type: 'number', description: "Mantém apenas os primeiros segundos." },
keepLastSeconds: { type: 'number', description: "Mantém apenas os últimos segundos." },
}}
/>

```ts
const { jobId } = await client.media.trimVideo({ videoUrl, keepFirstSeconds: 15 })
```

### media.trimAudio(input)
Corta um áudio, mantendo um intervalo, ou o extrai de um vídeo (`POST /v1/trim-audio`), como faz o nó [**Cortar áudio** (Trim Audio)](https://nodaro.ai/docs/nodes/audio/trim-audio).

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

<TypeTable
type={{
videoUrl: { type: 'string', description: "Um vídeo de onde tirar o áudio. Informe videoUrl ou audioUrl." },
audioUrl: { type: 'string', description: "Um arquivo de áudio a cortar." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "O formato de saída." },
startTime: { type: 'number', description: "O início do intervalo, em segundos." },
endTime: { type: 'number', description: "O fim do intervalo, em segundos." },
}}
/>

```ts
const { jobId } = await client.media.trimAudio({ videoUrl, startTime: 5, endTime: 35 })
```

## client.media: legendas
### media.addCaptions(input)
Embute legendas em um vídeo (`POST /v1/add-captions`), como faz o nó [**Adicionar legendas** (Add Captions)](https://nodaro.ai/docs/nodes/video/add-captions). Informe as palavras como `text`, informe `captions` com tempos por palavra ou deixe o Nodaro transcrever a fala, que é o padrão.

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

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "O vídeo a legendar." },
style: { type: '"subtitle" | "word-highlight" | "karaoke" | "tiktok-words" | "word-pop" | "bouncy"', description: "subtitle é um bloco estático. Os outros são estilos cinéticos, que animam palavra por palavra." },
text: { type: 'string', description: "No subtitle, a própria legenda, mostrada como um único bloco durante o vídeo inteiro. Em um estilo cinético, apenas um texto de reserva para quando a transcrição não encontra nada." },
captions: { type: '{ text, startMs, endMs }[]', description: "Legendas com tempos por palavra, uma entrada por palavra nos estilos cinéticos. Com elas, nenhuma transcrição é executada." },
autoTranscribe: { type: 'boolean', default: 'true', description: "Transcreve a fala quando nada mais fornece as palavras." },
transcribeProvider: { type: '"whisper" | "incredibly-fast-whisper" | "elevenlabs-stt"', default: '"incredibly-fast-whisper"', description: "O mecanismo de transcrição. Os estilos cinéticos precisam de um que retorne tempos por palavra." },
position: { type: '"bottom" | "top" | "center"', description: "Onde as legendas ficam." },
positionY: { type: 'number', description: "O centro vertical da legenda, em porcentagem da altura. Substitui position." },
fontSize: { type: 'number', description: "O tamanho da fonte." },
color: { type: 'string', description: "A cor do texto." },
backgroundColor: { type: 'string', description: "A cor de fundo atrás do texto." },
look: { type: '"outline" | "clean"', description: "Uma predefinição de estilo. outline usa letras maiúsculas grossas, com contorno preto e a palavra falada em amarelo." },
fontFamily: { type: 'string', description: "Uma das fontes suportadas." },
fontWeight: { type: 'number', description: "De 100 a 900, em passos de 100." },
strokeColor: { type: 'string', description: "A cor do contorno." },
strokeWidth: { type: 'number', description: "A espessura do contorno." },
uppercase: { type: 'boolean', description: "Mostra o texto em maiúsculas." },
maxWordsPerLine: { type: 'number', description: "No máximo esta quantidade de palavras por linha, de 1 a 20." },
highlightColor: { type: 'string', description: "Apenas estilos cinéticos: a cor da palavra que está sendo falada." },
animate: { type: 'boolean', default: 'true', description: "Apenas estilos cinéticos: false congela o movimento por palavra." },
segments: { type: 'CaptionSegmentInput[]', description: "Tratamentos diferentes para intervalos de tempo separados. Cada segmento tem startMs, endMs e os próprios style, look, palavras e ajustes." },
}}
/>

```ts
const { jobId } = await client.media.addCaptions({
videoUrl: "https://example.com/talk.mp4",
style: "word-highlight",
maxWordsPerLine: 2,
})
```

**De onde vêm as palavras.** Quando uma chamada traz várias fontes, as `captions` com tempos por palavra têm prioridade, depois o `text` no estilo `subtitle` e, por último, a transcrição. No `subtitle`, `text` é a legenda: ela é embutida como um único bloco estático durante o vídeo inteiro e nunca é substituída por uma transcrição. Use `\n` no texto para forçar uma quebra de linha. Em um estilo cinético, `text` é apenas um texto de reserva. Ele é usado quando a transcrição não encontra nada ou `autoTranscribe` é `false`, e então as palavras dele são distribuídas por igual ao longo do vídeo.

**Estilos e looks.** Nos estilos cinéticos, um `look` não definido é renderizado como `outline`. No `subtitle`, é renderizado como `clean`. Os ajustes explícitos substituem campos individuais do look. Um segmento que define o próprio `look` parte dessa predefinição e não herda os ajustes do nível superior.

**Ajustes no `subtitle`.** Os ajustes de estilo (`look`, `fontFamily`, `fontWeight`, `strokeColor`, `strokeWidth`, `uppercase`, `positionY` e `maxWordsPerLine`) também funcionam no `subtitle`. Um subtitle com qualquer um deles é cobrado pelo preço cinético, e um subtitle de texto simples é cobrado pelo preço mais baixo. `highlightColor` e `animate` são exclusivos dos estilos cinéticos e são recusados com um 400 no `subtitle`. Um subtitle transcrito automaticamente também é cobrado pelo preço cinético.

**Linhas.** `word-highlight`, `karaoke` e `bouncy` mostram uma linha por vez. Uma linha termina no fim de uma frase, em uma pausa de 0,5 segundo ou mais, quando ocupa cerca de 85% da largura do quadro ou ao chegar a `maxWordsPerLine` palavras. `word-pop` mostra uma palavra por vez, então `maxWordsPerLine` não tem efeito nesse estilo. Uma página de `tiktok-words` nunca atravessa o fim de uma frase nem uma pausa. Ambas ficam na tela por no máximo 1,5 segundo depois da última palavra falada. O `startMs` e o `endMs` de uma palavra marcam o tempo do efeito dela, não por quanto tempo ela fica visível.

**Mecanismos de transcrição.** Um estilo cinético precisa de tempos por palavra, então `transcribeProvider` deve ser `incredibly-fast-whisper`, o padrão aqui, ou `elevenlabs-stt`. `whisper` não tem tempos por palavra. Ele é recusado com `400 validation_error` apenas quando a transcrição é a única fonte possível das palavras. No `subtitle`, qualquer mecanismo funciona, porque um subtitle só precisa dos tempos de cada frase.

**Taxa de quadros.** Uma renderização estilizada mantém a taxa de quadros do vídeo de origem, arredondada para um número inteiro entre 15 e 60. Uma fonte com taxa de quadros variável, ou um clipe longo demais para o limite de quadros, é renderizado a 30 fps.

### Transcrever, corrigir e embutir
As `words` de um job de [`client.audio.transcribe()`](https://nodaro.ai/docs/developers/sdk/voices-and-audio) têm exatamente o formato que `captions` aceita. Corrija uma palavra e depois embuta as legendas sem uma segunda transcrição:

```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: renderizar e compor
### media.stillToVideo(input)
Transforma uma imagem fixa e uma faixa de áudio em um MP4 (`POST /v1/still-to-video`), como faz o nó [**Imagem fixa para vídeo** (Still to Video)](https://nodaro.ai/docs/nodes/video/still-to-video). É renderizado no servidor, sem modelo de IA, e não custa créditos. O vídeo dura o mesmo que o áudio; não há campo de duração.

```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: "A imagem fixa." },
audioUrl: { type: 'string', required: true, description: "A faixa de áudio. A duração dela define a duração do vídeo." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"', default: '"none"', description: "Como a câmera se move sobre a imagem." },
intensity: { type: 'number', description: "A intensidade do movimento, de 1 a 10." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "A resolução de saída." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "O formato de saída." },
fps: { type: '24 | 30', description: "A taxa de quadros." },
fit: { type: '"cover" | "contain"', description: "cover recorta a imagem para preencher o quadro. contain mostra a imagem inteira, com barras na cor padColor." },
padColor: { type: 'string', description: "A cor das barras quando fit é contain." },
}}
/>

```ts
const { jobId } = await client.media.stillToVideo({ imageUrl: coverUrl, audioUrl: podcastUrl, motion: "ken-burns" })
```

### media.slideshow(input)
Transforma de 2 a 100 imagens e uma faixa de áudio opcional em uma apresentação de slides em MP4 (`POST /v1/slideshow`), como faz o nó [**Apresentação de slides** (Slideshow)](https://nodaro.ai/docs/nodes/video/slideshow). Não custa créditos. Para uma única imagem, 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: "De 2 a 100 imagens, em ordem." },
audioUrl: { type: 'string', description: "Uma faixa de áudio. Com ela, o vídeo dura o mesmo que o áudio." },
imageDurations: { type: 'Array<number | null>', description: "Segundos por imagem, na mesma ordem. null significa automático. Com áudio, valores fixados cuja soma não bate com a duração são ajustados em escala para caber." },
perImageDuration: { type: 'number', description: "Segundos por imagem quando não há áudio." },
transition: { type: 'string', description: "A transição entre as imagens." },
transitionDuration: { type: 'number', description: "A duração de cada transição, em segundos." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"', description: "Como a câmera se move sobre cada imagem." },
intensity: { type: 'number', description: "A intensidade do movimento." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "A resolução de saída." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "O formato de saída." },
fps: { type: '24 | 30', description: "A taxa de quadros." },
fit: { type: '"cover" | "contain"', description: "Recortar para preencher, ou mostrar cada imagem inteira, com barras." },
padColor: { type: 'string', description: "A cor das barras." },
}}
/>

```ts
const { jobId } = await client.media.slideshow({ imageUrls: frames, audioUrl: musicUrl, motion: "alternate" })
```

Com áudio, o tempo é dividido por igual, a menos que `imageDurations` fixe a duração de algumas imagens. Sem áudio, o vídeo dura o número de imagens vezes `perImageDuration` e fica sem som.

### media.videoOverlay(input)
Posiciona de 1 a 20 camadas de imagem temporizadas sobre um vídeo em uma única passada (`POST /v1/video-overlay`), como faz o nó [**Sobreposição em vídeo** (Video Overlay)](https://nodaro.ai/docs/nodes/video/video-overlay). Custa **22 créditos** por execução, seja qual for o número de camadas ou a duração. O áudio do próprio vídeo é mantido como está.

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

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "O vídeo base." },
layers: { type: 'Array<{ imageUrl, start, end?, ... }>', required: true, description: "De 1 a 20 camadas. Cada uma tem imageUrl e start, e os opcionais end, preset, corner, anchor, x, y, width, height, fit, opacity, animate e zIndex." },
outputAspect: { type: '"16:9" | "9:16" | "1:1" | "4:5"', description: "Reenquadra a saída neste formato. Omita-o para manter o tamanho e a taxa de quadros do próprio vídeo." },
baseFit: { type: '"contain" | "cover"', default: '"cover"', description: "Como o vídeo preenche o novo formato, com outputAspect." },
backgroundColor: { type: 'string', default: '"#000000"', description: "A cor de fundo, como #RRGGBB, com outputAspect." },
}}
/>

Cada camada recebe `imageUrl` e `start`, em segundos, de 0 a 3.600, e um `end` opcional; sem `end`, ela fica até o fim do vídeo. `preset` é `card`, `corner-badge` ou `full-frame`. Os campos da caixa, `x` e `y`, vão de -100 a 100, e `width` e `height`, de 1 a 100, todos em porcentagem do quadro de saída. Um campo de caixa explícito substitui a predefinição. Uma camada sem nenhum dos dois é um selo de canto, no canto inferior direito ou no `corner` que ela indicar. `opacity` vai de 0 a 1, `animate` vem ativado por padrão e `zIndex` vai de 0 a 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" },
],
})
```

A saída do job concluído tem `videoUrl`, `thumbnailUrl`, `width`, `height`, `durationSec` e `warnings`. Cada aviso é `{ layer?, slot?, code, detail }`, com um código como `clipped`, `skipped`, `animated_first_frame` ou `audio_reencoded`.

### media.imageCollage(input)
Combina de 2 a 30 imagens em uma única colagem grande, em 2K ou 4K (`POST /v1/image-collage`), como faz o nó [**Colagem de imagens** (Image Collage)](https://nodaro.ai/docs/nodes/image/image-collage).

```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: "De 2 a 30 imagens, em ordem." },
layout: { type: '"smart" | "grid"', default: '"smart"', description: "smart monta linhas na proporção de cada imagem, sem recortar, então a altura varia. grid usa células iguais, com barras." },
imageSizes: { type: 'Array<0 | 1 | 2 | 3>', description: "Tamanhos relativos para o layout smart, na mesma ordem de imageUrls: 0 automático, 1 grande, 2 médio, 3 pequeno. O layout grid os ignora." },
numbered: { type: 'boolean', default: 'false', description: "Carimba um número de sequência em cada imagem, como em um storyboard." },
imageLabels: { type: 'Array<string | null>', description: "Uma legenda por imagem, com até 80 caracteres, mostrada depois do número. null ou uma string vazia significa nenhuma." },
badgePosition: { type: '"top-left" | "top-right"', default: '"top-left"', description: "O canto para os números e os rótulos." },
resolution: { type: '"2K" | "4K"', description: "O tamanho da colagem." },
aspectRatio: { type: 'string', description: "O formato da colagem." },
gap: { type: 'number', description: "O espaço entre as imagens." },
backgroundColor: { type: 'string', description: "A cor de fundo." },
}}
/>

```ts
const { jobId } = await client.media.imageCollage({
imageUrls: shotUrls,
numbered: true,
imageLabels: ["Wide", "Medium", "Close-up"],
})
```

Números e rótulos nunca mudam o layout, o tamanho de saída nem o preço. Um rótulo longo demais para a imagem é encurtado com reticências.

### media.imageOverlay(input)
Posiciona de 1 a 12 camadas sobre uma imagem base, com precisão de pixel (`POST /v1/image-overlay`), como faz o nó [**Sobreposição em imagem** (Image Overlay)](https://nodaro.ai/docs/nodes/image/image-overlay). É uma composição sem modelo de IA. Custa **11 créditos**, e cada tamanho de plataforma extra em `variants` aumenta o preço.

```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: "A imagem base." },
layers: { type: 'Array<{ kind?, imageUrl?, anchor?, ... }>', required: true, description: "De 1 a 12 camadas. Veja os campos da camada abaixo." },
canvas: { type: '{ width: number; height: number; backgroundColor?: string }', description: "Um tamanho de saída. Sem ele, a saída mantém o tamanho em pixels da imagem base." },
baseFit: { type: '"contain" | "cover"', description: "Como a imagem base preenche o canvas." },
outputFormat: { type: '"png" | "jpg" | "webp"', default: '"png"', description: "O formato de saída. png mantém a transparência." },
variants: { type: 'string[]', description: "Tamanhos de plataforma extras a renderizar na mesma execução, por ID de plataforma, como youtube-thumbnail. Qualquer uma das 12 plataformas." },
maskMode: { type: '"around" | "layers" | "outside" | "none"', description: "O que a saída de máscara mostra: um anel em volta das camadas (o padrão no editor), as camadas, tudo exceto as camadas, ou nenhuma máscara." },
maskSpread: { type: 'number', description: "A largura do anel, em pixels, para maskMode around." },
qrText: { type: 'string', description: "O conteúdo para uma camada QR que define qr.fromInput." },
}}
/>

**Camadas.** Uma camada é de um destes quatro tipos:

- **Uma imagem**: `kind: "image"`, o padrão, com `imageUrl`.
- **Texto real**: `kind: "text"` com um objeto `text`. Ele define o conteúdo, a fonte, o peso, a cor, o alinhamento, o contorno e a caixa de fundo, além de um tamanho em porcentagem da altura da base.
- **Um QR code**: `kind: "qr"` com `qr: { text }`.
- **Uma forma chapada**: `kind: "shape"` com `shape: { shape, color }`.

**As posições são porcentagens da imagem base**, então a mesma chamada funciona em uma prévia em 1K e em uma renderização em 4K:

- `anchor` é uma de nove posições, `center` por padrão.
- `x` e `y` deslocam a camada a partir da âncora, em porcentagem da largura e da altura da base. Com uma âncora à direita ou embaixo, um valor negativo a desloca para dentro.
- `width` é em porcentagem da largura da base, 25 por padrão. A altura segue a proporção da camada, a menos que você defina `height`, e então `fit` decide como a camada preenche a caixa.
- `opacity` vai de 0 a 1, `rotation` é em graus em torno do centro da camada, e `blend` é `over` (o padrão), `multiply` ou `screen`.
- `shadow` adiciona uma sombra suave, `roundedCorners` arredonda os cantos, em pixels, e `zIndex` define a ordem de empilhamento.

```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"],
})
```

A saída do job concluído tem `imageUrl`, com `width` e `height`, `maskUrl` e `variants`, cada uma no formato `{ id, label, width, height, url }`. As camadas SVG são desenhadas com nitidez no tamanho final. Uma camada QR com `qr.fromInput: true` obtém o conteúdo de `qrText`, e uma execução sem `qrText` é recusada com um 400 que o indica.

### media.suggestOverlayPlacement(input)
Pergunta a um modelo de visão **onde** uma camada deve ficar sobre uma imagem base (`POST /v1/image-overlay/suggest-placement`). O modelo mantém a camada longe de rostos, do assunto principal e de texturas carregadas. Responde diretamente, sem um job a consultar periodicamente, e é cobrado como uma chamada do nó **Descrever imagem** (Describe Image). Nada é composto: você aplica o posicionamento.

```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: "A imagem base." },
layerAspect: { type: 'number', default: '1', description: "A largura da camada dividida pela altura, para que a caixa proposta mantenha as proporções." },
intent: { type: 'string', description: "O que a camada é, como um logo ou um selo de preço." },
safeArea: { type: '{ x, y, w, h }', description: "A região que fica sempre visível, em frações do canvas. O posicionamento fica dentro dela." },
llmModel: { type: 'string', description: "O modelo de visão a usar." },
}}
/>

```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` tem `anchor`, `x`, `y` e `width` nas unidades em porcentagem de `imageOverlay`, além de um `reason` de uma frase que você pode mostrar ao usuário. `jobId` é o registro de cobrança.

## Frequently asked questions

### Como enviar um arquivo com o SDK do Nodaro?

Chame client.uploads.upload(file) com um File. Ele retorna uma url pública que você pode passar para qualquer nó que receba a URL de uma imagem, de um vídeo ou de um áudio.

### Como adicionar legendas a um vídeo pelo código?

Chame client.media.addCaptions com a videoUrl e um style. Sem text nem captions, o Nodaro transcreve a fala. A chamada retorna um jobId; o job concluído contém o vídeo legendado.

### Quais métodos de mídia não custam créditos?

stillToVideo e slideshow são renderizados sem modelo de IA e não custam créditos, e media.process é gratuito. videoOverlay custa 22 créditos por execução, e imageOverlay custa 11 créditos, e cada tamanho de plataforma extra aumenta o preço.

### Como acompanhar o download de um vídeo?

client.media.downloadVideo retorna um downloadId. Percorra client.media.downloadVideoProgress(downloadId) para receber a fase e a porcentagem até o download terminar ou falhar.
