# 앱과 템플릿

> TypeScript로 게시된 Nodaro 앱을 둘러보고 실행하고, 실행 기록을 읽고, 워크플로 템플릿을 프로젝트에 복제하고, 튜토리얼을 나열하세요.

Source: https://nodaro.ai/ko/docs/developers/sdk/apps-and-templates

<strong>`client.apps`</strong>는 게시된 앱, 즉 입력과 출력으로 된 간단한 양식으로 감싼 워크플로를 둘러보고 실행합니다. <strong>`client.templates`</strong>는 템플릿 마켓플레이스를 둘러보고 템플릿을 내 프로젝트 중 하나에 복제하며, <strong>`client.tutorials`</strong>는 튜토리얼 동영상과 튜토리얼 워크플로를 나열합니다. 개념은 [앱](https://nodaro.ai/docs/concepts/apps)과 [튜토리얼과 템플릿](https://nodaro.ai/docs/get-started/tutorials-and-templates)을 참조하세요.

## 메서드
| 메서드 | 하는 일 |
| --- | --- |
| [`apps.list(params?)`](#appslistparams) | 게시된 앱 둘러보기 |
| [`apps.get(slug)`](#appsgetslug) | 앱과 그 입력, 출력 읽기 |
| [`apps.run(slug, inputs?, opts?)`](#appsrunslug-inputs-opts) | 앱 실행 |
| [`apps.listRuns(slug, params?)`](#appslistrunsslug-params) | 앱의 내 실행 나열 |
| [`apps.getRun(slug, runId)`](#appsgetrunslug-runid) | 실행 하나 읽기 |
| [`apps.deleteRun(slug, runId)`](#appsdeleterunslug-runid) | 실행 보관 |
| [`templates.browse(params?)`](#templatesbrowseparams) | 템플릿 마켓플레이스 둘러보기 |
| [`templates.get(slug)`](#templatesgetslug) | 템플릿과 그 워크플로 읽기 |
| [`templates.clone(slug, params)`](#templatescloneslug-params) | 템플릿을 프로젝트에 복사 |
| [`tutorials.list()`](#tutorialslist) | 카테고리별 튜토리얼 나열 |

## client.apps
`list()`와 `get()`은 공개되어 있습니다. `run()`과 실행 기록은 로그인한 호출자로서 작동합니다.

### apps.list(params?)
게시된 앱을 한 페이지씩 둘러봅니다.

```ts
list(params?: { search?: string; category?: string; limit?: number; cursor?: string }): Promise<{
data: PublishedApp[]
nextCursor?: string | null
}>
```

<TypeTable
type={{
search: { type: 'string', description: "검색할 단어입니다." },
category: { type: 'string', description: "이 카테고리만 나열합니다." },
limit: { type: 'number', description: "페이지 크기로, 최대 50입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const { data: apps, nextCursor } = await client.apps.list({ search: "headshot", limit: 20 })
```

`PublishedApp`에는 `id`, `slug`, `name`, `description`, `creatorId`, `creatorName`, `thumbnailUrl`, `category`, `isFeatured`, `runCount`, `estimatedCredits`, `perMinuteCredits`, `perItemCredits`, `createdAt`, `updatedAt`이 있습니다.

`estimatedCredits`는 앱의 실행당 표시 가격입니다. 어떤 앱은 입력받는 녹음의 길이에 따라 가격이 정해집니다. 예를 들어 **편집 계획** 단계나 에피소드 전체의 렌더링입니다. 이런 앱은 `perMinuteCredits`가 0보다 크며, 가격은 `estimatedCredits`에 녹음 1분마다 `perMinuteCredits`를 더한 값입니다. 앱에 사용자가 채우는 목록 입력이 있으면 `perItemCredits`가 0보다 큽니다. 제작자가 저장한 항목을 넘는 항목마다 그만큼의 크레딧이 더해집니다.

앱 사용자가 고를 수 있는 선택지가 더 비싸면 표시 가격은 범위가 됩니다. `maxEstimatedCredits`는 `maxPerMinuteCredits`, `maxPerItemCredits`와 함께, 제작자가 정한 제한 안에서 앱이 노출한 필드로 실행 한 번에 들 수 있는 최대 가격입니다. `estimatedCredits`와 그 구성 요소는 저장된 설정의 가격, 즉 "부터" 가격입니다. `max*` 필드는 값이 다를 때만 있습니다. 범위가 생기기 전에 게시된 앱에는 다음에 게시할 때까지 이 필드가 없습니다.

### apps.get(slug)
앱 하나를 읽습니다. 최종 사용자가 채우는 필드인 `inputSchema`와 앱이 반환하는 것인 `outputs`가 함께 들어 있습니다.

```ts
get(slug: string): Promise<{ data: PublishedAppDetail }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "앱의 슬러그입니다." },
}}
/>

```ts
const { data: app } = await client.apps.get("pro-headshot")
console.log(app.inputSchema, app.outputs) // outputs: [{ nodeId, label, type }]
```

`runEstimatedCredits`, `runPerMinuteCredits`, `runPerItemCredits`는 앱 실행만의 가격입니다. 표시 가격에서 [최종 렌더링](https://nodaro.ai/docs/nodes/video/apply-edl#preview-and-render-final) 부분을 뺀 값이며, 최종 렌더링은 따로 실행되고 따로 청구됩니다. `editPlanPerMinute`는 **편집 계획** 노드가 녹음의 시작된 1분마다 청구되면 `true`이고, 15, 30, 60, 90, 120, 180분 단위로 청구되면 `false`입니다.

### apps.run(slug, inputs?, opts?)
앱을 실행하고 `{ executionId, status, runId? }`를 즉시 반환합니다. 결과는 [`client.executions.get(executionId)`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions)를 폴링해 받으세요.

```ts
run(slug: string, inputs?: Record<string, unknown>, opts?: {
inputOverrides?: Record<string, Record<string, unknown>>
}): Promise<{ executionId: string; status: "pending" | "running"; runId?: string }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "앱의 슬러그입니다." },
inputs: { type: 'Record<string, unknown>', description: "앱의 입력 값입니다. 키는 inputSchema의 필드 이름과 일치합니다." },
inputOverrides: { type: 'Record<string, Record<string, unknown>>', description: "이번 실행에만 적용되는 원시 노드 설정으로, 노드 ID별로 지정합니다. 예: { n1: { promptPrefix: '...' } }. 앱이 사용자에게 보여 주지 않는 필드에도 적용됩니다." },
}}
/>

```ts
const { executionId } = await client.apps.run("pro-headshot", { photo: photoUrl })

// Add hidden text before the app's prompt, for this run only
await client.apps.run(
"pro-headshot",
{ photo: photoUrl },
{ inputOverrides: { n1: { promptPrefix: "Studio portrait of" } } },
)
```

노출된 [동영상 URL](https://nodaro.ai/docs/nodes/automate/video-url#use-it-as-an-app-input) 노드로 동영상 링크를 받는 앱은 링크를 입력값으로 넣어 실행합니다. 예: `client.apps.run("clip-pack", { episode: "https://www.youtube.com/watch?v=..." })`. 서버는 첫 노드가 실행되기 전에 게시물 링크를 다운로드합니다. 4분 이상인 YouTube 동영상은 `inputOverrides`로 지정한 구간만 다운로드됩니다. 그 밖의 웹 링크는 그대로 사용됩니다.

`inputOverrides`는 고급 옵션입니다. 예를 들어 노드의 [앞뒤 텍스트](https://nodaro.ai/docs/concepts/prompt-pre-post-text)(`promptPrefix`와 `promptSuffix`)를 설정할 수 있습니다. 하지만 Webhook 출력 URL, 게시 계정, 스크레이퍼의 대상 같은 출력 노드의 대상은 설정할 수 없습니다. 그런 재정의는 `400 locked_field`로 거부됩니다.

### apps.listRuns(slug, params?)
앱의 내 실행을 한 페이지씩 나열합니다.

```ts
listRuns(slug: string, params?: { limit?: number; cursor?: string }): Promise<{ data: AppRun[]; nextCursor?: string | null }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "앱의 슬러그입니다." },
limit: { type: 'number', description: "페이지 크기입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const { data: runs } = await client.apps.listRuns("pro-headshot", { limit: 10 })
for (const run of runs) console.log(run.status, run.outputs)
```

`AppRun`에는 `id`, `appSlug`, `executionId`, `status`(`pending`, `running`, `completed`, `failed` 또는 `cancelled`), `inputs`, `outputs`(각각 `{ nodeId, type, url?, text? }`), `startedAt`, `finishedAt`이 있습니다.

### apps.getRun(slug, runId)
앱의 실행 하나를 읽습니다.

```ts
getRun(slug: string, runId: string): Promise<{ data: AppRun }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "앱의 슬러그입니다." },
runId: { type: 'string', required: true, description: "실행 ID입니다." },
}}
/>

```ts
const { data: run } = await client.apps.getRun("pro-headshot", runId)
```

### apps.deleteRun(slug, runId)
실행을 보관합니다. 실행을 복원하거나 영구 삭제하는 기능은 Nodaro 앱에서만 사용할 수 있으므로, 스크립트가 데이터를 없앨 수는 없습니다.

```ts
deleteRun(slug: string, runId: string): Promise<{ success: true; archived: true }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "앱의 슬러그입니다." },
runId: { type: 'string', required: true, description: "실행 ID입니다." },
}}
/>

```ts
await client.apps.deleteRun("pro-headshot", runId)
```

## client.templates
설계상 공개된 템플릿 마켓플레이스입니다. 둘러보고, 템플릿을 전체 워크플로와 함께 읽고, 내 프로젝트에 무료로 복제하세요.

### templates.browse(params?)
마켓플레이스를 한 페이지씩 둘러봅니다(`GET /v1/templates/browse`). 로그인이 필요 없습니다.

```ts
browse(params?: BrowseTemplatesParams): Promise<{ data: TemplateBrowseCard[]; nextCursor: string | null }>
```

<TypeTable
type={{
search: { type: 'string', description: "전체 텍스트 검색입니다." },
category: { type: 'string', description: "이 카테고리만 나열합니다." },
outputType: { type: 'string', description: "이 종류의 출력을 만드는 템플릿만 나열합니다." },
tag: { type: 'string', description: "이 태그가 있는 템플릿만 나열합니다." },
nodeType: { type: 'string', description: "이 노드 유형을 사용하는 템플릿만 나열합니다." },
provider: { type: 'string', description: "이 모델을 사용하는 템플릿만 나열합니다." },
complexity: { type: 'string', description: "이 복잡도의 템플릿만 나열합니다." },
sort: { type: '"newest" | "popular" | "most-favorited"', default: '"newest"', description: "정렬 순서입니다. 경로는 cheapest도 받습니다. 아래를 참고하세요." },
limit: { type: 'number', description: "페이지 크기입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const page = await client.templates.browse({ sort: "popular", search: "trailer" })
```

카드의 `estimatedCredits`는 템플릿의 표시 가격입니다. 가격이 템플릿에 입력되는 녹음의 길이를 따르면 `estimatedPerMinuteCredits`가 0보다 큽니다. 그러면 가격은 `estimatedCredits`에 녹음 1분마다 그만큼의 크레딧을 더한 값입니다. 그렇지 않으면 `estimatedPerMinuteCredits`는 없거나 `0`입니다. 템플릿의 선택지가 더 비쌀 수 있으면 카드에는 가격 범위의 상한인 `maxEstimatedCredits`와 `maxEstimatedPerMinuteCredits`도 있습니다.

경로는 **템플릿** 페이지의 **크레딧 적은 순** 정렬인 `sort=cheapest`도 받습니다. 60분 녹음의 가격이 낮은 순서로 정렬하며, 그 가격은 `estimatedCredits`에 `estimatedPerMinuteCredits`의 60배를 더한 값입니다. SDK의 `TemplateSort` 타입에는 `cheapest`가 없으므로 [`client.request()`](https://nodaro.ai/docs/developers/sdk/client#requestmethod-path-options)로 보내세요.

### templates.get(slug)
공개 템플릿 하나를 전체 워크플로 스냅샷인 `snapshotNodes`, `snapshotEdges`, `snapshotSettings`와 함께 읽습니다(`GET /v1/templates/:slug`).

```ts
get(slug: string): Promise<Template>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "템플릿의 슬러그입니다." },
}}
/>

```ts
const template = await client.templates.get("noir-trailer")
console.log(template.snapshotNodes.length)
```

슬러그를 알 수 없거나, 목록에서 제외되었거나, 비활성 상태이면 `NotFoundError`를 발생시킵니다.

### templates.clone(slug, params)
템플릿을 내 프로젝트 중 하나에 새 워크플로로 복사합니다(`POST /v1/templates/:slug/clone`). 크레딧이 들지 않습니다.

```ts
clone(slug: string, params: { projectId: string; name?: string }): Promise<{ workflowId: string; projectId: string }>
```

<TypeTable
type={{
slug: { type: 'string', required: true, description: "템플릿의 슬러그입니다." },
projectId: { type: 'string', required: true, description: "복사해 넣을 프로젝트입니다." },
name: { type: 'string', description: "새 워크플로의 이름입니다. 기본값은 템플릿의 이름입니다." },
}}
/>

```ts
const { workflowId } = await client.templates.clone("noir-trailer", { projectId })
await client.workflows.run(workflowId)
```

## client.tutorials
### tutorials.list()
모든 튜토리얼 카테고리를 그 튜토리얼 동영상, 튜토리얼 워크플로와 함께 나열합니다(`GET /v1/tutorials`). 공개되어 있으며 읽기 전용입니다.

```ts
list(): Promise<{ categories: TutorialCategory[] }>
```

```ts
const { categories } = await client.tutorials.list()
for (const category of categories) {
console.log(category.name, category.videos.length, category.flows.length)
}
```

각 카테고리에는 `id`, `name`, `slug`, `sortOrder`, `videos`, `flows`가 있습니다. 동영상에는 `title`, `videoUrl`, `thumbnailUrl`이 있습니다. 플로는 튜토리얼로 표시된 템플릿으로, `title`, `complexity`, `estimatedCredits`, 사용하는 노드 유형과 모델, 그리고 `templates.get()`과 `templates.clone()`에 전달할 수 있는 `slug`가 있습니다.

## 자주 묻는 질문

### 코드에서 Nodaro 앱을 실행하려면 어떻게 하나요?

앱의 슬러그와, 키가 앱의 입력 필드와 일치하는 inputs 객체를 담아 client.apps.run을 호출하세요. executionId가 반환되므로, 결과는 client.executions.get(executionId)를 폴링해 받으세요.

### 앱이 기대하는 입력은 어떻게 알 수 있나요?

client.apps.get(slug)을 호출하세요. inputSchema에는 최종 사용자가 채우는 필드가, outputs에는 앱이 반환하는 것이 나열됩니다.

### 템플릿을 복제하면 크레딧이 드나요?

아니요. client.templates.clone은 템플릿을 내 프로젝트 중 하나에 무료로 복사합니다. 비용은 새 워크플로를 실행할 때만 듭니다.

### inputOverrides로 앱이 결과를 보내는 곳을 바꿀 수 있나요?

아니요. 재정의로는 Webhook 출력 URL이나 게시 계정 같은 대상을 설정할 수 없습니다. 이런 실행은 400 locked_field로 거부됩니다.
