# メディアとアップロード

> Nodaro の SDK を使って、TypeScript からファイルをアップロードし、メディアライブラリを一覧表示し、SNS の動画をダウンロードし、クリップをトリミングし、字幕を焼き込み、画像と動画を合成します。

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

**`client.uploads`** は、ファイルを Nodaro に保存します。**`client.library`** は、すでに持っているメディアを一覧表示します。**`client.media`** は、メディアの準備と編集を行います。SNS の動画のダウンロード、クリップのトリミング、字幕の焼き込み、画像と動画の合成です。`client.media` のほとんどのメソッドはジョブを開始し、その `jobId` を返します。これは [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions) でポーリングします。これらのメソッドは、[アップロード](https://nodaro.ai/docs/developers/api/uploads)と[音声とメディア](https://nodaro.ai/docs/developers/api/voice-and-media)の REST API のエンドポイントを呼び出します。

## メソッド
| メソッド | 内容 |
| --- | --- |
| [`uploads.upload(file)`](#uploadsuploadfile) | ファイルを 1 つアップロードし、その公開 URL を取得します |
| [`library.list(params?)`](#librarylistparams) | 保存されているメディアを一覧表示します |
| [`media.downloadVideo(input)`](#mediadownloadvideoinput) | SNS の動画を、自分のストレージにダウンロードします |
| [`media.downloadVideoProgress(downloadId, opts?)`](#mediadownloadvideoprogressdownloadid-opts) | ダウンロードの進行状況を追跡します |
| [`media.videoMetadata(input)`](#mediavideometadatainput) | SNS の動画の長さとサイズを、ダウンロードせずに読み取ります |
| [`media.saveToStorage(input)`](#mediasavetostorageinput) | メディアの URL を、自分のストレージにコピーします |
| [`media.process(input)`](#mediaprocessinput) | 保存済みのファイルを、無料でカットまたはクロップします |
| [`media.trimVideo(input)`](#mediatrimvideoinput) | 動画を範囲でトリミングします |
| [`media.trimAudio(input)`](#mediatrimaudioinput) | オーディオをトリミングするか、動画から抽出します |
| [`media.addCaptions(input)`](#mediaaddcaptionsinput) | 動画に字幕を焼き込みます |
| [`media.stillToVideo(input)`](#mediastilltovideoinput) | 画像とオーディオトラックを、動画にします |
| [`media.slideshow(input)`](#mediaslideshowinput) | 画像と任意のオーディオを、スライドショーにします |
| [`media.videoOverlay(input)`](#mediavideooverlayinput) | タイミング付きの画像レイヤーを、動画の上に配置します |
| [`media.imageCollage(input)`](#mediaimagecollageinput) | 2〜30 枚の画像を、1 つのコラージュに結合します |
| [`media.imageOverlay(input)`](#mediaimageoverlayinput) | 画像、テキスト、QR、図形のレイヤーを、画像の上に配置します |
| [`media.suggestOverlayPlacement(input)`](#mediasuggestoverlayplacementinput) | レイヤーをどこに置くべきか、ビジョンモデルに尋ねます |

## client.uploads
### uploads.upload(file)
ファイルを 1 つアップロードし（`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` をスローします。[**画像アップロード**（Upload Image）](https://nodaro.ai/docs/nodes/image/upload-image)、[**動画アップロード**（Upload Video）](https://nodaro.ai/docs/nodes/video/upload-video)、[**オーディオアップロード**（Upload Audio）](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: "1 種類のメディアだけに絞ります。" },
search: { type: 'string', description: "名前にこのテキストを含むファイルだけに絞ります。大文字と小文字は区別しません。" },
limit: { type: 'number', default: '40', description: "ページのサイズで、1〜100 です。" },
cursor: { type: 'string', description: "前のページの nextCursor です。" },
owned: { type: 'boolean', default: 'false', description: "true にすると、ライブラリに保存されているかどうかにかかわらず、自分が所有するすべてのファイル（アップロードと生成物）を返します。" },
source: { type: 'string', description: "1 つの経路から来たメディアだけに絞ります。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)
```

各 `LibraryAsset` は、`id`、`type`、`filename`、`mimeType`、`sizeBytes`、`url`、`thumbnailUrl`、`metadata`、`isLibraryItem`、`uploadSource`、`source`、`sourceDetail`、`createdAt` を持ちます。`totalCount` があるのは、最初のページだけです。

## 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: "SNS の動画へのリンクです。" },
maxHeight: { type: 'number', description: "取得する最高解像度で、高さのピクセル数です。720 などです。省略すると、利用できる最良のものになります。" },
sectionStartSec: { type: 'number', description: "この時点から始まる部分だけを取得します。単位は秒です。section の 2 つのフィールドは、両方指定するか、どちらも指定しないかのいずれかです。" },
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`、サーバー送信イベント）。ダウンロードが完了または失敗するまで、約 500 ミリ秒ごとにイベントを 1 つ生成し、その後終了します。

```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)
SNS の動画の長さ、サイズ、タイトル、ライブ配信かどうかを、ダウンロード**せずに**読み取ります（`POST /v1/video-metadata`）。ジョブを介さず、直接答えを返します。一部の区間だけを取得するかどうかを判断するために使います。

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

<TypeTable
type={{
url: { type: 'string', required: true, description: "SNS の動画へのリンクです。" },
}}
/>

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

[**ストレージに保存**（Save to Storage）](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)
動画を範囲でトリミングします（`POST /v1/trim-video`）。[**動画のトリミング**（Trim Video）](https://nodaro.ai/docs/nodes/video/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)
オーディオを範囲でトリミングするか、動画から抽出します（`POST /v1/trim-audio`）。[**オーディオのトリミング**（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: "オーディオの取得元となる動画です。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 })
```

## client.media：字幕
### media.addCaptions(input)
動画に字幕を焼き込みます（`POST /v1/add-captions`）。[**字幕を追加**（Add Captions）](https://nodaro.ai/docs/nodes/video/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 では、字幕そのものです。動画全体で 1 つのブロックとして表示されます。キネティックスタイルでは、文字起こしで何も見つからなかったときのフォールバックにすぎません。" },
captions: { type: '{ text, startMs, endMs }[]', description: "単語ごとにタイミングの付いた字幕で、キネティックスタイルでは 1 単語につき 1 エントリーです。これを指定すると、文字起こしは行われません。" },
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 行あたりの最大単語数で、1〜20 です。" },
highlightColor: { type: 'string', description: "キネティックスタイルのみ：発話中の単語の色です。" },
animate: { type: 'boolean', default: 'true', description: "キネティックスタイルのみ：false にすると、単語ごとの動きが止まります。" },
segments: { type: 'CaptionSegmentInput[]', description: "時間範囲ごとに異なる処理を指定します。各セグメントは、startMs、endMs と、それぞれの style、look、words、レバーを持ちます。" },
}}
/>

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

**単語がどこから来るか。**呼び出しに複数の情報源がある場合、単語ごとにタイミングの付いた `captions` が優先され、次に `subtitle` スタイルの `text`、その次に文字起こしの順です。`subtitle` では、`text` がそのまま字幕になります。動画全体で 1 つの静止したブロックとして焼き込まれ、文字起こしの結果に置き換わることはありません。改行を強制するには `\n` を使います。キネティックスタイルでは、`text` はフォールバックにすぎません。文字起こしで何も見つからなかった場合や `autoTranscribe` が `false` の場合に使われ、その単語は動画全体に均等に配置されます。

**スタイルとルック。**キネティックスタイルでは、`look` を指定しない場合、`outline` としてレンダリングされます。`subtitle` では `clean` としてレンダリングされます。明示的なレバーは、ルックの個々のフィールドを上書きします。独自の `look` を指定したセグメントは、そのプリセットを起点とし、トップレベルのレバーは継承しません。

**`subtitle` でのレバー。**スタイル用のレバー（`look`、`fontFamily`、`fontWeight`、`strokeColor`、`strokeWidth`、`uppercase`、`positionY`、`maxWordsPerLine`）は、`subtitle` でも機能します。これらのいずれかを使った subtitle は、キネティックと同じ料金になり、プレーンテキストの subtitle は、より低い料金になります。`highlightColor` と `animate` はキネティック専用で、`subtitle` では 400 で拒否されます。自動で文字起こしされた subtitle も、キネティックと同じ料金になります。

**行。**`word-highlight`、`karaoke`、`bouncy` は、1 行ずつ表示します。行が終わるのは、文の終わり、0.5 秒以上の間、フレーム幅の約 85% を占めたとき、または `maxWordsPerLine` の単語数に達したときです。`word-pop` は 1 単語ずつ表示するため、`maxWordsPerLine` はここでは影響しません。`tiktok-words` のページは、文の終わりや間を越えることはありません。どちらも、最後に発話された単語の後、最大 1.5 秒間画面に残ります。単語の `startMs` と `endMs` は、その効果が現れるタイミングを示すものであり、表示され続ける長さではありません。

**文字起こしエンジン。**キネティックスタイルには単語ごとのタイミングが必要なため、`transcribeProvider` は、ここでのデフォルトである `incredibly-fast-whisper`、または `elevenlabs-stt` のいずれかである必要があります。`whisper` には単語ごとのタイミングがありません。文字起こしが単語の唯一の情報源になる場合に限り、`400 validation_error` で拒否されます。`subtitle` では、フレーズ単位のタイミングしか必要ないため、どのエンジンでも使えます。

**フレームレート。**スタイル付きのレンダリングは、元の動画のフレームレートを保ち、15〜60 の整数に丸めます。フレームレートが可変の動画や、フレーム数の上限を超える長さのクリップは、30 fps でレンダリングされます。

### 文字起こしして修正し、それから焼き込む
[`client.audio.transcribe()`](https://nodaro.ai/docs/developers/sdk/voices-and-audio) のジョブが持つ `words` は、`captions` が取る形と完全に同じです。単語を修正し、2 回目の文字起こしをせずに字幕を焼き込みます。

```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)
1 枚の静止画と 1 つのオーディオトラックを、MP4 にします（`POST /v1/still-to-video`）。[**静止画から動画**（Still to Video）](https://nodaro.ai/docs/nodes/video/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)
2〜100 枚の画像と任意のオーディオトラックを、MP4 のスライドショーにします（`POST /v1/slideshow`）。[**スライドショー**（Slideshow）](https://nodaro.ai/docs/nodes/video/slideshow)ノードと同じです。クレジットはかかりません。画像が 1 枚だけの場合は、`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)
タイミング付きの画像レイヤーを 1〜20 個、1 回の処理で動画の上に配置します（`POST /v1/video-overlay`）。[**動画オーバーレイ**（Video Overlay）](https://nodaro.ai/docs/nodes/video/video-overlay)ノードと同じです。レイヤーの数や長さにかかわらず、1 回の実行につき **20 クレジット** かかります。動画自体のオーディオは、そのまま保たれます。

```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 }` の形式で、`code` は `clipped`、`skipped`、`animated_first_frame`、`audio_reencoded` などです。

### media.imageCollage(input)
2〜30 枚の画像を、1 枚の大きな 2K または 4K のコラージュに結合します（`POST /v1/image-collage`）。[**画像コラージュ**（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: "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)
1〜12 個のレイヤーを、ベース画像の上にピクセル単位で正確に配置します（`POST /v1/image-overlay`）。[**画像オーバーレイ**（Image Overlay）](https://nodaro.ai/docs/nodes/image/image-overlay)ノードと同じです。AI モデルを使わない合成です。**10 クレジット** に加えて、`variants` に追加するプラットフォームサイズごとに **2 クレジット** かかります。

```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 レイヤーのペイロードです。" },
}}
/>

**レイヤー。**レイヤーは、次の 4 種類のいずれかです。

- **画像**：デフォルトの `kind: "image"` と `imageUrl` です。
- **本物のテキスト**：`kind: "text"` と `text` オブジェクトです。内容、フォント、太さ、色、配置、輪郭、背景ボックス、ベースの高さに対する割合（パーセント）でのサイズを設定します。
- **QR コード**：`kind: "qr"` と `qr: { text }` です。
- **単色の図形**：`kind: "shape"` と `shape: { shape, color }` です。

**位置は、ベース画像に対する割合（パーセント）です**。そのため、同じ呼び出しが 1K のプレビューにも 4K のレンダリングにも通用します。

- `anchor` は 9 つの位置のいずれかで、デフォルトは `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`、そして `variants`（それぞれ `{ id, label, width, height, url }`）が含まれます。SVG のレイヤーは、対象のサイズで鮮明に描画されます。`qr.fromInput: true` を指定した QR レイヤーは、そのペイロードを `qrText` から取得します。`qrText` を指定しない実行は、それを示す 400 で拒否されます。

### media.suggestOverlayPlacement(input)
1 つのレイヤーをベース画像の**どこに**置くべきかを、ビジョンモデルに尋ねます（`POST /v1/image-overlay/suggest-placement`）。モデルは、レイヤーを顔、メインの被写体、複雑なテクスチャから避けて配置します。ポーリングするジョブを介さず直接答えを返し、**画像の説明**（Describe Image）の呼び出し 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` に加えて、ユーザーに表示できる 1 文の `reason` を持ちます。`jobId` は、課金の記録です。

## Frequently asked questions

### Nodaro の SDK でファイルをアップロードするには、どうすればよいですか？

File を指定して client.uploads.upload(file) を呼び出します。画像、動画、オーディオの URL を受け取るどのノードにも渡せる、公開 URL が返ります。

### コードから動画に字幕を追加するには、どうすればよいですか？

videoUrl と style を指定して client.media.addCaptions を呼び出します。text も captions も指定しない場合、Nodaro が音声を文字起こしします。この呼び出しは jobId を返し、完了したジョブに字幕付きの動画が入ります。

### クレジットがかからないメディアのメソッドは、どれですか？

stillToVideo と slideshow は AI モデルを使わずにレンダリングされ、クレジットはかかりません。media.process も無料です。videoOverlay は 1 回の実行につき 20 クレジット、imageOverlay は 10 クレジットに加えて、追加のプラットフォームサイズごとに 2 クレジットかかります。

### 動画のダウンロードを追跡するには、どうすればよいですか？

client.media.downloadVideo は downloadId を返します。client.media.downloadVideoProgress(downloadId) をイテレートすると、ダウンロードが完了または失敗するまで phase と percent を受け取れます。
