# 노드 실행

> 워크플로 없이 TypeScript로 Nodaro 노드를 실행하는 방법입니다. 노드 타입을 찾고, 실행을 시작하고, 결과를 기다리며, 레퍼런스와 카메라 연출을 전달합니다.

Source: https://nodaro.ai/ko/docs/developers/sdk/nodes

<strong>`client.nodes`</strong>는 Nodaro 서버가 지원하는 노드 타입을 나열하고, 워크플로를 만들지 않고 그중 어떤 노드든 직접 실행합니다. 실행은 매개변수를 노드 자체의 경로(대부분의 노드 타입에서는 `POST /v1/<type>`)로 보내고, 기다릴 수 있는 작업을 반환합니다. [CLI](https://nodaro.ai/docs/developers/cli)가 `nodaro nodes run`에 쓰는 경로와 같으며, Nodaro의 MCP 도구도 이 경로를 씁니다. REST 관점의 설명은 [노드 하나 실행](https://nodaro.ai/docs/developers/api/nodes)을 참고하세요.

## 메서드
| 메서드 | 기능 |
| --- | --- |
| [`nodes.list()`](#list) | 모든 노드 타입을 모델, 크레딧 비용과 함께 나열합니다 |
| [`nodes.get(type)`](#gettype) | 노드 타입 하나를 읽어 옵니다 |
| [`nodes.run(type, params?, options?)`](#runtype-params-options) | 노드를 시작하고 작업 ID를 바로 반환합니다 |
| [`nodes.runAndWait(type, params?, opts?)`](#runandwaittype-params-opts) | 노드를 시작하고 작업을 폴링해 출력을 반환합니다 |
| [`nodes.runMany(type, paramsList, opts?)`](#runmanytype-paramslist-opts) | 노드 하나를 여러 번 동시에 실행하고 모두 끝날 때까지 기다립니다 |
| [`nodes.runBatchAndWait(type, params, opts?)`](#runbatchandwaittype-params-opts) | **이미지 생성** 호출 한 번으로 이미지를 여러 장 만들고 모두 끝날 때까지 기다립니다 |

## client.nodes
### list()
서버가 지원하는 모든 노드 타입을 나열합니다. 서버가 응답을 5분 동안 캐시할 수 있습니다. 이 호출은 비용이 들지 않으며 스코프도 필요 없습니다.

```ts
list(): Promise<{ data: NodeDescriptor[] }>
```

```ts
const { data: nodes } = await client.nodes.list()

const imageGenerators = nodes.filter((n) => n.category === "ai-image")
const takesReferences = nodes.filter((n) => n.capabilities?.includes("supports-reference-image"))
```

각 `NodeDescriptor`에는 다음 필드가 있습니다.

| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `type` | `string` | `generate-image` 같은 API 타입입니다. `run()`에 전달하세요. |
| `label` | `string` | 에디터에 표시되는 노드 이름입니다. |
| `category` | `string` | `ai-image`, `ai-video`, `ai-audio`, `ai-text`, `processing`, `parameter` 같은 카테고리입니다. |
| `description` | `string` | 한 줄 설명입니다. |
| `outputType` | `string` | `text`, `image`, `video`, `audio`, `data`, `none` 중 하나입니다. |
| `creditCost` | `number \| string` | 실행에 청구되는 크레딧 비용으로, 노드의 **실행** 버튼에 표시되는 금액과 같습니다. 고정 비용이면 숫자이고, 모델에 따라 달라지면 `"3-682"` 같은 범위입니다. 동영상 자르기, 동영상 루프, 동영상 합치기에서는 대신 5초 단계 하나의 가격이며, 실행 비용은 단계 수에 따라 정해집니다. Nodaro Cloud 전용입니다. |
| `providers` | `string[]` | 노드가 실행할 수 있는 모델 ID로, `provider` 매개변수에 씁니다. |
| `capabilities` | `string[]` | `supports-reference-image`나 `supports-end-frame` 같은 플래그입니다. |
| `inputSchema` | `{ fields }` | 설정할 수 있는 입력 필드로, 각각 `key`, `type`, `required`, `options`가 있습니다. |
| `maxDurationSec` | `number` | 노드가 받는 최대 길이로, 해당하는 노드에만 있습니다. |
| `providerResolutions` | `Record<string, string[]>` | 모델마다 해상도가 다를 때, 각 모델이 받는 해상도입니다. |

셀프 호스팅한 Community Edition과 Business 에디션 설치에는 크레딧 시스템이 없으므로, 디스크립터에 `creditCost`가 없습니다.

### get(type)
노드 타입 하나의 디스크립터를 읽어 옵니다.

```ts
get(type: string): Promise<{ data: NodeDescriptor }>
```

<TypeTable
type={{
type: { type: 'string', required: true, description: "generate-image나 generate-video 같은 노드의 API 타입입니다." },
}}
/>

```ts
const { data: node } = await client.nodes.get("generate-video")
console.log(node.providers)  // every video model id
console.log(node.creditCost) // Nodaro Cloud only
```

모델 옆에 가격을 표시하려면 `node.providers`를 [`client.credits.modelCosts()`](https://nodaro.ai/docs/developers/sdk/models-and-credits)에 전달하세요.

### run(type, params?, options?)
노드 하나를 시작하고 바로 반환합니다. 본문은 노드의 경로로 전송되며, 대부분의 노드 타입에서는 `POST /v1/<type>`입니다. 필드 이름은 노드의 입력 필드와 같습니다.

```ts
run(type: string, params?: Record<string, unknown>, options?: { idempotencyKey?: string }): Promise<RunNodeResult>
```

<TypeTable
type={{
type: { type: 'string', required: true, description: "generate-image, image-to-video, text-to-speech 같은 노드의 API 타입입니다." },
params: { type: 'Record<string, unknown>', default: '{}', description: "요청 본문입니다. 일부 노드 타입에는 아래에 나열한 타입 지정 매개변수가 있습니다." },
idempotencyKey: { type: 'string', description: "Idempotency-Key 헤더로 전송됩니다. 시간이 초과된 요청을 다시 시도할 때 같은 값을 재사용하면, 실행이 두 번 시작되거나 두 번 청구되지 않습니다." },
}}
/>

```ts
const result = await client.nodes.run("generate-image", {
prompt: "A snow leopard in the mountains",
provider: "nano-banana-2",
})

if ("jobId" in result) {
const { data: job } = await client.jobs.getStatus(result.jobId)
console.log(job.status)
}
```

**별도의 경로가 있는 노드 타입.** 일부 노드 타입은 다른 경로에서 실행되며, `run()`이 그 경로로 대신 보내 줍니다.

| 노드 타입 | 경로 |
| --- | --- |
| `generate-3d-scene`, `edit-3d-scene` | `/v1/3d-scene/generate`, `/v1/3d-scene/edit` |
| `llm-chat`, `after-effects`, `motion-graphics`, `lottie-overlay`, `3d-title` | `/v1/<type>/generate` |
| `image-to-text` | `/v1/image-to-text/describe` |
| `video-composer` | `/v1/scene-graph/generate` |
| `telegram-channel-feed` | `/v1/telegram-channel/fetch` |
| `suno-generate`, `suno-cover` 같은 모든 `suno-*` 노드 | `/v1/suno/<name>`. `suno-voice`는 `/v1/suno/voice/generate`를 씁니다. |
| `instagram-post`, `tiktok-post`, `youtube-upload`, `linkedin-post`, `x-post`, `facebook-post`, `telegram-post`, `publish-social` | `/v1/social/publish` |
| `ugc-creator`, `ugc-script`, `ugc-clips`, `ugc-clip`, `ugc-cards` | `/v1/ugc/creator`, `/v1/ugc/script` 등입니다. Nodaro Cloud 전용입니다. |
| `planType`이 있는 `render-video` | `/v1/render-video/plan` |

게시 경로에는 `platform`이 필요합니다. `x-post`처럼 플랫폼 하나에 대한 소셜 노드는 값을 생략하면 타입에서 채웁니다. `publish-social`은 보낸 `platform`을 그대로 씁니다.

**단독으로 실행되지 않는 노드 타입.** 별도의 경로가 없는 노드 타입은 API를 호출하지 않고 `code`가 `node_not_runnable`인 `NodaroError`로 거부됩니다. 메시지가 대신 실행할 것을 알려 줍니다.

- `modify-image`는 `image-to-image`로 실행하며, `nano-banana-edit` 모델이면 `edit-image`로 실행합니다.
- `upscale-image`와 `remove-background`는 `edit-image`로 실행합니다.
- `image-to-video`는 `generate-video`로 실행합니다.
- `composite`, `manual-edit`, `paint-mask`는 워크플로 안에서만 실행됩니다. `scene`과 `generative-pipeline`은 파이프라인 안에서만 실행됩니다.

입력, 트리거, 유틸리티, 피커(`upload-image`, `combine-text`)처럼 워크플로 안에서만 실행되는 노드 타입은 API가 해당 경로가 없다고 응답하면 같은 방식으로 거부됩니다. 존재하지 않는 캐릭터 ID처럼 리소스가 없다는 `404`는 그대로 `NotFoundError`입니다.

**반환되는 값.** 대부분의 노드 타입은 비동기입니다. 결과에 `jobId`가 담기고, 워커가 생성을 처리합니다. 작업이 끝날 때까지 [`client.jobs.getStatus(jobId)`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions)를 폴링하거나 `runAndWait()`를 사용하세요. `meta-ads-scrape` 같은 몇몇 동기식 노드 타입은 `jobId` 없이 전체 결과를 바로 반환합니다. `jobId`가 있는지에 따라 분기하세요.

**매개변수 보정.** 이미지 노드 타입(`generate-image`, `image-to-image`, `edit-image`)에서는 선택한 모델이 받지 않는 값을 서버가 보정할 수 있습니다. 실행은 보정된 값으로 진행되며, 예약되는 크레딧도 그 값에 맞춰집니다. 이때 결과에는 보정된 필드마다 항목 하나씩 담긴 `adjustments`가 있습니다.

```ts
const result = await client.nodes.run("generate-image", {
prompt: "A snow leopard",
provider: "gpt-image-2",
aspectRatio: "3:2",
})
if ("adjustments" in result && result.adjustments?.length) {
for (const a of result.adjustments) {
console.warn(`${a.field}: ${a.from} -> ${a.to ?? "(dropped)"} (${a.reason})`)
}
}
```

각 보정 항목에는 `field`(`aspectRatio`, `resolution`, `quality`, `duration` 중 하나), `from`, `to`, `reason`이 있습니다. 바뀐 것이 없으면 `adjustments`는 없습니다.

계정이 비용을 지불할 수 없으면 `InsufficientCreditsError`를, 저장 공간이 가득 차면 `StorageExceededError`를, 배포의 콘텐츠 정책이 요청을 거부하면 `JobBlockedError`를 발생시킵니다. [오류](https://nodaro.ai/docs/developers/sdk/errors)를 참고하세요.

### runAndWait(type, params?, opts?)
비동기 노드 하나를 완료될 때까지 실행합니다. `run()`을 호출해 `jobId`를 받은 다음, 작업이 끝날 때까지 [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions)를 폴링합니다. 상태가 `completed`이면 작업의 출력을 반환합니다.

```ts
runAndWait(type: string, params?: Record<string, unknown>, opts?: RunAndWaitOptions): Promise<NodeJobOutput>
```

<TypeTable
type={{
type: { type: 'string', required: true, description: "노드의 API 타입입니다." },
params: { type: 'Record<string, unknown>', default: '{}', description: "run()과 같은 요청 본문입니다." },
signal: { type: 'AbortSignal', description: "기다리기를 멈추고 JobAbortedError로 거부합니다. 작업 자체는 계속 실행됩니다." },
onProgress: { type: '(status: JobStatusResult) => void', description: "폴링할 때마다 작업 상태와 함께 호출됩니다. 모델이 진행률을 알려 주면 status.progress는 0부터 100까지입니다." },
pollMs: { type: 'number', default: '2000', description: "폴링 간격으로, 밀리초 단위입니다." },
maxMs: { type: 'number', default: '900000', description: "JobTimeoutError가 나기 전까지 기다리는 전체 시간으로, 밀리초 단위입니다." },
idempotencyKey: { type: 'string', description: "실행 요청과 함께 전송됩니다. 같은 요청을 다시 시도할 때 재사용하세요." },
}}
/>

```ts
const output = await client.nodes.runAndWait(
"generate-video",
{ prompt: "Rain falls on a neon street at night", provider: "seedance-2-fast", duration: 4 },
{ onProgress: (s) => console.log(`${s.progress ?? 0}%`) },
)
console.log(output.videoUrl, output.thumbnailUrl)
```

이미지 작업에서는 `NodeJobOutput`에 이미지의 픽셀 크기도 담기므로, 클라이언트가 이미지를 불러오기 전에 그리드를 배치할 수 있습니다.

| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `imageUrl` | `string` | 이미지이며, 작업이 여러 장을 만들었으면 첫 번째 이미지입니다. |
| `imageUrls` | `readonly string[]` | 작업이 두 장 이상 만들었을 때 모든 이미지입니다. |
| `width`, `height` | `number` | 저장된 `imageUrl`의 픽셀 크기로, EXIF 방향이 적용된 값입니다. 무료 등급의 워터마크도 포함됩니다. |
| `images` | `readonly JobOutputImage[]` | `imageUrls`의 항목마다 `{ url, width?, height? }` 하나이며, 순서도 같습니다. 크기가 없는 항목은 측정할 수 없었던 것입니다. |

크기 필드 네 개는 선택 사항입니다. 오래된 작업에는 없으며, 서버는 읽을 수 없었던 크기를 추측하지 않습니다.

```ts
const output = await client.nodes.runAndWait("generate-image", { prompt: "a snow leopard in the mountains" })
console.log(output.imageUrl, output.width, output.height)
for (const image of output.images ?? []) console.log(image.url, image.width ?? "?", image.height ?? "?")
```

출력은 `NodeJobOutput`입니다. 이미지 노드는 `imageUrl`, 동영상 노드는 `videoUrl`과 `thumbnailUrl`, 오디오 노드는 `audioUrl`을 담고, 노드가 기록하는 다른 필드도 함께 담습니다. 예를 들어 [음원 분리](https://nodaro.ai/docs/nodes/audio/audio-separation)는 `vocalUrl`, `instrumentalUrl`처럼 스템마다 URL 하나를 추가합니다.

다음과 같은 타입 지정 오류를 발생시킵니다.

| 오류 | 발생 시점 |
| --- | --- |
| 코드 `batch_requires_run_batch_and_wait`의 `NodaroError` | `count`가 1보다 크거나 `models`가 있는 `generate-image` 요청은 작업을 여러 개 만듭니다. `runAndWait()`는 아무것도 보내기 전에 이를 거부합니다. [`runBatchAndWait()`](#runbatchandwaittype-params-opts)를 쓰세요. |
| `InsufficientCreditsError`, `StorageExceededError`, `JobBlockedError` | 폴링을 시작하기 전에 실행 요청이 거부되었습니다. |
| `JobFailedError` | 작업이 `failed` 또는 `cancelled`로 끝났습니다. `jobId`와 작업의 오류 메시지를 담고 있습니다. |
| `JobTimeoutError` | `maxMs`가 지났습니다. 작업은 취소되지 않습니다. |
| `JobAbortedError` | 내 `signal`이 발생했습니다. 작업은 취소되지 않습니다. |
| `JobHeldError` | 콘텐츠 정책이 있는 배포에서 작업이 사람의 검토를 위해 보류되었습니다. |

**느린 복구.** 워커가 포기한 뒤에 모델이 결과를 전달하는 경우가 있습니다. 그러면 플랫폼이 결과를 복구하는 동안 작업은 `recovering: true`와 함께 `processing` 상태로 남으며, 느린 모델은 수십 분이 걸릴 수 있습니다. `JobTimeoutError`로 대기가 끝나면 나중에 `client.jobs.get(jobId)`로 작업을 가져오거나 `maxMs`를 늘리세요.

### runMany(type, paramsList, opts?)
노드 타입 하나를 동시에 여러 번 실행하고 모두 끝날 때까지 기다립니다. 예를 들어 후보 그리드를 생성할 때 씁니다. 각 항목은 `runAndWait()`로 실행됩니다.

```ts
runMany(type: string, paramsList: Record<string, unknown>[], opts?: RunAndWaitOptions): Promise<RunManyResult[]>
```

<TypeTable
type={{
type: { type: 'string', required: true, description: "모든 실행에 쓰이는 노드의 API 타입입니다." },
paramsList: { type: 'Record<string, unknown>[]', required: true, description: "실행마다 하나씩 넣는 요청 본문입니다." },
opts: { type: 'RunAndWaitOptions', description: "모든 실행이 공유하는 signal, onProgress, pollMs, maxMs입니다. 공유 signal은 일괄 처리 전체를 멈춥니다." },
}}
/>

```ts
const results = await client.nodes.runMany("generate-image", [
{ prompt: "A snow leopard at sunrise" },
{ prompt: "A snow leopard at golden hour" },
{ prompt: "A snow leopard at blue hour" },
])
for (const { jobId, output } of results) console.log(jobId, output.imageUrl)
```

모든 실행이 끝나면 입력 순서대로 항목마다 `{ jobId, output }`을 하나씩 반환합니다. 실행 중 하나라도 실패하면 `runAndWait()`와 같은 오류로 즉시 거부됩니다. `count`가 1보다 크거나 `models`가 있어 그 자체가 일괄 처리인 항목은 아무것도 보내기 전에 거부됩니다. 나중에 가장 좋은 결과를 고르려면 URL을 [`client.reduce.run()`](https://nodaro.ai/docs/developers/sdk/llm-and-reduce)에 전달하세요.

### runBatchAndWait(type, params, opts?)
**이미지 생성** 호출 **한 번**으로 이미지를 여러 장 만들고 모두 끝날 때까지 기다립니다. `count`를 쓰면 이미지를 1~4장 만들며, 각 이미지는 별도의 작업입니다. `models`를 쓰면 `provider` 하나 대신 이미지 모델 2~4개에 같은 요청을 보냅니다. 둘을 함께 쓰면 `count`는 모델마다 적용되며, 모두 합쳐 최대 8장입니다.

```ts
runBatchAndWait(
type: "generate-image",
params: GenerateImageParams,
opts?: RunAndWaitOptions,
): Promise<GenerateImageBatchOutput>
```

이 메서드는 일괄 처리를 한 번 제출한 다음, `runAndWait()`가 기다리는 방식으로 모든 작업을 기다립니다. 일괄 처리 전체가 내 잔액과 비교되고, 전체가 청구되거나 아예 청구되지 않습니다. `opts.idempotencyKey`를 쓰면 *i*번째 이미지의 키는 `<key>:<i>`가 되므로, 다시 시도하면 같은 작업이 반환되고 다시 청구되지 않습니다. `presetId`는 두 방식과 모두 조합됩니다. 프리셋의 요청으로 이미지를 `count`장 만들거나, 모델마다 프리셋을 한 번씩 적용합니다.

```ts
const { batch, results } = await client.nodes.runBatchAndWait(
"generate-image",
{ prompt: "a lighthouse at dusk", models: ["gpt-image-2", "flux-2-max"], count: 2, seed: 41 },
{ idempotencyKey: crypto.randomUUID() },
)
console.log(batch.batchId, batch.totalCredits)
for (const r of results) {
if (r.status === "completed") console.log(r.model, r.output.imageUrl)
else console.warn(r.model, r.jobId, r.error.message)
}
```

결과는 `{ batch, results }`입니다.

- **`batch`**는 제출에 대한 응답인 `GenerateImageBatchResult`입니다. `jobId`(첫 번째 작업), `jobIds`(모델별, 그다음 이미지별), `batchId`, `groups`(모델마다 `{ model, jobIds, credits?, adjustments? }`), `totalCredits`가 있습니다. 매개변수에 1보다 큰 `count`나 `models`가 있으면 `nodes.run("generate-image", …)`도 같은 객체를 반환합니다.
- **`results`**에는 작업마다 `BatchJobOutcome`이 하나씩 `batch.jobIds`의 순서대로 담깁니다. 각각 `{ jobId, model, status: "completed", output }` 또는 `{ jobId, model, status: "error", error }`입니다. `error`는 기다림을 끝낸 `JobFailedError`, `JobHeldError` 또는 `JobTimeoutError`입니다. 이미지 하나가 실패해도 나머지는 버려지지 않습니다.

작업이 하나도 생기기 전에만 제출 자체의 오류로 거부됩니다. 예를 들어 일괄 처리의 합계를 `required`로 담은 `InsufficientCreditsError`입니다. `signal`이 발동하면 `JobAbortedError`로도 거부됩니다. 이미지 한 장을 요청하면 아무것도 보내기 전에 코드 `not_a_batch`로 거부되므로, `runAndWait()`로 기다리세요. `seed`를 쓰면 *i*번째 이미지는 `seed + i`를 씁니다. 나중에 일괄 처리의 작업을 찾으려면 `client.jobs.list({ batchId })`를 쓰세요.

## 타입 지정 매개변수
다음 노드 타입에는 타입 지정 매개변수가 있어, 코드 에디터가 필드를 자동 완성하고 검사합니다. 다른 모든 노드 타입은 일반 객체를 받습니다. 그 필드는 노드의 입력 필드이며, `inputSchema`와 [노드 레퍼런스](https://nodaro.ai/docs/nodes)의 노드 페이지에 나열되어 있습니다. 3D 장면 노드에도 타입 지정 매개변수가 있습니다. [3D 장면](https://nodaro.ai/docs/developers/sdk/scenes-3d)을 참고하세요.

| 노드 타입 | 매개변수 타입 | 노드 페이지 |
| --- | --- | --- |
| `generate-image` | `GenerateImageParams` | [이미지 생성](https://nodaro.ai/docs/nodes/image/generate-image) |
| `generate-video` | `GenerateVideoParams` | [동영상 생성](https://nodaro.ai/docs/nodes/video/generate-video) |
| `text-to-video` | `TextToVideoParams` | [동영상 생성](https://nodaro.ai/docs/nodes/video/generate-video) |
| `assemble-narrated-video` | `AssembleNarratedVideoParams` | [내레이션 동영상 만들기](https://nodaro.ai/docs/nodes/video/assemble-narrated-video) |
| `video-depth` | `VideoDepthParams` | [동영상 깊이](https://nodaro.ai/docs/nodes/video/video-depth) |
| `video-upscale` | `VideoUpscaleParams` | [동영상 업스케일](https://nodaro.ai/docs/nodes/video/upscale-video) |

모든 타입 지정 매개변수 객체는 해당 경로의 다른 필드도 받습니다. 서버는 본문 전체를 검증합니다.

### GenerateImageParams
<TypeTable
type={{
prompt: { type: 'string', description: "이미지에 담길 내용입니다." },
provider: { type: 'string', description: "nano-banana-2나 gpt-image-2 같은 모델 ID입니다. 생략하면 노드의 기본 모델을 씁니다." },
negativePrompt: { type: 'string', description: "모델이 피해야 할 내용입니다." },
referenceImageUrls: { type: 'string[]', description: "레퍼런스 이미지 URL로, connectedReferences 다음에 추가됩니다." },
connectedReferences: { type: 'ConnectedReference[]', description: "에디터가 연결하는 것과 같은, 이름이 붙은 레퍼런스입니다. 아래의 레퍼런스를 참고하세요." },
describedReferences: { type: 'DescribedReference[]', description: "이름을 붙이고 설명할 수는 있지만 사진이 없는 대상으로, 최대 10개입니다." },
referenceOrder: { type: 'string[]', description: "모델이 보게 될 순서대로 나열한 레퍼런스 ID입니다." },
direction: { type: 'DirectionFields', description: "피커 ID로 지정하는 영화적 연출, 또는 팔레트나 무드보드입니다. 아래의 연출을 참고하세요." },
count: { type: 'number', description: "이미지 1~4장이며, 각 이미지는 별도의 작업입니다. 1보다 크면 runBatchAndWait()를 쓰세요." },
models: { type: 'string[]', description: "provider 대신 같은 요청을 실행할 이미지 모델 2~4개입니다. runBatchAndWait()를 쓰세요." },
presetId: { type: 'string', description: "편집기처럼 적용할 노드 프리셋입니다. 아래의 이미지 생성의 프리셋을 참고하세요." },
presetVariables: { type: 'Record<string, string>', description: "기본값이 없는 프리셋 자리표시자의 값입니다." },
promptPrefix: { type: 'string', description: "프롬프트 앞에 놓이는 텍스트입니다." },
promptSuffix: { type: 'string', description: "프롬프트 뒤에 놓이는 텍스트입니다." },
userPrompt: { type: 'string', description: "사용자가 입력한 그대로의 프롬프트입니다. 작업에 저장되며 모델에는 보내지지 않습니다." },
sessionId: { type: 'string', description: "작업을 만들 생성 세션입니다. client.sessions를 참고하세요." },
origin: { type: 'string', description: "작업에 저장되는 내 앱의 슬러그입니다. 소문자, 숫자, 대시로 된 최대 40자입니다." },
}}
/>

#### 이미지 생성의 프리셋
`presetId`는 편집기와 똑같이 노드 프리셋을 적용합니다. 프롬프트, 앞뒤 텍스트, 자리표시자 기본값, 스타일, 네거티브 프롬프트, 설정입니다. 그러면 작업이 편집기의 **실행**과 같아집니다. [`presets.listFactory("generate-image")`](https://nodaro.ai/docs/developers/sdk/pickers-and-prompts#presetslistfactorynodetype)의 기본 제공 ID나 내 프리셋 하나를 쓰세요. 전달한 필드가 프리셋보다 우선합니다.

기본값이 없는 자리표시자에는 `presetVariables`에 값이 필요합니다. 값이 없으면 호출은 그 자리표시자를 알려 주는 `400 preset_variables_required`로 실패합니다.

```ts
await client.nodes.runAndWait("generate-image", {
presetId: "generate-image/logo-wordmark",
presetVariables: { BRAND: "Acme" },
})
```

### GenerateVideoParams
이미지로 동영상 만들기 경로입니다. 시작 프레임, 선택 사항인 종료 프레임, 레퍼런스를 받습니다.

<TypeTable
type={{
prompt: { type: 'string', description: "클립에서 일어나는 일입니다. 시작 프레임이 있으면 선택 사항입니다." },
provider: { type: 'string', description: "seedance-2-fast나 kling-3.0 같은 모델 ID입니다." },
imageUrl: { type: 'string', description: "시작 프레임입니다." },
endFrameUrl: { type: 'string', description: "종료 프레임으로, 이를 지원하는 모델에서 씁니다." },
frameFit: { type: '"original" | "ratio" | "resolution"', default: '"resolution"', description: "시작 프레임이나 종료 프레임의 형태를 바꾸는 방식입니다. resolution은 모델이 렌더링하는 크기로 조정하고, ratio는 화면 비율만 맞추며, original은 그대로 보냅니다." },
frameDelivery: { type: '"auto" | "frame" | "reference"', default: '"auto"', description: "프레임을 실제 시작 프레임으로 보낼지, 첫 프레임으로 지정한 레퍼런스 이미지로 보낼지 정합니다. auto는 모델마다 선택합니다." },
referenceImageUrls: { type: 'string[]', description: "레퍼런스 이미지입니다." },
referenceVideoUrls: { type: 'string[]', description: "레퍼런스 동영상으로, 이를 받는 모델에서 씁니다." },
referenceAudioUrls: { type: 'string[]', description: "레퍼런스 오디오로, 이를 받는 모델에서 씁니다." },
referenceVideoCaptions: { type: 'string[]', description: "레퍼런스 동영상마다 하나씩, 같은 순서로 넣는 캡션입니다." },
referenceAudioCaptions: { type: 'string[]', description: "레퍼런스 오디오 클립마다 하나씩, 같은 순서로 넣는 캡션입니다." },
connectedReferences: { type: 'ConnectedReference[]', description: "이름이 붙은 레퍼런스입니다." },
describedReferences: { type: 'DescribedReference[]', description: "사진 없이 이름을 붙인 대상입니다." },
referenceOrder: { type: 'string[]', description: "레퍼런스 순서입니다." },
draft: { type: 'boolean', description: "seedance-2-5에서 480p 실행 가격으로 만드는 480p 초안입니다. 아래 Seedance 2.5 초안을 참고하세요." },
direction: { type: 'object', description: "피커 ID로 지정하는 영화적 연출과 모션 연출입니다." },
}}
/>

### TextToVideoParams
프롬프트만 쓰는 동영상 경로인 `POST /v1/text-to-video`입니다. 프롬프트는 필수이며, 시작 프레임과 종료 프레임은 대신 `generate-video`에 속합니다. 텍스트로 동영상 만들기 모드가 없는 모델은 `400 image_required`로 응답합니다.

<TypeTable
type={{
prompt: { type: 'string', required: true, description: "클립에서 일어나는 일입니다." },
provider: { type: 'string', description: "모델 ID입니다." },
duration: { type: 'number', description: "초 단위 길이입니다. 허용되는 값은 모델에 따라 다릅니다." },
sound: { type: 'boolean', description: "네이티브 오디오로, 소리를 렌더링하는 모델에서 씁니다." },
negativePrompt: { type: 'string', description: "피해야 할 내용입니다." },
aspectRatio: { type: 'string', description: "16:9 같은 화면 비율입니다." },
resolution: { type: 'string', description: "720p 같은 해상도입니다." },
seed: { type: 'number', description: "고정 시드로, 이를 지원하는 모델에서 씁니다." },
draft: { type: 'boolean', description: "seedance-2-5에서 480p 실행 가격으로 만드는 480p 초안입니다. 어떤 해상도든 480p가 됩니다. 아래 Seedance 2.5 초안을 참고하세요." },
referenceImageUrls: { type: 'string[]', description: "레퍼런스 이미지입니다." },
referenceVideoUrls: { type: 'string[]', description: "레퍼런스 동영상입니다." },
referenceAudioUrls: { type: 'string[]', description: "레퍼런스 오디오입니다." },
direction: { type: 'object', description: "피커 ID로 지정하는 영화적 연출과 모션 연출입니다." },
subject: { type: 'object', description: "사람이나 동물 같은 피사체의 피커 ID로, 연출 문구 앞에 배치됩니다." },
}}
/>

### Seedance 2.5 초안
`GenerateVideoParams`와 `TextToVideoParams`는 `draft: true`를 받습니다. `seedance-2-5`에서는 실행이 480p 실행 가격의 480p 초안이 됩니다. 마음에 드는 테이크는 `run("video-upscale", { provider: "seedance-2-5-hd", sourceJobId })`로 완성하세요. 같은 테이크를 1080p로 다시 렌더링하며, 가격은 초안의 길이로 정해집니다. 내가 완료한 초안이 아닌 소스는 아무것도 청구되기 전에 4xx로 거부됩니다. [Seedance 2.5 초안 모드](https://nodaro.ai/docs/nodes/video/generate-video#seedance-25-draft-mode)를 참고하세요.

```ts
const draft = await client.nodes.run("generate-video", { prompt, provider: "seedance-2-5", draft: true, duration: 8 })
// Wait for the draft and review it. When you like the take, finalize it at 1080p.
if ("jobId" in draft) {
const final = await client.nodes.runAndWait("video-upscale", { provider: "seedance-2-5-hd", sourceJobId: draft.jobId })
console.log(final.videoUrl)
}
```

### VideoDepthParams
클립을 가까운 곳은 밝고 먼 곳은 어두운 깊이 동영상으로 바꿉니다(`POST /v1/video-depth`). 완료된 작업에는 `videoUrl`과 `thumbnailUrl`이 있습니다. 가격과 길이 제한은 [동영상 깊이](https://nodaro.ai/docs/nodes/video/video-depth)를 참고하세요.

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "소스 동영상으로, 공개 http(s) URL입니다." },
provider: { type: '"video-depth-anything" | "depth-anything-frames"', default: '"video-depth-anything"', description: "video-depth-anything은 프레임 간에 일관되며 아래 설정을 모두 받습니다. depth-anything-frames는 프레임마다 따로 실행되므로 깜박일 수 있습니다." },
modelSize: { type: '"small" | "base" | "large"', default: '"large"', description: "클수록 더 세밀하고 느립니다." },
colormap: { type: '"grayscale" | "turbo" | "inferno" | "magma" | "viridis"', default: '"grayscale"', description: "depth-anything-frames는 grayscale과 inferno만 렌더링합니다. 다른 값은 grayscale이 됩니다." },
resolution: { type: '"auto" | "360p" | "480p" | "720p" | "1080p"', default: '"auto"', description: "video-depth-anything 전용입니다." },
sideBySide: { type: 'boolean', default: 'false', description: "video-depth-anything 전용입니다. 원본과 깊이를 나란히 놓습니다." },
}}
/>

선택한 공급자가 받지 않는 설정은 무시되므로, 공급자를 바꿔도 요청이 실패하지 않습니다. 위 목록에 없는 값은 `400 validation_error`입니다. 클립은 크레딧을 예약하기 전에 측정됩니다. 너무 긴 클립은 `400 video_too_long`으로 응답합니다. `video-depth-anything`에서는 2,400프레임 또는 300초 초과, `depth-anything-frames`에서는 80초 초과입니다. 읽을 수 없는 클립은 `400 video_unreadable`로 응답합니다.

```ts
const { videoUrl } = await client.nodes.runAndWait("video-depth", {
videoUrl: "https://cdn.example.com/clip.mp4",
colormap: "turbo",
resolution: "720p",
})
```

### AssembleNarratedVideoParams
동영상 블록과 내레이션을 하나의 동영상으로 이어 붙입니다. 실행 가격은 블록 수로 정해지며 44~143크레딧이 듭니다. 기본 3단위에 블록 6개마다 1단위가 더해지며, 6개에 못 미치는 나머지도 1단위로 셉니다. 각 블록을 내레이션에 맞추는 방법은 [내레이션 동영상 만들기](https://nodaro.ai/docs/nodes/video/assemble-narrated-video)를 참고하세요.

<TypeTable
type={{
blocks: { type: '{ videoUrl: string; audioUrl?: string }[]', required: true, description: "재생 순서대로 놓은 블록 1~60개입니다." },
voiceVolume: { type: 'number', default: '100', description: "내레이션 볼륨으로, 0~200입니다." },
clipAudioVolume: { type: 'number', default: '40', description: "클립 자체 오디오의 볼륨으로, 0~200입니다." },
maxSlowdown: { type: 'number', default: '1.5', description: "클립을 내레이션에 맞추려고 느리게 할 수 있는 정도로, 1~2입니다." },
trimStartFrames: { type: 'number', default: '0', description: "각 클립의 시작에서 잘라 낼 프레임 수로, 0~120입니다." },
trimEndFrames: { type: 'number', default: '0', description: "각 클립의 끝에서 잘라 낼 프레임 수로, 0~120입니다." },
}}
/>

## 레퍼런스
`generate-image`, `generate-video`, `text-to-video`는 에디터가 연결하는 방식대로 레퍼런스를 받습니다. 서버가 이를 프롬프트 안의 `@image_1` 같은 번호 붙은 지시문으로 바꾸므로, “Image 1 is...”(이미지 1은…) 같은 문장을 직접 쓰지 않아도 됩니다.

- <strong>`connectedReferences`</strong>는 에디터의 연결된 레퍼런스 형식인 `ConnectedReference` 항목의 목록으로, SDK에서 내보냅니다. 서버는 중복을 제거하고 모델이 받을 수 있는 만큼만 남깁니다. `referenceOrder`는 ID로 순서를 정합니다.
- **외모 고정.** 항목에 `identityLock: { enabled: true, text? }`를 넣을 수 있습니다. 그러면 서버가 해당 레퍼런스의 외모를 유지하라고 모델에 알려 주는 짧은 문장을 추가합니다. `text`는 기본 문구를 대체하며, 그 안의 `{ref}`는 레퍼런스의 이름을 나타냅니다. 기본값은 꺼짐입니다.
- <strong>`describedReferences`</strong>는 스크립트 속 역할처럼 이름은 붙일 수 있지만 사진이 없는 대상을 위한 `{ name, description }` 항목을 최대 10개 받습니다. 각 항목은 프롬프트에서 `Name — description.` 한 줄이 됩니다. 모델이 누구인지 알 수 있도록 프롬프트 텍스트에도 이름을 넣어 두세요.
- 레퍼런스 항목의 <strong>`descriptionOverride`</strong>는 이번 실행에서만 저장된 설명을 대체합니다.
- **동영상 및 오디오 레퍼런스의 캡션.** `referenceVideoCaptions`와 `referenceAudioCaptions`는 `referenceVideoUrls`와 `referenceAudioUrls`의 순서를 따릅니다. 각 캡션은 프롬프트에서 `@video_1: caption.` 또는 `@audio_1: caption.`으로 나타납니다.
- **프롬프트에서 이미지 레퍼런스 멘션.** `generate-image`에서는 레퍼런스에 이름을 붙이고 `@<name>:<index>` 또는 `@<name>:<index>:<role>` 형식으로 멘션할 수 있습니다. 예를 들면 `@town:1:background`입니다. 멘션한 위치에 해당 레퍼런스나 그 역할이 렌더링됩니다. `~lock`과 `~nolock`은 캐릭터 멘션에서와 같이 작동합니다.

사용할 수 있는 역할은 [레퍼런스 역할](https://nodaro.ai/docs/guides/reference-roles)을, 외모를 일관되게 유지하는 작업은 [일관된 캐릭터](https://nodaro.ai/docs/guides/consistent-characters)를 참고하세요.

## 연출
`generate-image`, `generate-video`, `text-to-video`는 단어 대신 피커 **ID**로 된 `direction` 객체를 받습니다. Nodaro가 ID마다 검증된 문구를 프롬프트에 써 넣으므로, 코드는 ID만 보내고 문구는 항상 최신 상태로 유지됩니다.

```ts
await client.nodes.runAndWait("generate-image", {
prompt: "A detective waits under a street lamp",
direction: { shotSize: "wide-shot", timeOfDay: "golden-hour", mood: "suspicious" },
})
```

- **키**는 `shotSize`, `lightingStyle`, `style`, `mood`, `photographer`, `era` 같은 피커 차원입니다. 동영상 경로에는 `cameraMotion`, `actionFx`, `transition`, `loopSubject`, `temporal` 키 같은 모션 키가 더해집니다.
- **값**은 ID 하나 또는 ID 배열입니다. 값을 여러 개 받는 차원은 자체 한도까지만 유지하고 나머지는 버립니다. 요청은 키 하나에 값이 8개를 넘거나 ID 하나가 100자를 넘을 때만 거부됩니다.
- **알 수 없는 키와 ID는 거부되지 않고 건너뜁니다.** 빈 `direction`은 프롬프트를 바꾸지 않습니다.
- **맵 하나로 이미지와 동영상을 모두 처리합니다.** 정지 이미지 전용 키를 동영상 실행에 보내면 받아들여지지만 아무것도 추가되지 않습니다.
- `extend-video`는 프롬프트가 기존 클립을 이어 가므로 `direction`을 받지 않습니다.
- **내 팔레트와 무드보드**는 `generate-image`에서 작동합니다. `colorLook`을 `paletteDirectionId(id)`로, 또는 `style`을 `moodboardDirectionId(id)`로 설정하세요. [팔레트와 무드보드](https://nodaro.ai/docs/developers/sdk/pickers-and-prompts#clientpalettes)를 참고하세요.

유효한 ID는 [`client.pickerCatalogs`](https://nodaro.ai/docs/developers/sdk/pickers-and-prompts)에서 읽어 오세요. 모든 차원은 [피커 카탈로그](https://nodaro.ai/docs/developers/picker-catalogs)를 참고하세요.

## 언어 모델 노드
`run(type, params)`는 언어 모델 노드 타입을 각각의 경로로 보냅니다. `generate-script`, `image-critic`, `qa-check`, `describe-to-picker`는 `/v1/<type>`으로, 나머지는 더 긴 경로로 보냅니다. 이런 노드에는 [`client.request()`](https://nodaro.ai/docs/developers/sdk/client#requestmethod-path-options)가 필요 없습니다.

| 노드 타입 | 엔드포인트 |
| --- | --- |
| `llm-chat` | `/v1/llm-chat/generate` |
| `after-effects` | `/v1/after-effects/generate` |
| `motion-graphics` | `/v1/motion-graphics/generate` |
| `lottie-overlay` | `/v1/lottie-overlay/generate` |
| `3d-title` | `/v1/3d-title/generate` |
| `image-to-text` | `/v1/image-to-text/describe` |
| `video-composer` | `/v1/scene-graph/generate` |

```ts
await client.nodes.run("generate-script", {
prompt: "A 3-scene product launch script for a smart water bottle",
reasoningEffort: "high",
})
```

- <strong>`reasoningEffort`</strong>는 모델에 따라 `"none"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` 중 하나입니다. 생략하거나 모델이 지원하지 않는 수준을 보내면 모델의 기본값이 쓰입니다. `xhigh`와 `max`는 크레딧 등급이 한 단계 높게 청구됩니다. 이것만으로는 프리미엄 모델의 가격이 프리미엄 그대로입니다. Claude 모델에서는 어떤 추론 강도든 제작사 자체 API에서 실행되며 한 단계 더 높게 청구되므로, 프리미엄 Claude 모델은 premium-direct 가격이 됩니다. 모델과 그 등급은 [텍스트 생성 - 모든 LLM](https://nodaro.ai/docs/nodes/automate/prompt)를 참고하세요. 예외가 하나 있습니다. `describe-to-picker`와 그 텍스트 버전인 `text-to-picker`는 기본 모델인 Claude Opus 5.5에서 이 필드를 생략하면 `"high"`로 실행됩니다. 그 실행은 28크레딧으로 청구됩니다. 바꾸려면 다른 수준을 보내세요. 다른 모델은 기본값을 유지합니다.
- <strong>`advancedMode: true`</strong>는 Gemini 또는 Claude 모델을 제작사의 API에서 직접 실행합니다. `temperature`, `maxTokens`, 전체 추론 강도 범위가 완전히 적용되는 것은 그 경우뿐입니다. 추론 강도로 인한 인상과 별도로 크레딧이 한 단계 높게 청구됩니다. 프리미엄 모델은 premium-direct 가격으로 올라갑니다. 해당 옵션이 없는 모델은 `400 advanced_mode_unsupported`로 응답합니다.
- **스트리밍은 래핑되지 않습니다.** SDK는 텍스트 생성 - 모든 LLM 노드의 스트리밍 응답을 읽지 않습니다. 이 경우 읽기 가능한 스트림과 함께 `fetch`를 사용하세요.

## 모델별 규칙
일부 동영상 모델은 다른 모델이 받지 않는 값을 받습니다. 각 모델 페이지에 전체 옵션과 가격이 나와 있습니다.

- <strong>[Seedance 2](https://nodaro.ai/docs/models/video/seedance-2)</strong>는 `resolution: "4k"`와 `aspectRatio: "adaptive"` 또는 `"21:9"`를 받습니다. [Seedance 2 Fast](https://nodaro.ai/docs/models/video/seedance-2-fast)와 [Seedance 2 Mini](https://nodaro.ai/docs/models/video/seedance-2-mini)는 480p 또는 720p로만 렌더링합니다.
- <strong>[Seedance 2.5](https://nodaro.ai/docs/models/video/seedance-2-5)</strong>는 480p, 720p, 1080p로 렌더링하고, 한 번의 호출로 최대 30초까지 만들며, 이미지 30개, 동영상 10개, 오디오 10개 레퍼런스를 받습니다. 시작 프레임이 있으면 그 프레임의 화면 비율을 쓰고, 명시적인 `aspectRatio`는 거부합니다.
- **[MiniMax H3](https://nodaro.ai/docs/models/video/minimax-h3)**(`minimax-h3`)는 `resolution: "2K"`(기본값) 또는 `"768P"`에서 이미지 9개, 동영상 3개, 오디오 3개 레퍼런스를 받습니다. 다른 값은 2K로 렌더링되고 청구됩니다.
- **[Wan 3.0](https://nodaro.ai/docs/models/video/wan-3-0)**(`wan-3`와 더 빠른 `wan-3-prime`)은 이미지 10개, 동영상 5개, 오디오 5개 레퍼런스를 받습니다. 레퍼런스 목록은 `imageUrl`이나 `endFrameUrl`과 함께 쓸 수 없습니다. `duration`은 2~30의 정수이고, `resolution`은 `480p`, `720p`, `1080p` 중 하나입니다.
- <strong>[Gemini Omni Flash](https://nodaro.ai/docs/models/video/gemini-omni-flash)</strong>는 [Gemini Omni](https://nodaro.ai/docs/models/video/gemini-omni)와 같은 요청을 받습니다. 길이는 4, 6, 8, 10초 중 하나, 해상도는 720p~4K, 화면 비율은 16:9 또는 9:16만 지원합니다.
- **Gemini Omni와 Gemini Omni Flash**(`gemini-omni-video`와 `gemini-omni-flash`)는 `characterReferences`로 실제 인물의 얼굴을 유지합니다. `{ imageUrl, description, bodyImageUrl?, name?, voice? }` 항목을 최대 3개 전달하세요. `voice`는 `{ preset, description?, exampleLine? }`이며, `preset`은 `@nodaro/shared`의 `GEMINI_OMNI_VOICE_PRESETS` ID 중 하나입니다. 클립이 바뀌어도 그 캐릭터의 보이스를 하나로 유지합니다. 다른 모델은 `400`으로 응답하며, `characterReferences`는 시작 프레임(`imageUrl`)과 함께 쓸 수 없습니다. 캐릭터 하나는 모델의 입력 단위 7개 중 1개를 쓰고, `bodyImageUrl`이 있으면 2개를 씁니다. 레퍼런스 이미지와 소스 동영상도 이 단위를 함께 씁니다. 캐릭터 레퍼런스는 크레딧을 추가하지 않습니다. 얼굴이 같아야 할 때는 `referenceImageUrls` 대신 이것을 쓰세요.

**텍스트 음성 변환.** `provider`를 생략하면 [텍스트 음성 변환](https://nodaro.ai/docs/nodes/audio/text-to-speech)은 10,000자 이하의 텍스트에 [ElevenLabs v4](https://nodaro.ai/docs/models/audio/elevenlabs-v4)를 씁니다. 더 긴 텍스트는 한도가 40,000자인 [ElevenLabs Turbo v2.5](https://nodaro.ai/docs/models/audio/elevenlabs-turbo-v2-5)로 대체되므로, 잘리지 않습니다. 직접 지정한 `provider`는 항상 그대로 쓰입니다. ElevenLabs v3를 계속 쓰려면 `provider: "elevenlabs-v3"`를 보내세요. 한도는 5,000자입니다.

**클립 사이의 연속성.** 텍스트 음성 변환은 `previousText`와 `nextText`도 받습니다. 클립 바로 앞과 뒤에 말하는 대사로, 각각 최대 1,000자입니다. 이 텍스트는 읽히지 않습니다. ElevenLabs v4, v4 Turbo, Turbo v2.5, Multilingual v2는 이 텍스트로 따로 만든 클립 사이의 억양을 하나로 유지합니다. ElevenLabs v3는 이 텍스트를 무시합니다.

## 스크래퍼 및 기타 입력 노드
입력 노드도 같은 방식으로 실행합니다. [웹 스크래핑](https://nodaro.ai/docs/nodes/automate/web-scrape) 같은 스크래퍼는 바로 응답합니다. 결과에 내 기록용 `jobId`와 데이터 자체가 함께 담기므로, 폴링하지 않고 사용할 수 있습니다. 요청 필드는 노드의 입력 필드이며, `inputSchema`에 나열되어 있습니다.

## 자주 묻는 질문

### Nodaro SDK로 이미지를 생성하려면 어떻게 하나요?

노드 타입 generate-image와 프롬프트를 넣어 client.nodes.runAndWait를 호출하세요. 작업 출력이 반환되며, 그 안의 imageUrl이 생성된 이미지입니다.

### run과 runAndWait의 차이는 무엇인가요?

run은 노드를 시작하고 바로 jobId를 반환합니다. runAndWait는 노드를 시작한 뒤 2초마다 작업을 폴링하고, 작업이 완료되면 출력을 반환합니다.

### runAndWait는 얼마나 기다리나요?

기본적으로 최대 15분입니다. maxMs 옵션으로 바꿀 수 있습니다. 시간이 초과되어도 작업은 취소되지 않으며, 보통 서버에서 그대로 완료됩니다.

### 어떤 노드 타입을 실행할 수 있나요?

client.nodes.list()를 호출하면 서버가 지원하는 모든 타입을 카테고리, 출력 타입, 모델, 크레딧 비용과 함께 받을 수 있습니다. 노드 레퍼런스에 있는 모든 노드에는 API 타입이 있습니다.

### 호출 한 번으로 이미지를 여러 장 생성하려면 어떻게 하나요?

generate-image와 count(이미지 1~4장) 또는 models(이미지 모델 2~4개)를 넣어 client.nodes.runBatchAndWait를 호출하세요. 일괄 처리는 전체가 청구되거나 아예 청구되지 않으며, 이미지 하나가 실패해도 나머지는 버려지지 않습니다.

### 요청을 다시 시도할 때 두 번 결제되지 않게 하려면 어떻게 하나요?

run이나 runAndWait의 옵션에 idempotencyKey를 전달하고, 다시 시도할 때 같은 키를 재사용하세요. 플랫폼은 두 번째 실행을 시작하는 대신 첫 번째 실행을 반환합니다.
