# 스튜디오 프로덕션

> TypeScript로 플랜에서 스튜디오 프로덕션을 만들고, 오퍼레이션으로 편집하고, 스틸과 클립을 생성하고, 계획된 프레임을 검토하고, 공유하거나 복사합니다.

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

**스튜디오 프로덕션**은 설정에 영화의 샷을 담은 Nodaro 워크플로입니다. 각 샷에는 구도를 잡은 스틸, 선택 사항인 애니메이션 클립, 그리고 이를 만든 플랜, 룩, 출연진 연결, 보이스가 있습니다. <strong>`client.studio`</strong>는 프로덕션을 읽고 쓰므로, 스크립트, AI 어시스턴트, 스튜디오 앱이 하나의 프로덕션을 함께 다룹니다. <strong>`client.shots`</strong>는 공유 링크 뒤에 있는 공유 샷 레코드를 저장합니다. 이 메서드들은 [스튜디오 프로덕션 REST API](https://nodaro.ai/docs/developers/api/studio-productions)를 호출합니다. 작성 가이드는 [MCP로 다루는 스튜디오 프로덕션](https://nodaro.ai/docs/mcp/studio-productions)을 참고하세요.

스튜디오 프로덕션은 Nodaro Cloud에서 실행됩니다. 이 경로를 제공하지 않는 곳에서는 모든 메서드가 `NotFoundError`를 발생시킵니다. 한 번 확인하려면 `client.studio.productions.list()`를 호출하세요. 프로덕션이 있는 배포 환경은 빈 페이지로 응답하고, 없는 배포 환경은 `NotFoundError`를 발생시킵니다.

## 두 가지 메서드 세트
`client.studio`에는 두 계층이 있습니다. 둘 다 같은 프로덕션을 다룹니다.

| 계층 | 용도 | 반환값 |
| --- | --- | --- |
| `client.studio.productions.*` | 프로덕션 문서: 플랜으로 만들기, 오퍼레이션으로 편집, 스틸과 클립 생성, 보이스와 음악 추가, 공유와 복사 | 페이로드 자체 |
| `client.studio.*` | 계획된 프레임: 기능 확인, 키프레임 생성과 검토, 번들, 편집기 저장, 리비전 확인을 거치는 링크 공유 | API의 `{ data }` 래퍼 |

**래퍼에는 타입이 지정되어 있고, 프로덕션 문서에는 없습니다.** 프로덕션, 샷, 오퍼레이션은 열린 JSON인 `Record<string, unknown>`입니다. 분기 조건으로 쓰는 값에는 모두 타입이 지정되어 있습니다. `version`, `rebased`, `receipts`, `warnings`, 견적의 `credits`, 실행의 `jobIds`입니다. 오퍼레이션 어휘는 서버가 제공하니 `skill()`에서 읽으세요.

## client.studio.productions의 메서드
| 메서드 | 기능 |
| --- | --- |
| [`skill()`](#productionsskill) | 작성 가이드, 카탈로그, 플랜 스키마, 오퍼레이션 가이드 읽기 |
| [`validatePlan(plan)`](#productionsvalidateplanplan) | 플랜을 무료로 확인 |
| [`list(opts?)`](#productionslistopts) | 내 프로덕션 나열 |
| [`get(productionId, opts?)`](#productionsgetproductionid-opts) | 프로덕션 읽기 |
| [`exportPlan(productionId, opts?)`](#productionsexportplanproductionid-opts) | 내보내기 단계를 계획하고 가격 확인 |
| [`create(input?)`](#productionscreateinput) | 프로덕션 만들기, 선택적으로 플랜에서 |
| [`ops(productionId, input)`](#productionsopsproductionid-input) | 오퍼레이션 일괄 처리를 적용하거나 미리 보기 |
| [`reconcile(productionId)`](#productionsreconcileproductionid) | 완료된 생성 반영 |
| [`importPlan(productionId, plan, opts?)`](#productionsimportplanproductionid-plan-opts) | 플랜의 장면을 프로덕션에 추가 |
| [`describe(productionId, input)`](#productionsdescribeproductionid-input) | 개요를 장면으로 만들기 |
| [`generate()`, `generateStill()`, `generateClip()`](#generate-stills-and-clips) | 샷의 구도를 잡거나 애니메이션 만들기 |
| [`frame(productionId, input)`](#productionsframeproductionid-input) | 샷의 클립에서 스틸 가져오기 |
| [`voice(productionId, input)`](#productionsvoiceproductionid-input) | 샷의 대사 읽기 |
| [`revoice(productionId, input)`](#productionsrevoiceproductionid-input) | 샷 클립의 보이스 바꾸기 |
| [`music(productionId, input)`](#productionsmusicproductionid-input) | 영화에 음악 입히기 |
| [`share()`, `unshare()`, `clone()`](#share-and-copy) | 공유 링크를 켜거나 끄기, 또는 프로덕션 복사 |

## client.studio.productions
### productions.skill()
서버가 실행 중인 버전에서 만든 작성 가이드, 전체 카탈로그, 플랜의 JSON Schema, 오퍼레이션 가이드를 반환합니다. 무료입니다.

```ts
skill(): Promise<{ skill: string; catalog: string; schema: Record<string, unknown>; operating: string; generatedFrom: object }>
```

```ts
const { skill, schema, operating } = await client.studio.productions.skill()
```

`operating`은 `ops()`가 받는 오퍼레이션을 나열합니다. 어휘를 코드에 고정하지 말고 실행할 때 읽으세요.

### productions.validatePlan(plan)
플랜이 프로덕션이 되기 전에 확인합니다. 무료이고, 아무것도 저장하지 않으며, 출연진 이름을 내 라이브러리와 대조합니다. `valid`가 `true`가 될 때까지 `errors`를 고치며 반복한 다음, `create({ plan })`을 호출하세요.

```ts
validatePlan(plan: Record<string, unknown>): Promise<{
valid: boolean
errors: Array<{ path: string; message: string; hint?: string }>
warnings: Array<{ path: string; message: string; hint?: string }>
summary?: { name?: string; scenes: number; shots: number; cast: number; bound: number }
}>
```

<TypeTable
type={{
plan: { type: 'Record<string, unknown>', required: true, description: "skill().schema 형태의 프로덕션 플랜입니다." },
}}
/>

```ts
const check = await client.studio.productions.validatePlan(plan)
if (!check.valid) console.log(check.errors)
```

`summary.bound`는 내 라이브러리의 캐릭터와 일치한 출연진 항목 수입니다.

### productions.list(opts?)
내 프로덕션을 최신순으로 나열합니다.

```ts
list(opts?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: StudioProduction[]; nextCursor?: string }>
```

<TypeTable
type={{
limit: { type: 'number', description: "페이지 크기입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
includeArchived: { type: 'boolean', default: 'false', description: "대시보드에서 숨기는 보관된 프로덕션도 포함합니다." },
}}
/>

```ts
const { data: productions } = await client.studio.productions.list({ limit: 20 })
```

### productions.get(productionId, opts?)
프로덕션을 읽습니다. 순수한 읽기이며 완료된 작업을 반영하지 않으므로, 작업을 기다리고 있다면 먼저 `reconcile()`을 호출하세요.

```ts
get(productionId: string, opts?: { detail?: "summary" | "full"; shotId?: string }): Promise<StudioProduction>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
detail: { type: '"summary" | "full"', default: '"summary"', description: "summary는 개수와 활성 URL을 반환합니다. full은 모든 결과와 그 결과를 만든 맥락을 추가합니다." },
shotId: { type: 'string', description: "샷 하나만 읽습니다. 생성 후 다시 읽을 때 가장 가벼운 방법입니다." },
}}
/>

```ts
const production = await client.studio.productions.get(productionId, { detail: "full" })
```

### productions.exportPlan(productionId, opts?)
영화를 조립하는 단계를 순서대로, 가격과 함께 반환합니다. 아무것도 실행하지 않고 아무것도 쓰지 않으므로, 단계는 일반 노드 메서드로 직접 실행하세요.

```ts
exportPlan(productionId: string, opts?: { upscale?: boolean }): Promise<{
canExport: boolean
steps: Array<{ id: string; label: string; node: string; creditModel: string; credits: number | null; params: Record<string, unknown> }>
resultStepId: string | null
estimate: number | null
unpriced: string[]
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
upscale: { type: 'boolean', default: 'false', description: "4K 마무리도 계획합니다. 비용이 더 들기 때문에 항상 내가 선택합니다." },
}}
/>

```ts
const plan = await client.studio.productions.exportPlan(productionId)
console.log(plan.canExport, plan.estimate)
for (const step of plan.steps) console.log(step.id, step.label, step.node, step.credits)
```

프로덕션의 클립이 두 개 미만이면 `canExport`는 `false`입니다. 가격이 없는 단계가 하나라도 있으면 `estimate`는 `null`이고, `unpriced`가 그 단계들의 모델을 알려 줍니다. 일부만 더한 합계는 비용을 실제보다 적게 보여 주기 때문입니다. 각 단계의 `node`는 `merge-video-audio`, `combine-videos`, `video-upscale` 같은 노드 유형입니다.

### productions.create(input?)
프로덕션을 만들고, 선택적으로 같은 호출에서 플랜을 반영합니다.

```ts
create(input?: { name?: string; plan?: Record<string, unknown> }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>
```

<TypeTable
type={{
name: { type: 'string', description: "프로덕션 이름입니다." },
plan: { type: 'Record<string, unknown>', description: "반영할 검증된 플랜입니다." },
}}
/>

```ts
const { production, summary } = await client.studio.productions.create({ name: "Rome chase", plan })
```

### productions.ops(productionId, input)
프로덕션에 **오퍼레이션** 일괄 처리를 적용합니다. 모든 변경은 오퍼레이션이며, 위치가 아니라 샷 id, 역할 슬러그, 결과의 작업 id 같은 고정 키로 대상을 지정합니다.

```ts
ops(productionId: string, input: StudioOpsRequest): Promise<StudioOpsResponse>
ops(productionId: string, input: StudioOpsRequest & { dryRun: true }): Promise<StudioOpsDryRunResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
ops: { type: 'unknown[]', required: true, description: "순서대로 적용되는 오퍼레이션입니다. 요청당 최대 100개입니다. 어휘는 skill().operating에 있습니다." },
baseVersion: { type: 'number', description: "일괄 처리를 작성할 때 기준으로 삼은 버전입니다. 기본적으로 참고용입니다. 일괄 처리는 최신 버전에 적용되고, 응답에 rebased가 표시됩니다." },
strict: { type: 'boolean', description: "리베이스를 거부합니다. 그러면 이전 baseVersion은 409 workflow_conflict로 실패합니다." },
clientRequestId: { type: 'string', description: "이 일괄 처리를 나타내는 8~128자의 내 토큰으로, 재시도가 두 번 적용되지 않게 합니다." },
dryRun: { type: 'true', description: "쓰지 않고 일괄 처리가 무엇을 할지 미리 봅니다. 호출 안에 리터럴 true를 직접 쓰세요." },
}}
/>

```ts
const result = await client.studio.productions.ops(productionId, {
ops: [/* operations from the operating guide */],
baseVersion: version,
clientRequestId: crypto.randomUUID(),
})
version = result.version // carry it forward as the next baseVersion
for (const r of result.receipts) console.log(r.summary)
```

- **원자적.** 잘못된 오퍼레이션이 하나라도 있으면 일괄 처리 전체가 [`StudioOpError`](https://nodaro.ai/docs/developers/sdk/errors#studio-batches)로 거부되며, 그 `opIndex`가 해당 오퍼레이션을 알려 줍니다. 아무것도 쓰이지 않습니다.
- **리베이스.** 두 사람이 한 프로덕션을 동시에 편집할 수 있습니다. 이전 버전을 기준으로 작성한 일괄 처리도 최신 버전에 적용되며, `rebased`는 `true`입니다.
- **영수증.** `receipts`에는 오퍼레이션마다 과거형 설명이 한 줄씩 들어 있습니다. 예를 들어 `Deleted take 2 of Shot 1 (in the bin)`(샷 1의 테이크 2를 삭제해 휴지통으로 옮김) 같은 줄입니다. 오퍼레이션의 영향이 지정한 대상을 넘어서면, 그 `impact`가 새로 고칠 `keyframeIds`와 `shotIds`를 나열합니다.
- **응답을 그대로 채택.** 내 사본을 `production`으로 교체하고 `version`을 다음 요청으로 넘기세요. 이전 사본에 병합하지 마세요.

**일괄 처리 미리 보기.** `dryRun: true`를 주면 응답은 일괄 처리가 무엇을 **할지** 알려 주므로, 사람이 어시스턴트의 편집을 먼저 승인할 수 있습니다. 응답에는 `dryRun`, `version`, `receipts`, `warnings`가 있고 `production`은 없습니다. 각 영수증에는 `class`가 추가됩니다. `S`는 안전, `D`는 삭제, `P`는 작업물에 접근할 수 있는 사람 변경, `$`는 크레딧 지출입니다. 또한 `restorable`이 추가되는데, 이 값은 오퍼레이션이 무언가를 휴지통에 넣었을 때만 있습니다. `restorable ?? false`로 읽으세요.

`dryRun: true`는 호출 자체의 객체 안에 리터럴로 쓰세요. 변수를 거쳐 전달하면 `boolean`으로 넓혀져, 실제로는 미리 보기를 하면서도 호출의 타입은 적용으로 지정됩니다.

미리 보기는 요청을 **두 번** 보냅니다. 먼저 배포 환경이 미리 보기를 지원하는지 확인하는 빈 일괄 처리를 보내고, 그다음 내 일괄 처리를 보냅니다. 그렇지 않으면 미리 보기를 지원하지 않는 배포 환경이 경고 없이 일괄 처리를 적용해 버리기 때문입니다. 오류는 두 가지가 생길 수 있습니다.

```ts

try {
const preview = await client.studio.productions.ops(productionId, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)
} catch (err) {
if (err instanceof StudioPreviewUnavailable) {
// Nothing was sent. Say that no preview is available; do not apply the batch instead.
} else if (err instanceof StudioPreviewAppliedError) {
// The batch was applied. Adopt err.applied.production and err.applied.version.
// Do not send it again. When err.applied is undefined, read the production first.
} else {
throw err
}
}
```

### productions.reconcile(productionId)
마지막으로 확인한 뒤 완료된 모든 생성을 반영하고, 아직 실행 중인 것을 알려 줍니다. 앱을 열지 않고도 완료된 작업을 결과로 바꾸는 유일한 호출이며, 반영된 것이 있을 때만 씁니다.

```ts
reconcile(productionId: string): Promise<{
landed: string[]
pending: string[]
failed: string[]
warnings: string[]
production: StudioProduction
version: number
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
}}
/>

```ts
const { landed, pending } = await client.studio.productions.reconcile(productionId)
```

`landed`는 미디어가 이제 프로덕션에 들어간 작업을, `pending`은 아직 실행 중인 작업을, `failed`는 실패했거나 취소된 작업을 나열합니다.

### productions.importPlan(productionId, plan, opts?)
플랜의 장면을 기존 프로덕션에 추가합니다.

```ts
importPlan(productionId: string, plan: Record<string, unknown>, opts?: { mode?: "append" }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
plan: { type: 'Record<string, unknown>', required: true, description: "추가할 플랜입니다." },
mode: { type: '"append"', default: '"append"', description: "기존 장면 뒤에 장면을 추가합니다." },
}}
/>

```ts
await client.studio.productions.importPlan(productionId, extraScenesPlan)
```

### productions.describe(productionId, input)
디렉터가 개요를 장면으로 만듭니다. 작업을 시작하고 곧바로 반환하며, 장면은 `reconcile()`로 반영됩니다. 반환되는 프로덕션에는 이 실행이 대기 중인 초안으로 기록되어 있습니다.

```ts
describe(productionId: string, input: {
brief: string
llmModel: string
mode?: "append" | "replace"
label?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
brief: { type: 'string', required: true, description: "영화의 내용입니다." },
llmModel: { type: 'string', required: true, description: "장면 초안을 쓰는 언어 모델입니다." },
mode: { type: '"append" | "replace"', description: "append는 초안으로 작성된 장면을 추가합니다. replace는 영화를 다시 씁니다." },
label: { type: 'string', description: "실행의 이름입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
const { jobId } = await client.studio.productions.describe(productionId, {
brief: "A courier races across Rome in the rain to deliver a violin.",
llmModel,
})
```

### 스틸과 클립 생성
`generateStill()`은 샷의 구도를 잡고, `generateClip()`은 샷에 애니메이션을 입히며, `generate()`는 `kind`에 따라 둘 중 하나를 합니다. 실행은 작업을 제출하고, 프로덕션에 대기 중 표시를 기록한 뒤 반환합니다. 몇 분씩 기다리는 일은 없습니다. 요청은 서버에서 샷 자체의 플랜, 룩, 레퍼런스로 구성되므로, 스크립트에서 호출하든 앱에서 클릭하든 같은 미디어가 나옵니다.

```ts
generate(productionId: string, input: StudioGenerateRequest): Promise<StudioGenerateResult>
generateStill(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>
generateClip(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
shotId: { type: 'string', required: true, description: "구도를 잡거나 애니메이션을 입힐 샷입니다." },
kind: { type: '"still" | "clip"', description: "generate() 전용: 만들 대상입니다." },
count: { type: 'number', description: "스틸: 후보 수입니다. 결과는 쌓이며, 새 실행이 이전 결과를 대체하지 않습니다." },
mode: { type: '"start" | "start-end" | "references"', description: "클립: 보낼 입력입니다. start는 시작 프레임만, start-end는 두 프레임을 모두 보냅니다. 생략하면 샷에 저장된 입력을 따릅니다." },
dryRun: { type: 'boolean', description: "가격 견적을 반환하고 아무것도 제출하지 않습니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
overrides: { type: 'Record<string, unknown>', description: "이번 실행에만 적용되는 변경입니다. 모델, 프롬프트, 화면 비율, 연출 id 등을 바꿀 수 있습니다. 샷 자체는 바뀌지 않습니다." },
}}
/>

```ts

const quote = await client.studio.productions.generateStill(productionId, "shot-2", { count: 2, dryRun: true })
if (isStudioGenerateEstimate(quote)) console.log(quote.credits) // null means unpriced, not free

const run = await client.studio.productions.generateStill(productionId, "shot-2", {
count: 2,
clientRequestId: crypto.randomUUID(),
})
```

- **먼저 견적.** `dryRun: true`는 실행 가격을 계산하고 아무것도 쓰지 않습니다. 응답은 `isStudioGenerateEstimate()`로 구분하세요.
- **안전하게 재시도.** 같은 `clientRequestId`를 쓰면 재시도는 첫 호출이 시작한 작업을 `deduped: true` 표시와 함께 반환하고, 아무것도 제출하거나 청구하지 않습니다. 이 값 없이 유료 호출을 재시도하지 마세요. `frame()`과 `voice()`를 포함해 이 페이지의 모든 유료 호출이 이 값을 받습니다.
- **레인은 자동으로 정해집니다.** 클립의 동영상 경로는 샷의 입력에 따라 정해지고 `lane`으로 반환됩니다. `generate-video` 또는 `text-to-video`입니다.

### productions.frame(productionId, input)
샷의 활성 클립에서 스틸을 가져와 `target`이 지정한 곳에 넣습니다. 몇 초 걸리는 작업을 기다린 뒤, 변경된 프로덕션과 이미지 `url`을 반환합니다.

```ts
frame(productionId: string, input: {
shotId: string
mode?: "first" | "last" | "timestamp"
timestamp?: number
target?: "new-shot" | "start-frame" | "end-frame" | "still"
clientRequestId?: string
}): Promise<StudioMediaResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
shotId: { type: 'string', required: true, description: "클립을 사용할 샷입니다." },
mode: { type: '"first" | "last" | "timestamp"', description: "가져올 프레임입니다." },
timestamp: { type: 'number', description: "mode가 timestamp일 때 쓰는 시간(초)입니다." },
target: { type: '"new-shot" | "start-frame" | "end-frame" | "still"', default: '"new-shot"', description: "프레임을 넣을 곳입니다. 이 샷 뒤의 새 샷, 이 샷의 시작 프레임이나 종료 프레임, 또는 이 샷의 다른 스틸입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
const { url } = await client.studio.productions.frame(productionId, { shotId: "shot-2", mode: "last" })
```

### productions.voice(productionId, input)
샷의 대사를 읽고 샷에 기록합니다. 작업이 끝날 때까지 기다립니다.

```ts
voice(productionId: string, input: {
shotId: string
text: string
voiceId?: string
voiceType?: "premade" | "custom" | "library"
ttsProvider?: string
delivery?: Record<string, number>
clientRequestId?: string
}): Promise<StudioMediaResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
shotId: { type: 'string', required: true, description: "샷입니다." },
text: { type: 'string', required: true, description: "읽을 대사입니다." },
voiceId: { type: 'string', description: "보이스입니다." },
voiceType: { type: '"premade" | "custom" | "library"', description: "보이스의 종류입니다." },
ttsProvider: { type: 'string', description: "음성 모델입니다." },
delivery: { type: 'Record<string, number>', description: "음성 경로의 한도 안에서 지정하는 전달 설정입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
await client.studio.productions.voice(productionId, { shotId: "shot-3", text: "We're out of time." })
```

### productions.revoice(productionId, input)
샷의 활성 클립에 있는 보이스를 바꿉니다. 몇 분이 걸리므로 `jobId`를 반환하며, 새 클립은 그 대기 중 표시를 통해 반영됩니다.

```ts
revoice(productionId: string, input: { shotId: string; plan: Record<string, unknown>; clientRequestId?: string }): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
shotId: { type: 'string', required: true, description: "샷입니다." },
plan: { type: 'Record<string, unknown>', required: true, description: "보이스 교체 경로가 받는 형식의, 화자 순서대로 된 보이스 교체 플랜입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
const { jobId } = await client.studio.productions.revoice(productionId, {
shotId: "shot-3",
plan: recastPlan, // the speaker-ordered plan the voice recast route takes
})
```

### productions.music(productionId, input)
영화에 음악을 입힙니다. 완성된 트랙은 대기 중 표시를 통해 반영됩니다.

```ts
music(productionId: string, input: {
prompt: string
duration?: number
instrumental?: boolean
vocalGender?: string
model?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
prompt: { type: 'string', required: true, description: "원하는 음악입니다." },
duration: { type: 'number', description: "길이(초)입니다." },
instrumental: { type: 'boolean', description: "보컬 없는 음악입니다." },
vocalGender: { type: 'string', description: "보컬이 있는 음악에서 가수의 목소리입니다." },
model: { type: 'string', description: "음악 모델입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
const { jobId } = await client.studio.productions.music(productionId, {
prompt: "Tense strings building to a chase",
instrumental: true,
})
```

### 공유와 복사
`share()`는 링크 공유 화면을 열고, `unshare()`는 다시 닫습니다. 공유는 오퍼레이션이 아니라 별도의 호출이므로, 편집의 부작용으로 작업물을 볼 수 있는 사람이 바뀌는 일은 없습니다. `clone()`은 내가 소유하거나 볼 수 있는 프로덕션을 내 스튜디오 프로젝트로 복사합니다.

```ts
share(productionId: string): Promise<StudioProduction>
unshare(productionId: string): Promise<StudioProduction>
clone(productionId: string, input?: { name?: string }): Promise<StudioProduction>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "프로덕션 id입니다." },
name: { type: 'string', description: "clone 전용: 사본의 이름입니다." },
}}
/>

```ts
await client.studio.productions.share(productionId)
const copy = await client.studio.productions.clone(productionId, { name: "Rome chase, take 2" })
```

사본은 비공개이고 보관되지 않은 상태로 시작합니다. 공유와 보관 상태는 넘어가지 않습니다. 사본은 원본을 **내가** 볼 수 있는 범위를 기준으로 복사되므로, 다른 사람의 휴지통은 함께 오지 않습니다.

## client.studio: 계획된 프레임
이 메서드들은 계획된 키프레임과 그 검토를 다룹니다. 컨트롤을 제공하기 전에 `capabilities()`를 확인하고, 프로덕션을 다시 열 때는 `reconcile()`을 한 번 호출하세요. 제출에 대한 응답이 유실되었을 수 있기 때문입니다. 여기서는 해당 메서드를 직접 호출하지 않는 한 생성을 시작하거나 후보를 채택하는 일이 없습니다.

| 메서드 | 기능 |
| --- | --- |
| [`capabilities()`](#studiocapabilities) | 이 배포 환경이 지원하는 플랜 버전과 오퍼레이션 읽기 |
| [`skill()`, `list()`, `validatePlan()`, `create()`](#studioskill-list-validateplan-and-create) | 프로덕션 계층과 같은 읽기와 만들기를 래퍼로 반환 |
| [`get(id, options?)`](#studiogetid-options) | 기능 정보와 함께 프로덕션 읽기 |
| [`edit(id, input)`](#studioeditid-input) | 리비전 조건을 붙여 오퍼레이션 적용 |
| [`saveEditorState(id, input)`](#studiosaveeditorstateid-input) | 불러온 리비전을 기준으로 일반 편집기 필드 저장 |
| [`generateKeyframe(id, input)`](#studiogeneratekeyframeid-input) | 계획된 프레임을 채택하지 않고 생성 |
| [`generateShot(id, input)`](#studiogenerateshotid-input) | 스틸이나 클립의 견적을 내거나 제출 |
| [`acceptKeyframe(id, review, concurrency?)`](#studioacceptkeyframeid-review-concurrency) | 검토한 후보 채택 |
| [`reconcile(id)`](#studioreconcileid) | 아무것도 채택하지 않고 완료된 작업 기록 |
| [`setShared(id, input)`](#studiosetsharedid-input) | 검토한 리비전에 묶어 공유하거나 공유 해제 |
| [`clone(id, input?)`](#studiocloneid-input) | 저장된 프로덕션 복사 |
| [`importBundle(input)`](#studioimportbundleinput) | 이식 가능한 프로덕션 가져오기 |
| [`appendBundle(id, input)`](#studioappendbundleid-input) | 프로덕션에 번들 덧붙이기 |

### studio.capabilities()
이 배포 환경이 지원하는 플랜 버전과 계획된 프레임 오퍼레이션을 반환합니다.

```ts
capabilities(): Promise<{ data: StudioProductionCapabilities }>
```

```ts
const { data: caps } = await client.studio.capabilities()
if (caps.operations.generateKeyframes) showGenerateFrameButton()
```

`operations`에는 오퍼레이션마다 플래그가 하나씩 있습니다. `readKeyframes`, `editKeyframes`, `generateKeyframes`, `acceptKeyframes`, `rejectKeyframes`, `editSequencePlans`, `generateLinkedClips`, `retakeLinkedClips`, `saveEditorState`, `revisionedSharing`, `editableSharedCopies`, `cloneLinkedProductions`, `importPlannedBundles`, `importLinkedBundles`, `appendPlannedBundles`, `appendLinkedBundles`입니다. `automaticAcceptance`와 `unattendedGeneration`은 항상 `false`입니다.

### studio.skill(), list(), validatePlan(), create()
`productions.skill()`, `list()`, `validatePlan()`, `create()`와 같은 호출이며, `{ data }` 래퍼에 담겨 반환됩니다. `list()`에서는 행이 `response.data.data`에 있습니다.

```ts
skill(): Promise<{ data: Record<string, unknown> }>
list(options?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: {
data: Array<{ id: string; name: string; version: number; updatedAt: string; thumbnailUrl: string | null; shared: boolean; archived: boolean; shotCount: number }>
nextCursor?: string
} }>
validatePlan(plan: Record<string, unknown>): Promise<{ data: { valid: boolean; errors: object[]; warnings: object[]; summary?: object } }>
create(input: { name?: string; plan?: Record<string, unknown> }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
options: { type: '{ limit?, cursor?, includeArchived? }', description: "list(): 페이지 나누기와 보관된 행입니다." },
plan: { type: 'Record<string, unknown>', description: "validatePlan()과 create(): 플랜입니다." },
name: { type: 'string', description: "create(): 프로덕션 이름입니다." },
}}
/>

```ts
const { data: page } = await client.studio.list({ limit: 20 })
for (const row of page.data) console.log(row.name, row.shotCount)
```

### studio.get(id, options?)
기능 정보와 함께 프로덕션을 읽습니다. 작업을 반영하지는 않습니다.

```ts
get(id: string, options?: { detail?: "summary" | "full"; shotId?: string }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
detail: { type: '"summary" | "full"', description: "full은 모든 결과와 그 맥락을 추가합니다." },
shotId: { type: 'string', description: "샷 하나만 읽습니다." },
}}
/>

```ts
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0] // { id, label, revision, previewUrl, acceptedUrl, pending, ... }
```

### studio.edit(id, input)
리비전 조건을 붙여 오퍼레이션을 적용하고(`POST .../:id/ops`), 래퍼에 담아 반환합니다. `remove_shot`, `restore_trashed`, `purge_trashed`에는 `strict`와 함께 `baseVersion`을 쓰고, 연결된 시퀀스 세그먼트는 그 장면을 제거하기 전에 분리하세요.

```ts
edit(id: string, input: { ops: Array<{ op: string; [field: string]: unknown }>; baseVersion?: number; strict?: boolean; clientRequestId?: string }): Promise<{ data: StudioProductionReply & { version: number; rebased: boolean; receipts: object[] } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
ops: { type: 'Array<{ op: string; ... }>', required: true, description: "오퍼레이션입니다." },
baseVersion: { type: 'number', description: "불러온 버전입니다." },
strict: { type: 'boolean', description: "최신 버전으로의 리베이스를 거부합니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
await client.studio.edit(productionId, {
ops: [{ op: "reject_keyframe_result", keyframeId, expectedRevision, resultKey, expectedAcceptedResultKey, reason: "Face drifted" }],
baseVersion: version,
strict: true,
})
```

`edit()`로 보내는 계획된 프레임 오퍼레이션 몇 가지입니다.

- <strong>`reject_keyframe_result`</strong>는 생성하지 않고 **수정 필요**를 기록합니다. `operations.rejectKeyframes`가 필요합니다.
- <strong>`update_sequence_plan`</strong>은 시퀀스의 순서가 정해진 세그먼트(각각 `{ shotId, startKeyframeId, endKeyframeId }`)를 편집하고, 장면 id는 유지합니다. `operations.editSequencePlans`가 필요합니다.
- <strong>`detach_sequence_segment`</strong>는 `mode`를 `clear`나 `keep-accepted`로 설정해 세그먼트 하나를 독립시킵니다. `operations.editSequencePlans`가 필요합니다.
- <strong>`purge_trashed`</strong>는 내가 표시하는 휴지통 항목을 비우고, `clear_trash`는 계획된 프레임을 포함해 모든 휴지통을 비웁니다.

### studio.saveEditorState(id, input)
불러온 리비전을 기준으로 일반 편집기 필드를 저장합니다. 먼저 `operations.saveEditorState`를 확인하세요. 저장은 항상 `strict`로 처리됩니다. 충돌하면 409로 실패하므로, 로컬 초안을 보관하고 다시 불러온 다음 충돌을 해결하세요.

```ts
saveEditorState(id: string, input: { expectedVersion: number; graph: object; clientRequestId?: string })
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
expectedVersion: { type: 'number', required: true, description: "불러온 버전입니다." },
graph: { type: 'object', required: true, description: "저장할 편집기 상태입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
}}
/>

```ts
await client.studio.saveEditorState(productionId, { expectedVersion: version, graph })
```

이 저장으로는 프레임 플랜, 채택, 엔드포인트 연결, 작업 기록, 보호된 휴지통 항목, 공유를 바꿀 수 없습니다. 이런 항목은 각각의 전용 동작을 사용하세요.

### studio.generateKeyframe(id, input)
계획된 프레임을 채택하지 않고 생성합니다. 프레임에는 시험 실행이 없습니다.

```ts
generateKeyframe(id: string, input: { keyframeId: string; expectedRevision: number; clientRequestId?: string; overrides?: Record<string, unknown> }): Promise<{ data: { jobIds: string[]; deduped?: true; lane?: string } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
keyframeId: { type: 'string', required: true, description: "계획된 프레임입니다." },
expectedRevision: { type: 'number', required: true, description: "읽어 온 프레임의 리비전입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다. 재시도할 때 같은 값을 유지하세요." },
overrides: { type: 'Record<string, unknown>', description: "이번 실행에만 적용되는 변경입니다." },
}}
/>

```ts
const { data: caps } = await client.studio.capabilities()
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0]

if (frame && caps.operations.generateKeyframes) {
const { data: generation } = await client.studio.generateKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
clientRequestId: crypto.randomUUID(),
})
// follow generation.jobIds with client.jobs, then call reconcile()
}
```

생성은 후보를 채택하지 않으며 캐릭터 초상화를 만들지도 않습니다. 설명만 있는 출연진 레퍼런스에는 초상화가 필요 없습니다.

### studio.generateShot(id, input)
샷의 스틸이나 클립의 견적을 내거나 제출합니다.

```ts
generateShot(id: string, input: StudioShotGenerationInput): Promise<{ data: StudioGenerationReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
kind: { type: '"still" | "clip"', required: true, description: "만들 대상입니다." },
shotId: { type: 'string', required: true, description: "샷입니다." },
count: { type: 'number', description: "후보 수입니다." },
mode: { type: '"start" | "start-end" | "references"', description: "클립: 보낼 입력입니다." },
dryRun: { type: 'boolean', description: "견적을 반환하고 아무것도 제출하지 않습니다." },
expectedInputHash: { type: 'string', description: "연결된 클립: 검토한 견적의 inputHash입니다." },
retakeResultKey: { type: 'string', description: "연결된 클립: 원래 요청으로 다시 만들 테이크입니다." },
clientRequestId: { type: 'string', description: "내 재시도 토큰입니다." },
overrides: { type: 'Record<string, unknown>', description: "이번 실행에만 적용되는 변경입니다." },
}}
/>

```ts
const { data: quote } = await client.studio.generateShot(productionId, { kind: "clip", shotId, dryRun: true })
if ("inputHash" in quote) {
await client.studio.generateShot(productionId, {
kind: "clip",
shotId,
expectedInputHash: quote.inputHash,
clientRequestId: crypto.randomUUID(),
})
}
```

**연결된 클립.** 계획된 프레임 사이를 잇는 클립의 견적에는 `inputHash`, 채택된 `endpointPins`, 정규화된 길이, 해상도, 화면 비율, 사운드 설정, 그리고 가격 계산에 쓰인 `creditIdentifier`가 들어 있습니다. 검토한 `inputHash`를 `expectedInputHash`로 전달하세요. 견적 이후 설정이나 채택된 프레임이 바뀌었다면, 호출은 아무것도 제출하기 전에 `409 sequence_quote_changed`로 실패합니다. 새 견적을 요청하세요. 견적의 크레딧은 추정치이며, 생성할 때는 현재 가격이 예약됩니다.

**리테이크.** `operations.retakeLinkedClips`가 `true`이면 `kind: "clip"`과 `shotId`와 함께 `retakeResultKey`를 전달하고, `dryRun: true`로 견적을 내세요. 검토한 `expectedInputHash`와 새 `clientRequestId`로 제출하고, `mode`, `overrides`, `count`는 빼세요. 리테이크는 플랜이나 채택이 바뀐 뒤에도 원래 요청과 보관된 프레임 이미지를 다시 사용합니다. 이전 테이크는 기록에 남습니다. 확인할 수 있는 원래 요청이나 보관된 이미지가 없는 테이크는 거부되며, 리테이크가 같은 동영상 바이트를 재현하지는 않습니다.

### studio.acceptKeyframe(id, review, concurrency?)
계획된 프레임에 대해 검토한 후보를 채택합니다. 이는 별도의 명시적인 단계이며, 생성 과정에서 호출되는 일은 없습니다.

```ts
acceptKeyframe(id: string, review: StudioKeyframeAcceptanceInput, concurrency?: { baseVersion?: number; strict?: boolean; clientRequestId?: string })
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
keyframeId: { type: 'string', required: true, description: "계획된 프레임입니다." },
expectedRevision: { type: 'number', required: true, description: "검토한 프레임 리비전입니다." },
resultKey: { type: 'string', required: true, description: "채택할 후보입니다." },
expectedAcceptedResultKey: { type: 'string | null', required: true, description: "이전에 채택된 후보, 또는 null입니다." },
requirementChecks: { type: 'Array<{ requirementId: string; outcome: "pass" | "waived" }>', required: true, description: "각 요구 사항 확인의 결과입니다." },
waivedReason: { type: 'string', description: "확인을 면제할 때 필요합니다." },
concurrency: { type: '{ baseVersion?, strict?, clientRequestId? }', description: "쓰기에 붙는 리비전 조건입니다." },
}}
/>

```ts
await client.studio.acceptKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
resultKey,
expectedAcceptedResultKey: frame.acceptedResultKey,
requirementChecks, // one { requirementId, outcome } per requirement of the frame
})
```

충돌하면 일반적인 오류가 발생합니다. SDK는 다른 결과를 고르거나 최신 리비전으로 스스로 재시도하지 않습니다.

### studio.reconcile(id)
후보를 채택하거나 생성을 시작하지 않고 완료된 작업을 기록합니다. 휴지통에 있는 장면의 작업도 확인합니다. 완료된 클립은 그 장면에 저장된 그래프에 남으며, `restore_trashed`로 복구할 수 있습니다.

```ts
reconcile(id: string): Promise<{ data: StudioProductionReply & { landed: string[]; pending: string[]; failed: string[]; version: number } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
}}
/>

```ts
const { data } = await client.studio.reconcile(productionId)
console.log(data.landed, data.pending)
```

### studio.setShared(id, input)
검토한 리비전에 묶어 프로덕션을 공유하거나 공유를 해제합니다. `operations.revisionedSharing`을 확인하고 `expectedVersion`을 전달하세요. 그러면 동시에 일어난 편집은 `409 workflow_conflict`로 실패하며, SDK는 재시도하지 않습니다. 공개 범위를 바꿀 수 있는 호출자만 사용할 수 있습니다.

```ts
setShared(id: string, input: { shared: boolean; allowEditableCopy?: boolean; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로덕션 id입니다." },
shared: { type: 'boolean', required: true, description: "true이면 공유 링크를 켜고, false이면 끕니다." },
allowEditableCopy: { type: 'boolean', description: "로그인한 링크 열람자가 편집 가능한 프로덕션을 복사할 수 있게 합니다. operations.editableSharedCopies가 필요합니다." },
expectedVersion: { type: 'number', description: "검토한 버전입니다." },
}}
/>

```ts
await client.studio.setShared(productionId, { shared: true, allowEditableCopy: true, expectedVersion: version })
```

`allowEditableCopy`를 쓰면 소유자나 워크스페이스 관리자는 링크 열람자가 저장된 플랜, 프롬프트, 출연진 설명, 보관된 레퍼런스 입력, 테이크 기록을 복사할 수 있게 합니다. 휴지통과 비공개 검토 메모는 절대 포함되지 않습니다. 사본은 비공개이고 채택된 프레임이 없는 상태로 시작합니다. 복사를 끄거나 공유를 해제하면 새 사본을 만들 수 없으며, 이미 만든 사본은 독립적으로 남습니다.

### studio.clone(id, input?)
저장된 프로덕션을 복사합니다. 연결된 프레임이 있는 프로덕션을 복사하기 전에 `operations.cloneLinkedProductions`를 확인하고, 불러온 `expectedVersion`을 전달하세요. 원본이 바뀌었다면 409로 실패합니다.

```ts
clone(id: string, input?: { name?: string; projectId?: string; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "복사할 프로덕션입니다." },
name: { type: 'string', description: "사본의 이름입니다." },
projectId: { type: 'string', description: "사본을 넣을 내 프로젝트입니다." },
expectedVersion: { type: 'number', description: "불러온 원본 버전입니다." },
}}
/>

```ts
const { data: { production: copy } } = await client.studio.clone(productionId, {
name: "Rome chase copy",
expectedVersion: version,
})
```

사본은 비공개로 시작합니다. 프레임 입력은 유지되며 내 스토리지 용량에 포함됩니다. 프레임, 장면, 시퀀스 id는 새로 발급되고, 실행 중인 작업과 채택된 프레임은 넘어가지 않습니다. 사본의 프레임에 의존하는 미디어를 생성하기 전에 그 프레임을 검토하고 채택하세요. 복사는 생성을 제출하지 않습니다.

### studio.importBundle(input)
이식 가능한 프로덕션을 새 장면, 프레임, 시퀀스 id를 가진 새 비공개 프로덕션으로 가져옵니다(`POST .../import-bundle`). 미디어 없는 레시피와 플랜에는 `operations.importPlannedBundles`를, 보관된 프레임 미디어가 있는 번들에는 `importLinkedBundles`를 확인하세요.

```ts
importBundle(input: { bundle: Record<string, unknown>; projectId?: string }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
bundle: { type: 'Record<string, unknown>', required: true, description: "이식 가능한 프로덕션입니다." },
projectId: { type: 'string', description: "가져올 대상인 내 프로젝트입니다." },
}}
/>

```ts
const { data: { production } } = await client.studio.importBundle({ bundle })
```

연결된 번들은 원본 프로덕션을 지정합니다. 서버는 내가 그 프로덕션을 소유하는지 확인하고, 무언가를 복사하기 전에 보관된 이미지를 모두 검증합니다. 접근 권한이 없거나 출처가 위조되었으면 새 프로덕션을 만들기 전에 가져오기가 거부됩니다. 어느 종류의 가져오기도 채택이나 실행 중인 작업을 가져오지 않으며, 미디어를 생성하지도 않습니다.

### studio.appendBundle(id, input)
새 id와 정확한 리비전 확인을 거쳐 완전한 번들을 편집 가능한 프로덕션에 덧붙입니다(`POST .../:id/import-bundle`). OAuth 토큰에는 `workflows:write`가 필요하고, 나에게 프로덕션 편집 권한이 있어야 합니다. 먼저 `appendPlannedBundles`나 `appendLinkedBundles`를 확인하세요.

```ts
appendBundle(id: string, input: { bundle: Record<string, unknown>; expectedVersion: number; afterShotId?: string; applyFilm?: boolean }): Promise<{
data: { production: StudioProductionRecord; importedShotIds: string[]; importedKeyframeIds: string[] }
}>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "덧붙일 대상 프로덕션입니다." },
bundle: { type: 'Record<string, unknown>', required: true, description: "덧붙일 번들입니다." },
expectedVersion: { type: 'number', required: true, description: "불러온 프로덕션 버전입니다." },
afterShotId: { type: 'string', description: "이 샷 뒤에 삽입합니다. 생략하면 끝에 덧붙입니다." },
applyFilm: { type: 'boolean', description: "번들의 영화 룩을 적용합니다. 음악, 컷, 영화 개요는 그대로 유지됩니다." },
}}
/>

```ts
const { data } = await client.studio.appendBundle(productionId, { bundle, expectedVersion: version })
console.log(data.importedShotIds)
```

기존 장면, 프레임, 작업, 공유, 그 밖의 설정은 유지되고, 가져온 출연진 역할은 병합됩니다. 가져온 프레임은 다시 채택해야 합니다. 알 수 없는 `afterShotId`나 오래된 `expectedVersion`은 실패합니다.

## client.shots
`/s/:id` 공유 링크 뒤에 있는 샷 레코드로, 공유와 리믹스에 쓰입니다. 샷은 빌더의 상태를 저장합니다. 피커 선택, 프롬프트, 대상 모델, `@` 멘션 레퍼런스, 결과 URL이 추측할 수 없는 12자 id 아래에 저장되며, 이 id는 공유 키이기도 합니다. 샷은 기본적으로 **비공개**이며, 공유는 내가 직접 하는 공개 범위 변경입니다.

```ts
create(input?: CreateShotInput): Promise<{ id: string }>
get(id: string): Promise<{ shot: Shot }>
update(id: string, input: UpdateShotInput): Promise<{ shot: Shot }>
delete(id: string): Promise<void>
```

<TypeTable
type={{
id: { type: 'string', description: "get, update, delete: 샷 id입니다." },
mode: { type: '"single" | "multi-shot" | "frame-to-motion" | "storyboard"', description: "빌더 모드입니다." },
selectionState: { type: 'Record<string, unknown>', description: "피커 선택입니다." },
freeText: { type: 'string', description: "자유 프롬프트 텍스트입니다." },
negativePrompt: { type: 'string', description: "피할 내용입니다." },
assembledPrompt: { type: 'string', description: "최종 프롬프트입니다." },
perModelPrompts: { type: 'Record<string, string>', description: "모델별 프롬프트입니다." },
models: { type: 'string[]', description: "대상 모델입니다." },
entityRefs: { type: 'Array<{ entitySlug, variantSlug?, role?, kind? }>', description: "멘션한 캐릭터, 장소, 오브젝트, 크리처입니다." },
resultUrls: { type: 'string[]', description: "결과 URL입니다. 일반 공개 http 또는 https URL이어야 합니다. 서명된 URL은 거부되므로, 공유 레코드에 토큰이 새어 나가는 일이 없습니다." },
visibility: { type: '"private" | "public"', default: '"private"', description: "샷을 읽을 수 있는 사람입니다." },
}}
/>

```ts
const { id } = await client.shots.create({ mode: "single", freeText: "A lighthouse in a storm", models: ["nano-banana-2"] })
await client.shots.update(id, { visibility: "public" }) // anyone with the id can now read it
const { shot } = await client.shots.get(id)
```

공개 샷은 id를 아는 누구나 읽을 수 있습니다. 비공개 샷은 소유자만 읽을 수 있고, 다른 사람은 `NotFoundError`를 받습니다. 샷을 수정하거나 삭제할 수 있는 사람은 소유자뿐입니다.

## 자주 묻는 질문

### 스튜디오 프로덕션이란 무엇인가요?

프로덕션은 설정에 영화의 샷을 담은 워크플로입니다. 각 샷에는 구도를 잡은 스틸, 선택 사항인 애니메이션 클립, 그리고 이를 만든 플랜, 룩, 출연진, 보이스가 있습니다. 스튜디오 앱과 SDK는 같은 프로덕션을 읽고 씁니다.

### client.studio에 메서드 세트가 두 개인 이유는 무엇인가요?

client.studio.productions는 프로덕션 문서를 다룹니다. 오퍼레이션, 스틸, 클립, 보이스, 음악을 처리하고 페이로드 자체를 반환합니다. client.studio는 계획된 프레임과 그 검토를 다루고, API의 data 래퍼를 반환합니다. 둘 다 같은 프로덕션에 접근합니다.

### 생성을 재시도할 때 두 번 결제하지 않으려면 어떻게 하나요?

직접 만든 clientRequestId를 전달하고, 재시도할 때 같은 값을 다시 쓰세요. 서버는 첫 호출이 시작한 작업으로 응답하고, 응답에 deduped를 표시하며, 다시 요금을 부과하지 않습니다.

### 스틸이나 클립의 가격을 먼저 확인하려면 어떻게 하나요?

dryRun을 true로 설정해 generateStill이나 generateClip을 호출하세요. 응답은 크레딧이 담긴 견적이며, 아무것도 제출되지 않습니다. isStudioGenerateEstimate로 응답을 구분하세요.
