# 미디어 및 업로드

> Nodaro SDK로 TypeScript에서 파일을 업로드하고, 미디어 라이브러리를 나열하며, 소셜 동영상 다운로드, 클립 자르기, 자막 입히기, 이미지와 동영상 합성을 처리합니다.

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

<strong>`client.uploads`</strong>는 내 파일을 Nodaro에 저장하고, <strong>`client.library`</strong>는 이미 가진 미디어를 나열하고, <strong>`client.gallery`</strong>는 공개 갤러리와 내 즐겨찾기를 둘러보며, <strong>`client.media`</strong>는 미디어를 준비하고 편집합니다. 소셜 동영상을 다운로드하고, 클립을 자르고, 자막을 입히고, 이미지와 동영상을 합성합니다. 대부분의 `client.media` 메서드는 작업을 시작하고 그 `jobId`를 반환하며, 이 작업은 [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions)로 폴링합니다. 이 메서드들은 [업로드](https://nodaro.ai/docs/developers/api/uploads) REST API와 [음성 및 미디어](https://nodaro.ai/docs/developers/api/voice-and-media) REST API의 엔드포인트를 호출합니다.

## 메서드
| 메서드 | 기능 |
| --- | --- |
| [`uploads.upload(file)`](#uploadsuploadfile) | 파일 하나를 업로드하고 공개 URL을 받습니다 |
| [`library.list(params?)`](#librarylistparams) | 저장된 내 미디어를 나열하고 검색합니다 |
| [`gallery.browse(params?)`](#gallerybrowseparams) | 공개 갤러리를 둘러봅니다 |
| [`gallery.toggleFavorite(jobId)`](#gallerytogglefavoritejobid) | 생성 결과를 즐겨찾기에 추가하거나 제거합니다 |
| [`gallery.favorites()`](#galleryfavorites) | 즐겨찾기한 작업 ID를 나열합니다 |
| [`media.downloadVideo(input)`](#mediadownloadvideoinput) | 소셜 동영상을 내 저장 공간에 다운로드합니다 |
| [`media.downloadVideoProgress(downloadId, opts?)`](#mediadownloadvideoprogressdownloadid-opts) | 다운로드 진행 상황을 따라갑니다 |
| [`media.videoMetadata(input)`](#mediavideometadatainput) | 소셜 동영상을 다운로드하지 않고 길이와 크기를 읽어 옵니다 |
| [`media.saveToStorage(input)`](#mediasavetostorageinput) | 미디어 URL을 내 저장 공간으로 복사합니다 |
| [`media.process(input)`](#mediaprocessinput) | 저장된 파일을 무료로 자르거나 크롭합니다 |
| [`media.trimVideo(input)`](#mediatrimvideoinput) | 동영상을 지정한 구간으로 자릅니다 |
| [`media.trimAudio(input)`](#mediatrimaudioinput) | 오디오를 자르거나 동영상에서 추출합니다 |
| [`media.splitMedia(input)`](#mediasplitmediainput) | 동영상이나 오디오 파일을 최소 길이와 최대 길이 사이의 구간으로 자릅니다 |
| [`media.addCaptions(input)`](#mediaaddcaptionsinput) | 동영상에 자막을 입힙니다 |
| [`media.stillToVideo(input)`](#mediastilltovideoinput) | 이미지와 오디오 트랙으로 동영상을 만듭니다 |
| [`media.slideshow(input)`](#mediaslideshowinput) | 이미지와 선택 사항인 오디오로 슬라이드쇼를 만듭니다 |
| [`media.videoOverlay(input)`](#mediavideooverlayinput) | 동영상 위에 시간이 지정된 이미지 레이어를 배치합니다 |
| [`media.imageCollage(input)`](#mediaimagecollageinput) | 이미지 2~30개를 콜라주 하나로 합칩니다 |
| [`media.imageOverlay(input)`](#mediaimageoverlayinput) | 이미지 위에 이미지, 텍스트, QR, 도형 레이어를 배치합니다 |
| [`media.videoMask(input)`](#mediavideomaskinput) | 텍스트로 설명한 대상을 추적해 마스크 동영상을 받습니다 |
| [`media.maskComposite(input)`](#mediamaskcompositeinput) | 마스크 동영상을 통해 한 동영상을 다른 동영상 위에 배치합니다 |
| [`media.suggestOverlayPlacement(input)`](#mediasuggestoverlayplacementinput) | 레이어를 둘 위치를 비전 모델에 묻습니다 |

## client.uploads
### uploads.upload(file)
파일 하나를 업로드하고(`POST /v1/upload`, 멀티파트) 공개 URL과 저장 정보를 반환합니다. SDK는 파일을 폼 데이터로 보내며, 멀티파트 경계는 런타임이 설정하도록 맡깁니다.

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

<TypeTable
type={{
file: { type: 'File', required: true, description: "업로드할 파일로, 이미지, 동영상, 오디오 파일입니다. File은 브라우저와 Node.js 20 이상에서 전역 객체입니다." },
}}
/>

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

| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `url` | `string` | 저장된 파일의 공개 URL입니다. `imageUrl`, `videoUrl`, `audioUrl`로 전달하세요. |
| `assetId` | `string \| null` | 저장된 항목의 ID이며, 익명 업로드이면 `null`입니다. |
| `thumbnailUrl` | `string \| null` | 이미지와 동영상의 썸네일이며, 없으면 `null`입니다. |
| `category` | `string` | `image`, `video`, `audio` 중 하나입니다. |
| `filename` | `string` | 표시되는 파일 이름입니다. |
| `mimeType` | `string` | 서버가 확정한 미디어 타입입니다. 브라우저가 선언한 타입과 다를 수 있습니다. 예를 들어 `application/octet-stream`으로 보낸 `.mp4` 파일은 `video/mp4`가 됩니다. |
| `sizeBytes` | `number` | 저장된 크기입니다. |
| `r2Key` | `string` | 파일의 저장 키입니다. |

저장 공간이 가득 차면 `StorageExceededError`를 발생시킵니다. [이미지 업로드](https://nodaro.ai/docs/nodes/image/upload-image), [동영상 업로드](https://nodaro.ai/docs/nodes/video/upload-video), [오디오 업로드](https://nodaro.ai/docs/nodes/audio/upload-audio) 노드도 같은 저장 공간을 씁니다.

## client.library
### library.list(params?)
저장된 내 미디어를 커서와 함께 나열합니다(`GET /v1/library`). 기본적으로 에디터의 미디어 선택기에 보이는 것처럼, 라이브러리에 저장한 항목과 나에게 공유된 항목을 반환합니다.

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

<TypeTable
type={{
type: { type: '"all" | "image" | "video" | "audio"', default: '"all"', description: "한 종류의 미디어만 반환합니다." },
q: { type: 'string', description: "검색어입니다. 파일 이름에 모든 단어가 들어 있는 파일만 반환하며, 대소문자는 구분하지 않습니다. 처음 5개 단어만 쓰이며, %, _, *는 와일드카드가 아닙니다." },
search: { type: 'string', description: "q의 이전 이름입니다. 여전히 작동하지만 더 이상 권장되지 않습니다. 둘 다 주면 q가 전송됩니다." },
limit: { type: 'number', default: '40', description: "페이지 크기로, 1~100입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
owned: { type: 'boolean', default: 'false', description: "true이면 라이브러리에 저장했는지와 관계없이, 업로드와 생성 결과를 포함해 내가 소유한 모든 파일을 반환합니다." },
source: { type: 'string', description: "한 경로에서 온 미디어만 반환합니다. internal, mcp, app, cli, sdk, extension, web, api 중 하나입니다. source가 없는 예전 미디어는 일치하지 않습니다." },
}}
/>

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

```ts
const { data, nextCursor } = await client.library.list({ type: "image", q: "poster" })
```

각 `LibraryAsset`에는 `id`, `type`, `filename`, `mimeType`, `sizeBytes`, `url`, `thumbnailUrl`, `metadata`, `isLibraryItem`, `uploadSource`, `source`, `sourceDetail`, `jobId`, `createdAt`이 있습니다. `jobId`는 파일을 만든 작업이며, 업로드한 파일이면 `null`입니다. 오래된 서버는 이 필드를 생략합니다. `totalCount`는 첫 페이지에만 있습니다.

## client.gallery
완료된 생성 결과의 공개 갤러리와 내 즐겨찾기입니다. 즐겨찾기는 생성 결과에 다는 북마크로, 내 생성 결과나 다른 사람의 공개 생성 결과에 달 수 있습니다. 내 작업에는 `Job.favorited`로 담기며, `client.jobs.list({ favorited: true })`로 나열할 수 있습니다. 갤러리를 숨긴 배포 환경은 이 경로에 404로 응답합니다.

### gallery.browse(params?)
공개 갤러리의 한 페이지를 최신순으로 읽습니다(`GET /v1/gallery`). 토큰이 필요 없습니다.

```ts
browse(params?: BrowseGalleryParams): Promise
```

<TypeTable
type={{
type: { type: '"image" | "video" | "audio"', description: "한 종류의 미디어만 반환합니다." },
userId: { type: 'string', description: "이 사람의 작업물만 반환합니다. 내 ID를 쓰면 비공개 항목도 포함됩니다." },
favoritesOnly: { type: 'boolean', description: "내 userId와 함께 쓰면 즐겨찾기한 항목만 반환합니다." },
limit: { type: 'number', default: '20', description: "페이지 크기이며, 1~50입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const { data: items, nextCursor } = await client.gallery.browse({ type: "image", limit: 20 })
```

페이지는 `{ data: GalleryItem[], nextCursor, totalCount? }`이며, `totalCount`는 첫 페이지에만 있습니다. 각 항목은 `{ id, type, jobName, outputUrl, thumbnailUrl, createdAt, prompt, model, preview? }`이고, `id`는 작업 ID입니다. `favoritesOnly`는 [`client.me()`](https://nodaro.ai/docs/developers/sdk/client#me)에서 얻은 내 `userId`와 함께 쓸 때만 작동합니다. 토큰이 없으면 `UnauthorizedError`를 발생시킵니다. 다른 사람의 `userId`를 쓰거나 `userId`가 없으면 `ForbiddenError`를 발생시킵니다.

### gallery.toggleFavorite(jobId)
작업을 즐겨찾기에 추가하거나, 이미 즐겨찾기라면 제거합니다(`POST /v1/gallery/favorite`). `favorited`는 호출 후 작업의 상태입니다.

```ts
toggleFavorite(jobId: string): Promise<{ favorited: boolean }>
```

```ts
const { favorited } = await client.gallery.toggleFavorite(jobId)
```

내 작업과 공개 작업을 즐겨찾기할 수 있습니다. 다른 사람의 비공개 작업은 `NotFoundError`를 발생시킵니다. OAuth 앱에는 `assets:write` 스코프가 필요합니다.

### gallery.favorites()
즐겨찾기한 작업 ID를 최신순으로 반환합니다(`GET /v1/gallery/favorites`). OAuth 앱에는 `assets:read` 또는 `jobs:read` 스코프가 필요합니다.

```ts
favorites(): Promise<string[]>
```

```ts
const ids = await client.gallery.favorites()
```

## client.media: 가져오기 및 준비
### media.downloadVideo(input)
YouTube, TikTok, Instagram, X, Facebook의 동영상을 내 저장 공간에 다운로드합니다(`POST /v1/download-video`). 작업 ID가 아닌 `downloadId`를 반환합니다. `downloadVideoProgress()`로 진행 상황을 따라가세요. 완료된 파일은 내 라이브러리에 들어갑니다.

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

<TypeTable
type={{
url: { type: 'string', required: true, description: "소셜 동영상의 링크입니다." },
maxHeight: { type: 'number', description: "가져올 최대 해상도로, 720처럼 높이의 픽셀 수로 지정합니다. 생략하면 가능한 최고 해상도를 가져옵니다." },
sectionStartSec: { type: 'number', description: "여기서 시작하는 부분만 가져오며, 초 단위입니다. 자르는 위치가 키프레임에 맞춰지므로 앞뒤로 약 3초씩 더 붙어 옵니다. 정확하게 자르려면 다시 자르세요. 두 구간 필드는 둘 다 지정하거나 둘 다 생략하세요." },
sectionEndSec: { type: 'number', description: "그 부분의 끝으로, 초 단위입니다." },
requireAudio: { type: 'boolean', default: 'true', description: "기본적으로 소리가 없는 다운로드는 실패로 처리됩니다. 보통 소스가 잘못 응답했다는 뜻이기 때문이며, 그 전에 다른 경로를 먼저 시도합니다. 정말로 무음인 클립을 받으려면 false로 설정하세요." },
}}
/>

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

### media.downloadVideoProgress(downloadId, opts?)
다운로드의 실시간 진행 상황을 비동기 이터레이터로 스트리밍합니다(`GET /v1/download-video/progress/:id`, 서버 전송 이벤트). 다운로드가 완료되거나 실패할 때까지 약 500ms마다 이벤트를 하나씩 내보낸 다음 끝납니다.

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

<TypeTable
type={{
downloadId: { type: 'string', required: true, description: "downloadVideo()가 반환한 ID입니다." },
signal: { type: 'AbortSignal', description: "진행 상황 추적을 멈춥니다." },
}}
/>

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

각 이벤트는 `{ phase, percent, videoUrl?, thumbnailUrl?, error? }`입니다. 진행 기록은 다운로드가 끝나고 곧 만료되므로, `downloadVideo()`가 반환되면 바로 순회를 시작하세요. 큰 다운로드는 몇 분이 걸리므로 클라이언트의 `timeoutMs`는 적용되지 않습니다.

### media.videoMetadata(input)
소셜 동영상을 다운로드하지 **않고** 길이, 크기, 제목, 라이브 상태를 읽어 옵니다(`POST /v1/video-metadata`). 작업 없이 바로 응답합니다. 일부 구간만 가져올지 정할 때 사용하세요.

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

<TypeTable
type={{
url: { type: 'string', required: true, description: "소셜 동영상의 링크입니다." },
}}
/>

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

필드는 가능한 범위에서만 채워집니다. 플랫폼이 모든 필드를 알려 주지 않을 수 있습니다.

### media.saveToStorage(input)
URL의 미디어 파일을 내 Nodaro 저장 공간으로 복사합니다(`POST /v1/save-to-storage`). 서버가 파일을 가져오므로, 클라이언트를 거치는 데이터는 없습니다.

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

<TypeTable
type={{
mediaUrl: { type: 'string', required: true, description: "복사할 파일의 URL입니다." },
filename: { type: 'string', description: "저장할 파일 이름입니다." },
mediaType: { type: '"image" | "video" | "audio"', description: "미디어의 종류입니다." },
}}
/>

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

[저장소에 저장](https://nodaro.ai/docs/nodes/publish/save-to-storage) 노드는 워크플로 안에서 같은 일을 합니다.

### media.process(input)
이미 내 저장 공간에 있는 파일을 자르거나 크롭합니다(`POST /v1/media/process`). 바로 응답하며 비용이 들지 않습니다. 유료 단계 전에 소스를 준비할 때 사용하세요.

```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: "처리할 저장된 파일입니다." },
type: { type: '"video" | "audio"', required: true, description: "파일의 종류입니다." },
trim: { type: '{ startTime: number; endTime: number }', description: "이 구간만 남기며, 초 단위입니다." },
crop: { type: '{ x, y, width, height }', description: "프레임에서 이 사각형만 남기며, 픽셀 단위입니다." },
format: { type: '"mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"', description: "출력 형식입니다." },
deleteSource: { type: 'boolean', default: 'false', description: "소스가 내 것이고 다른 곳에서 쓰이지 않으면, 처리 후 소스를 삭제합니다." },
}}
/>

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

결과에는 `url`, `thumbnailUrl`, `assetId`, `sizeBytes`, `mimeType`, `metadata`가 있습니다.

### media.trimVideo(input)
[동영상 자르기](https://nodaro.ai/docs/nodes/video/trim-video) 노드처럼 동영상을 지정한 구간으로 자릅니다(`POST /v1/trim-video`). 편한 단위로 구간을 지정하세요.

```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: "자를 동영상입니다." },
startTime: { type: 'number', description: "남길 부분이 시작되는 위치로, 초 단위입니다." },
endTime: { type: 'number', description: "남길 부분이 끝나는 위치로, 초 단위입니다." },
trimStartFrames: { type: 'number', description: "시작에서 잘라 낼 프레임 수입니다." },
trimEndFrames: { type: 'number', description: "끝에서 잘라 낼 프레임 수입니다." },
trimStartSeconds: { type: 'number', description: "시작에서 잘라 낼 초입니다." },
trimEndSeconds: { type: 'number', description: "끝에서 잘라 낼 초입니다." },
keepFirstSeconds: { type: 'number', description: "처음 몇 초만 남깁니다." },
keepLastSeconds: { type: 'number', description: "마지막 몇 초만 남깁니다." },
}}
/>

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

### media.trimAudio(input)
[오디오 자르기](https://nodaro.ai/docs/nodes/audio/trim-audio) 노드처럼 오디오를 지정한 구간으로 자르거나 동영상에서 오디오를 추출합니다(`POST /v1/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: "오디오를 가져올 동영상입니다. videoUrl 또는 audioUrl을 지정하세요." },
audioUrl: { type: 'string', description: "자를 오디오 파일입니다." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "출력 형식입니다." },
startTime: { type: 'number', description: "구간의 시작으로, 초 단위입니다." },
endTime: { type: 'number', description: "구간의 끝으로, 초 단위입니다." },
}}
/>

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

### media.splitMedia(input)
[구간 분할](https://nodaro.ai/docs/nodes/video/split-into-chunks) 노드처럼 동영상, 오디오 파일 또는 둘 다를 연속된 구간으로 자릅니다(`POST /v1/split-media`). 각 입력은 따로 분할됩니다.

```ts
splitMedia(input: {
videoUrl?: string
audioUrl?: string
chunkDuration: number
minPartDuration?: number
audioFormat?: "mp3" | "wav" | "aac"
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', description: "분할할 동영상입니다. videoUrl, audioUrl 또는 둘 다를 보내세요." },
audioUrl: { type: 'string', description: "분할할 오디오 파일입니다." },
chunkDuration: { type: 'number', required: true, description: "구간의 최대 길이로, 초 단위입니다. 1 이상입니다." },
minPartDuration: { type: 'number', default: '4', description: "구간의 최소 길이로, 초 단위입니다. chunkDuration보다 큰 값은 400입니다." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "오디오 구간의 형식입니다." },
}}
/>

실행 비용은 구간 수와 관계없이 22크레딧입니다. 완료된 작업의 출력에는 순서대로 담긴 구간인 `videoUrls`와 `audioUrls`, `chunkCount`, 그리고 각 구간의 정확한 길이인 `partDurationsSec`가 있습니다. 두 입력을 모두 분할했다면 `audioPartDurationsSec`에 오디오 구간의 길이가 나열됩니다. 파일 하나는 최대 3시간까지이며, 구간은 최대 200개까지 만들 수 있습니다.

```ts
const { jobId } = await client.media.splitMedia({ videoUrl, chunkDuration: 60, minPartDuration: 30 })
```

## client.media: 자막
### media.addCaptions(input)
[자막 추가](https://nodaro.ai/docs/nodes/video/add-captions) 노드처럼 동영상에 자막을 입힙니다(`POST /v1/add-captions`). 단어를 `text`로 주거나, 단어별 타이밍이 있는 `captions`를 주거나, 기본값대로 Nodaro가 음성을 텍스트로 변환하게 하세요.

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

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "자막을 넣을 동영상입니다." },
style: { type: '"subtitle" | "word-highlight" | "karaoke" | "tiktok-words" | "word-pop" | "bouncy"', description: "subtitle은 고정된 블록입니다. 나머지는 단어별로 움직이는 키네틱 스타일입니다." },
text: { type: 'string', description: "subtitle에서는 자막 자체이며, 동영상 전체에 블록 하나로 표시됩니다. 키네틱 스타일에서는 음성 텍스트 변환 결과가 없을 때만 쓰는 대체 텍스트입니다." },
captions: { type: '{ text, startMs, endMs }[]', description: "단어별 타이밍이 있는 자막으로, 키네틱 스타일에서는 단어마다 항목 하나입니다. 이 값이 있으면 음성 텍스트 변환을 실행하지 않습니다." },
autoTranscribe: { type: 'boolean', default: 'true', description: "다른 곳에서 단어를 받지 못할 때 음성을 텍스트로 변환합니다." },
transcribeProvider: { type: '"whisper" | "incredibly-fast-whisper" | "elevenlabs-stt"', default: '"incredibly-fast-whisper"', description: "음성 텍스트 변환 엔진입니다. 키네틱 스타일에는 단어 타이밍을 제공하는 엔진이 필요합니다." },
position: { type: '"bottom" | "top" | "center"', description: "자막이 놓이는 위치입니다." },
positionY: { type: 'number', description: "자막의 세로 중심으로, 높이에 대한 퍼센트입니다. position보다 우선합니다." },
fontSize: { type: 'number', description: "글꼴 크기입니다." },
color: { type: 'string', description: "텍스트 색입니다." },
backgroundColor: { type: 'string', description: "텍스트 뒤의 배경색입니다." },
look: { type: '"outline" | "clean"', description: "스타일 프리셋입니다. outline은 검은 외곽선이 있는 굵은 대문자 서체이며, 말하는 단어가 노란색으로 표시됩니다." },
fontFamily: { type: 'string', description: "지원되는 글꼴 중 하나입니다." },
fontWeight: { type: 'number', description: "100~900이며, 100 단위로 지정합니다." },
strokeColor: { type: 'string', description: "외곽선 색입니다." },
strokeWidth: { type: 'number', description: "외곽선 두께입니다." },
uppercase: { type: 'boolean', description: "텍스트를 대문자로 표시합니다." },
maxWordsPerLine: { type: 'number', description: "한 줄에 넣을 최대 단어 수로, 1~20입니다." },
highlightColor: { type: 'string', description: "키네틱 스타일 전용입니다. 지금 말하는 단어의 색입니다." },
animate: { type: 'boolean', default: 'true', description: "키네틱 스타일 전용입니다. false이면 단어별 움직임을 멈춥니다." },
segments: { type: 'CaptionSegmentInput[]', description: "구간마다 다른 처리를 적용합니다. 각 세그먼트에는 startMs, endMs와 자체 style, look, 단어, 조절 항목이 있습니다." },
}}
/>

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

**단어의 출처.** 호출에 단어 소스가 여러 개 있으면 단어별 타이밍이 있는 `captions`가 가장 우선이고, 그다음은 `subtitle` 스타일의 `text`, 그다음은 음성 텍스트 변환입니다. `subtitle`에서는 `text`가 곧 자막입니다. 동영상 전체에 고정된 블록 하나로 입혀지며, 음성 변환 결과로 대체되지 않습니다. 줄을 강제로 바꾸려면 그 안에 `\n`을 쓰세요. 키네틱 스타일에서 `text`는 대체용일 뿐입니다. 음성 텍스트 변환 결과가 없거나 `autoTranscribe`가 `false`일 때 쓰이며, 이때 단어는 동영상 전체에 고르게 배분됩니다.

**스타일과 룩.** 키네틱 스타일에서 `look`을 설정하지 않으면 `outline`으로 렌더링됩니다. `subtitle`에서는 `clean`으로 렌더링됩니다. 명시적인 조절 항목은 룩의 개별 필드보다 우선합니다. 자체 `look`을 지정한 세그먼트는 그 프리셋에서 시작하며, 최상위 조절 항목을 상속하지 않습니다.

**`subtitle`의 조절 항목.** 스타일 조절 항목(`look`, `fontFamily`, `fontWeight`, `strokeColor`, `strokeWidth`, `uppercase`, `positionY`, `maxWordsPerLine`)은 `subtitle`에서도 작동합니다. 이 중 하나라도 쓰면 자막은 키네틱 가격으로 청구되고, 일반 텍스트 자막은 더 낮은 가격으로 청구됩니다. `highlightColor`와 `animate`는 키네틱 전용이며, `subtitle`에서는 400으로 거부됩니다. 음성을 자동으로 텍스트로 변환한 자막도 키네틱 가격으로 청구됩니다.

**줄.** `word-highlight`, `karaoke`, `bouncy`는 한 번에 한 줄을 표시합니다. 줄은 문장이 끝날 때, 0.5초 이상 멈출 때, 프레임 너비의 약 85%를 채울 때, 또는 단어 수가 `maxWordsPerLine`에 도달할 때 끝납니다. `word-pop`은 한 번에 한 단어를 표시하므로 `maxWordsPerLine`은 효과가 없습니다. `tiktok-words` 페이지는 문장 끝이나 멈춤을 넘어가지 않습니다. 둘 다 마지막으로 말한 단어 뒤 최대 1.5초 동안 화면에 남습니다. 단어의 `startMs`와 `endMs`는 효과의 타이밍을 정할 뿐, 단어가 얼마나 오래 보이는지는 정하지 않습니다.

**음성 텍스트 변환 엔진.** 키네틱 스타일에는 단어 타이밍이 필요하므로, `transcribeProvider`는 이 경우의 기본값인 `incredibly-fast-whisper` 또는 `elevenlabs-stt`여야 합니다. `whisper`에는 단어 타이밍이 없습니다. 이 엔진은 음성 텍스트 변환이 단어의 유일한 소스일 때만 `400 validation_error`로 거부됩니다. `subtitle`은 구문 단위의 타이밍만 필요하므로 어떤 엔진이든 쓸 수 있습니다.

**프레임 레이트.** 스타일이 적용된 렌더링은 소스 동영상의 프레임 레이트를 15~60 사이의 정수로 반올림해 유지합니다. 가변 프레임 레이트 소스나 프레임 한도를 넘는 긴 클립은 30fps로 렌더링됩니다.

### 음성 변환, 수정, 자막 입히기
[`client.audio.transcribe()`](https://nodaro.ai/docs/developers/sdk/voices-and-audio) 작업의 `words`는 `captions`가 받는 형태와 정확히 같습니다. 단어를 수정한 다음, 음성 변환을 다시 하지 않고 자막을 입히세요.

```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: 렌더링 및 합성
### media.stillToVideo(input)
[정지 이미지로 동영상 만들기](https://nodaro.ai/docs/nodes/video/still-to-video) 노드처럼 정지 이미지 하나와 오디오 트랙 하나로 MP4를 만듭니다(`POST /v1/still-to-video`). AI 모델 없이 서버에서 렌더링되며 크레딧이 들지 않습니다. 동영상 길이는 오디오 길이와 같으며, 길이 필드는 없습니다.

```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: "정지 이미지입니다." },
audioUrl: { type: 'string', required: true, description: "오디오 트랙입니다. 오디오 길이가 동영상 길이를 정합니다." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"', default: '"none"', description: "카메라가 이미지 위를 움직이는 방식입니다." },
intensity: { type: 'number', description: "움직임의 강도로, 1~10입니다." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "출력 해상도입니다." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "출력 화면 비율입니다." },
fps: { type: '24 | 30', description: "프레임 레이트입니다." },
fit: { type: '"cover" | "contain"', description: "cover는 프레임을 채우도록 이미지를 크롭합니다. contain은 padColor 색의 여백과 함께 이미지 전체를 보여 줍니다." },
padColor: { type: 'string', description: "fit이 contain일 때 여백의 색입니다." },
}}
/>

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

### media.slideshow(input)
[슬라이드쇼](https://nodaro.ai/docs/nodes/video/slideshow) 노드처럼 이미지 2~100개와 선택 사항인 오디오 트랙으로 MP4 슬라이드쇼를 만듭니다(`POST /v1/slideshow`). 크레딧이 들지 않습니다. 이미지가 하나라면 `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~100개입니다." },
audioUrl: { type: 'string', description: "오디오 트랙입니다. 지정하면 동영상 길이가 오디오 길이와 같아집니다." },
imageDurations: { type: 'Array<number | null>', description: "같은 순서로 지정하는 이미지별 초입니다. null은 자동입니다. 오디오가 있을 때 고정한 값의 합이 맞지 않으면, 길이에 맞게 비율이 조정됩니다." },
perImageDuration: { type: 'number', description: "오디오가 없을 때의 이미지당 초입니다." },
transition: { type: 'string', description: "이미지 사이의 전환입니다." },
transitionDuration: { type: 'number', description: "각 전환의 길이로, 초 단위입니다." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"', description: "카메라가 각 이미지 위를 움직이는 방식입니다." },
intensity: { type: 'number', description: "움직임의 강도입니다." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "출력 해상도입니다." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "출력 화면 비율입니다." },
fps: { type: '24 | 30', description: "프레임 레이트입니다." },
fit: { type: '"cover" | "contain"', description: "프레임을 채우도록 크롭하거나, 여백과 함께 각 이미지 전체를 보여 줍니다." },
padColor: { type: 'string', description: "여백의 색입니다." },
}}
/>

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

오디오가 있으면 `imageDurations`로 일부 이미지를 고정하지 않는 한 시간이 균등하게 나뉩니다. 오디오가 없으면 동영상 길이는 이미지 수에 `perImageDuration`을 곱한 값이며, 소리가 없습니다.

### media.videoOverlay(input)
[동영상 오버레이](https://nodaro.ai/docs/nodes/video/video-overlay) 노드처럼 시간이 지정된 이미지 레이어 1~20개를 한 번에 동영상 위에 배치합니다(`POST /v1/video-overlay`). 레이어 수나 길이와 관계없이 실행마다 **22크레딧**이 듭니다. 동영상 자체의 오디오는 그대로 유지됩니다.

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

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "기본 동영상입니다." },
layers: { type: 'Array<{ imageUrl, start, end?, ... }>', required: true, description: "레이어 1~20개입니다. 각 레이어에는 imageUrl과 start가 있고, 선택 사항으로 end, preset, corner, anchor, x, y, width, height, fit, opacity, animate, zIndex가 있습니다." },
outputAspect: { type: '"16:9" | "9:16" | "1:1" | "4:5"', description: "출력을 이 화면 비율로 다시 구성합니다. 생략하면 동영상 자체의 크기와 프레임 레이트를 유지합니다." },
baseFit: { type: '"contain" | "cover"', default: '"cover"', description: "outputAspect를 쓸 때 동영상이 새 화면 비율을 채우는 방식입니다." },
backgroundColor: { type: 'string', default: '"#000000"', description: "outputAspect를 쓸 때의 배경색으로, #RRGGBB 형식입니다." },
}}
/>

각 레이어는 `imageUrl`과 0~3,600초 범위의 `start`, 그리고 선택 사항인 `end`를 받습니다. `end`가 없으면 동영상이 끝날 때까지 남습니다. `preset`은 `card`, `corner-badge`, `full-frame` 중 하나입니다. 상자 필드인 `x`와 `y`는 -100~100, `width`와 `height`는 1~100이며, 모두 출력 프레임에 대한 퍼센트입니다. 명시적인 상자 필드는 프리셋보다 우선합니다. 둘 다 없는 레이어는 오른쪽 아래 또는 `corner`로 지정한 위치의 코너 배지가 됩니다. `opacity`는 0~1이고, `animate`는 기본적으로 켜져 있으며, `zIndex`는 0~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" },
],
})
```

완료된 작업의 출력에는 `videoUrl`, `thumbnailUrl`, `width`, `height`, `durationSec`, `warnings`가 있습니다. 각 경고는 `{ layer?, slot?, code, detail }`이며, 코드는 `clipped`, `skipped`, `animated_first_frame`, `audio_reencoded` 등입니다.

### media.imageCollage(input)
[이미지 콜라주](https://nodaro.ai/docs/nodes/image/image-collage) 노드처럼 이미지 2~30개를 하나의 큰 2K 또는 4K 콜라주로 합칩니다(`POST /v1/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: "순서대로 나열한 이미지 2~30개입니다." },
layout: { type: '"smart" | "grid"', default: '"smart"', description: "smart는 각 이미지 고유의 화면 비율로 크롭 없이 행을 만들므로 높이가 달라집니다. grid는 여백이 있는 같은 크기의 칸을 씁니다." },
imageSizes: { type: 'Array<0 | 1 | 2 | 3>', description: "smart 레이아웃의 상대 크기로, imageUrls와 같은 순서입니다. 0은 자동, 1은 크게, 2는 중간, 3은 작게입니다. grid 레이아웃은 이 값을 무시합니다." },
numbered: { type: 'boolean', default: 'false', description: "스토리보드처럼 각 이미지에 순번을 찍습니다." },
imageLabels: { type: 'Array<string | null>', description: "이미지마다 붙는 캡션으로, 최대 80자이며 번호 다음에 표시됩니다. null이나 빈 문자열은 캡션 없음을 뜻합니다." },
badgePosition: { type: '"top-left" | "top-right"', default: '"top-left"', description: "번호와 라벨을 둘 모서리입니다." },
resolution: { type: '"2K" | "4K"', description: "콜라주 크기입니다." },
aspectRatio: { type: 'string', description: "콜라주의 화면 비율입니다." },
gap: { type: 'number', description: "이미지 사이의 간격입니다." },
backgroundColor: { type: 'string', description: "배경색입니다." },
}}
/>

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

번호와 라벨은 레이아웃, 출력 크기, 가격을 바꾸지 않습니다. 이미지에 비해 너무 긴 라벨은 말줄임표로 줄여집니다.

### media.imageOverlay(input)
[이미지 오버레이](https://nodaro.ai/docs/nodes/image/image-overlay) 노드처럼 기본 이미지 위에 레이어 1~12개를 픽셀 단위로 정확하게 배치합니다(`POST /v1/image-overlay`). AI 모델을 쓰지 않는 합성입니다. **11크레딧**이 들며, `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: "기본 이미지입니다." },
layers: { type: 'Array<{ kind?, imageUrl?, anchor?, ... }>', required: true, description: "레이어 1~12개입니다. 아래의 레이어 필드를 참고하세요." },
canvas: { type: '{ width: number; height: number; backgroundColor?: string }', description: "출력 크기입니다. 지정하지 않으면 출력은 기본 이미지의 픽셀 크기를 유지합니다." },
baseFit: { type: '"contain" | "cover"', description: "기본 이미지가 캔버스를 채우는 방식입니다." },
outputFormat: { type: '"png" | "jpg" | "webp"', default: '"png"', description: "출력 형식입니다. png는 투명도를 유지합니다." },
variants: { type: 'string[]', description: "같은 실행에서 함께 렌더링할 추가 플랫폼 크기로, youtube-thumbnail 같은 플랫폼 ID로 지정합니다. 플랫폼 12개 중 어느 것이든 쓸 수 있습니다." },
maskMode: { type: '"around" | "layers" | "outside" | "none"', description: "마스크 출력에 보여 줄 대상입니다. 레이어 주변의 테두리(에디터의 기본값), 레이어, 레이어를 제외한 전체, 또는 마스크 없음입니다." },
maskSpread: { type: 'number', description: "maskMode가 around일 때 테두리의 너비로, 픽셀 단위입니다." },
qrText: { type: 'string', description: "qr.fromInput을 설정한 QR 레이어의 내용입니다." },
}}
/>

**레이어.** 레이어의 종류는 네 가지입니다.

- **사진**: 기본값인 `kind: "image"`이며, `imageUrl`을 씁니다.
- **실제 텍스트**: `text` 객체와 함께 `kind: "text"`를 씁니다. 내용, 글꼴, 굵기, 색, 정렬, 외곽선, 배경 상자, 그리고 기본 이미지 높이에 대한 퍼센트 크기를 설정합니다.
- **QR 코드**: `qr: { text }`와 함께 `kind: "qr"`을 씁니다.
- **단색 도형**: `shape: { shape, color }`와 함께 `kind: "shape"`를 씁니다.

**위치는 기본 이미지에 대한 퍼센트입니다.** 그래서 같은 호출이 1K 미리 보기와 4K 렌더링에서 똑같이 작동합니다.

- `anchor`는 아홉 가지 위치 중 하나이며, 기본값은 `center`입니다.
- `x`와 `y`는 앵커에서 레이어를 이동하며, 기본 이미지 너비와 높이에 대한 퍼센트입니다. 오른쪽이나 아래쪽 앵커에서는 음수 값이 레이어를 안쪽으로 옮깁니다.
- `width`는 기본 이미지 너비에 대한 퍼센트이며, 기본값은 25입니다. `height`를 설정하지 않으면 높이는 레이어의 비율을 따르고, 설정하면 `fit`이 레이어가 상자를 채우는 방식을 정합니다.
- `opacity`는 0~1이고, `rotation`은 레이어 중심을 기준으로 한 각도이며, `blend`는 `over`(기본값), `multiply`, `screen` 중 하나입니다.
- `shadow`는 부드러운 그림자를 추가하고, `roundedCorners`는 모서리를 픽셀 단위로 둥글게 만들며, `zIndex`는 쌓이는 순서를 정합니다.

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

완료된 작업의 출력에는 `imageUrl`, 그 `width`와 `height`, `maskUrl`, 그리고 각각 `{ id, label, width, height, url }` 형식인 `variants`가 있습니다. SVG 레이어는 목표 크기에서 선명하게 그려집니다. `qr.fromInput: true`인 QR 레이어는 `qrText`에서 내용을 가져오며, `qrText`가 없는 실행은 그 필드 이름을 밝힌 400으로 거부됩니다.

### media.videoMask(input)
[동영상 마스크](https://nodaro.ai/docs/nodes/video/video-mask) 노드처럼 일반 텍스트로 설명한 대상을 동영상 전체에서 추적해, 대상이 있는 곳은 흰색인 흑백 마스크 동영상을 반환합니다(`POST /v1/video-mask`).

```ts
videoMask(input: {
videoUrl: string
prompt: string
threshold?: number
provider?: "sam-3.1" | "sam-3"
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "대상을 추적할 동영상입니다. 30fps 기준 최대 5분입니다." },
prompt: { type: 'string', required: true, description: "대상으로, 최대 500자입니다. 대상이 여러 개면 쉼표로 구분하세요." },
threshold: { type: 'number', default: '0.5', description: "0.05~0.95입니다. 값이 낮을수록 더 많이 찾습니다." },
provider: { type: '"sam-3.1" | "sam-3"', default: '"sam-3.1"', description: "SAM 3은 SAM 3.1의 절반 가격입니다." },
}}
/>

가격은 클립 길이에 따라 달라지며, SAM 3.1에서는 55~3,108크레딧입니다. 30fps보다 빠른 클립은 그 비율만큼 더 길게 계산됩니다. 완료된 작업의 출력에는 `videoUrl`(마스크), `width`, `height`, `frames`, `coverage`가 있습니다.

### media.maskComposite(input)
[마스크 합성](https://nodaro.ai/docs/nodes/video/mask-composite) 노드처럼 마스크 동영상을 통해 전경 동영상을 배경 동영상 위에 배치합니다(`POST /v1/mask-composite`). 마스크의 흰색 부분에 전경이 보입니다.

```ts
maskComposite(input: {
foregroundUrl: string
backgroundUrl: string
maskUrl: string
invertMask?: boolean
feather?: number
grow?: number
backgroundFit?: "cover" | "contain" | "stretch"
audioSource?: "foreground" | "background" | "mix" | "none"
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
foregroundUrl: { type: 'string', required: true, description: "위에 놓을 동영상입니다. 최대 300초입니다. 결과는 이 동영상의 크기, 프레임 레이트, 길이를 따릅니다." },
backgroundUrl: { type: 'string', required: true, description: "뒤에 놓을 동영상입니다. 전경에 맞춰지며, 더 짧으면 반복됩니다." },
maskUrl: { type: 'string', required: true, description: "마스크 동영상입니다. 흰색 부분에 전경이 보입니다. 마스크가 더 짧으면 마지막 프레임을 유지합니다." },
invertMask: { type: 'boolean', default: 'false', description: "마스크가 보여 주는 부분을 뒤바꿉니다." },
feather: { type: 'number', default: '2', description: "마스크 가장자리를 0~20픽셀만큼 부드럽게 합니다." },
grow: { type: 'number', default: '0', description: "마스크를 -10~10픽셀만큼 키우거나 줄입니다." },
backgroundFit: { type: '"cover" | "contain" | "stretch"', default: '"cover"', description: "배경이 프레임을 채우는 방식입니다." },
audioSource: { type: '"foreground" | "background" | "mix" | "none"', default: '"foreground"', description: "결과에 남길 소리입니다." },
}}
/>

실행 비용은 결과의 시작된 5초마다 11크레딧입니다.

```ts
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)
비전 모델에 레이어 하나를 기본 이미지의 **어디에** 둘지 묻습니다(`POST /v1/image-overlay/suggest-placement`). 모델은 레이어를 얼굴, 주요 피사체, 복잡한 질감에서 떨어뜨려 놓습니다. 폴링할 작업 없이 바로 응답하며, 이미지 설명 호출 1회로 청구됩니다. 합성은 하지 않으므로 배치는 직접 적용하세요.

```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: "기본 이미지입니다." },
layerAspect: { type: 'number', default: '1', description: "레이어의 너비를 높이로 나눈 값으로, 제안된 상자가 비율을 유지하게 합니다." },
intent: { type: 'string', description: "로고나 가격 배지처럼 레이어가 무엇인지입니다." },
safeArea: { type: '{ x, y, w, h }', description: "항상 보이는 영역으로, 캔버스에 대한 비율로 지정합니다. 배치는 이 영역 안에 머뭅니다." },
llmModel: { type: 'string', description: "사용할 비전 모델입니다." },
}}
/>

```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`에는 `imageOverlay`의 퍼센트 단위로 된 `anchor`, `x`, `y`, `width`와 함께, 사용자에게 보여 줄 수 있는 한 문장짜리 `reason`이 있습니다. `jobId`는 청구 기록입니다.

## 자주 묻는 질문

### Nodaro SDK로 파일을 업로드하려면 어떻게 하나요?

File을 넣어 client.uploads.upload(file)을 호출하세요. 이미지, 동영상, 오디오 URL을 받는 모든 노드에 전달할 수 있는 공개 url을 반환합니다.

### 코드에서 동영상에 자막을 추가하려면 어떻게 하나요?

videoUrl과 style을 넣어 client.media.addCaptions를 호출하세요. text나 captions가 없으면 Nodaro가 음성을 텍스트로 변환합니다. 호출은 jobId를 반환하며, 완료된 작업에 자막이 입혀진 동영상이 담깁니다.

### 크레딧이 들지 않는 미디어 메서드는 무엇인가요?

stillToVideo와 slideshow는 AI 모델 없이 렌더링되어 크레딧이 들지 않으며, media.process도 무료입니다. videoOverlay는 실행마다 22크레딧, imageOverlay는 11크레딧이 들며, 플랫폼 크기를 하나 추가할 때마다 가격이 더해집니다.

### 코드에서 생성 결과를 즐겨찾기하려면 어떻게 하나요?

client.gallery.toggleFavorite(jobId)를 호출하세요. 작업을 즐겨찾기에 추가하거나, 이미 즐겨찾기라면 제거합니다. 즐겨찾기 목록은 client.gallery.favorites()나 client.jobs.list({ favorited: true })로 볼 수 있습니다.

### 동영상 다운로드를 추적하려면 어떻게 하나요?

client.media.downloadVideo는 downloadId를 반환합니다. client.media.downloadVideoProgress(downloadId)를 순회하면 다운로드가 완료되거나 실패할 때까지 단계와 퍼센트를 받을 수 있습니다.
