# 장소

> TypeScript로 장소를 만들고 설정 샷을 생성해 하나를 승인한 뒤, 시간대, 날씨, 계절, 앵글, 모션 베리언트를 추가하는 방법을 안내합니다.

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

<strong>`client.locations`</strong>는 장소 스튜디오가 하는 모든 작업을 스크립트로 처리합니다. 장소를 만들고 수정하며, 설정 샷 후보를 생성하고, 그중 하나를 대표 이미지로 승인하고, 시간대, 날씨, 계절, 앵글, 조명 베리언트와 분위기 클립을 추가합니다. 장소는 대표 이미지, 베리언트 컬렉션, 레퍼런스 사진, 설명을 보관하므로, 이후의 모든 샷에서 같은 장소를 보여 줄 수 있습니다. 이 메서드들은 [장소 REST API](https://nodaro.ai/docs/developers/api/locations)를 호출합니다. 에디터에서 보는 화면은 [장소](https://nodaro.ai/docs/guides/locations)를 참고하세요.

## 메서드
| 메서드 | 기능 |
| --- | --- |
| [`list(params?)`](#listparams) | 내 장소 목록을 반환합니다 |
| [`listArchived(params?)`](#listarchivedparams) | 보관된 내 장소 목록을 반환합니다 |
| [`get(id)`](#getid) | 장소 하나를 진행 중인 작업과 함께 읽어 옵니다 |
| [`create(input)`](#createinput) | 장소를 만듭니다 |
| [`update(id, input)`](#updateid-input) | 장소를 변경합니다 |
| [`delete(id)` 및 `restore(id)`](#deleteid-and-restoreid) | 장소를 보관하거나 다시 복원합니다 |
| [`generate(input)`](#generateinput) | 설정 샷 후보를 생성합니다 |
| [`generateAsset(input)`](#generateassetinput) | 시간대, 날씨, 계절, 앵글 또는 조명 베리언트를 생성합니다 |
| [`generateSurroundContinuation(input)`](#generatesurroundcontinuationinput) | 360도 링의 다음 뷰를 생성합니다 |
| [`generateMotion(input)`](#generatemotioninput) | 대표 이미지를 분위기 클립으로 애니메이션화합니다 |
| [`removeAsset(id, data)`](#removeassetid-data) | 베리언트 컬렉션에서 테이크 하나를 제거합니다 |
| [`approveMainImage(id, candidateJobId)`](#approvemainimageid-candidatejobid) | 후보를 대표 이미지로 만듭니다 |
| [`recaption(id)`](#recaptionid) | 장소의 설명을 다시 작성합니다 |

## client.locations
### list(params?)
내 장소 목록을 반환합니다. 기본적으로 활성 장소만 반환합니다. 페이지 나누기는 선택 사항입니다. `limit` 없이 호출하면 전체 목록을 받고 커서는 없습니다. 최대 500인 `limit`을 지정하면 한 페이지와 `nextCursor`를 받습니다.

```ts
list(params?: { archived?: boolean; q?: string; limit?: number; cursor?: string }): Promise<{
locations: Location[]
nextCursor?: string | null
}>
```

<TypeTable
type={{
archived: { type: 'boolean', default: 'false', description: "true이면 대신 보관된 장소를 나열합니다." },
q: { type: 'string', description: "검색어입니다. 이름이나 설명에 모든 단어가 들어 있는 장소만 반환하며, 순서와 대소문자는 구분하지 않습니다. characters.list()와 같은 검색입니다." },
limit: { type: 'number', description: "페이지 크기로, 최대 500입니다. 생략하면 전체 목록을 받습니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const { locations } = await client.locations.list()
const { locations: cafes } = await client.locations.list({ q: "café" })

const page = await client.locations.list({ limit: 100 })
const next = await client.locations.list({ limit: 100, cursor: page.nextCursor ?? undefined })
```

`nextCursor`가 `null`이 될 때까지 다음 페이지를 계속 요청하세요. `q`의 검색 규칙은 [캐릭터](https://nodaro.ai/docs/developers/sdk/characters#listparams)에 있습니다. 처음 5개 단어만 쓰이며, `%`, `_`, `*`는 와일드카드가 아닙니다. 각 `cursor`와 함께 같은 `q`를 보내세요.

### listArchived(params?)
보관된 내 장소 목록을 반환합니다. `list({ archived: true })`의 단축 메서드입니다.

```ts
listArchived(params?: { limit?: number; cursor?: string }): Promise<{ locations: Location[]; nextCursor?: string | null }>
```

<TypeTable
type={{
limit: { type: 'number', description: "페이지 크기로, 최대 500입니다." },
cursor: { type: 'string', description: "이전 페이지의 nextCursor입니다." },
}}
/>

```ts
const { locations: archived } = await client.locations.listArchived()
```

### get(id)
장소 하나를 읽어 옵니다. 아직 생성 중인 베리언트인 `pendingJobs`와, 이전 대표 이미지 후보를 최신순으로 최대 5개 담은 `previousCandidates`가 함께 반환됩니다. 그중 하나를 `approveMainImage()`로 승격하세요.

```ts
get(id: string): Promise<LocationDetail>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
}}
/>

```ts
const location = await client.locations.get(locationId)
console.log(location.sourceImageUrl, location.weather, location.atmosphereMotions)
```

보관된 장소도 ID로는 계속 반환되므로, 그 장소를 가리키는 워크플로 노드는 계속 불러와집니다. `Location`에는 `sourceImageUrl`의 대표 이미지, 컬렉션 6개(`timeOfDay`, `weather`, `seasons`, `angles`, `lighting`, `atmosphereMotions`이며 각각 `{ name, url }` 목록), `boards`, `referencePhotos`, `canonicalDescription`, `styleLock`, `updatedAt`이 있습니다.

### create(input)
장소를 만듭니다. `name`과 `nodeId`는 필수입니다. 캔버스 노드가 없는 스크립트는 `nodeId`에 `"mcp-managed"`를 전달할 수 있습니다.

```ts
create(input: CreateLocationInput): Promise<{ id: string }>
```

<TypeTable
type={{
nodeId: { type: 'string', required: true, description: "장소가 속한 캔버스 노드, 또는 mcp-managed입니다." },
name: { type: 'string', required: true, description: "이름입니다." },
description: { type: 'string', description: "자유 형식의 설명입니다." },
category: { type: 'string', description: "urban이나 nature 같은 카테고리입니다." },
style: { type: 'string', description: "realistic이나 anime 같은 시각 스타일입니다." },
projectId: { type: 'string', description: "장소를 넣을 프로젝트입니다." },
workflowId: { type: 'string', description: "장소가 만들어진 워크플로입니다." },
sourceImageUrl: { type: 'string', description: "대표 이미지 URL입니다." },
imageProvider: { type: 'string | null', description: "대표 이미지를 만든 이미지 모델입니다." },
referencePhotos: { type: 'LocationReferencePhoto[]', description: "무드 보드 사진으로 최대 20장이며, 각각 { url, kind } 형식입니다. kind는 wide, interior, exterior, detail, moodBoard, other 중 하나입니다." },
canonicalDescription: { type: 'string', description: "프롬프트에 쓰이는 설명입니다." },
styleLock: { type: 'boolean', description: "베리언트를 장소의 승인된 스타일로 유지합니다." },
}}
/>

```ts
const { id: locationId } = await client.locations.create({
nodeId: "mcp-managed",
name: "Rainy Tokyo Alley",
description: "Neon-soaked alley with vending machines",
category: "urban",
style: "realistic",
})
```

### update(id, input)
장소를 변경합니다. 보낸 필드만 기록됩니다. 작업하는 동안 생성 작업이 베리언트 컬렉션에 항목을 추가하므로, 베리언트 컬렉션은 이 호출에 포함되지 않습니다. 컬렉션에는 `generateAsset()`과 `removeAsset()`을 사용하세요.

```ts
update(id: string, input: UpdateLocationInput): Promise<{ id: string; updatedAt: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
name: { type: 'string', description: "이름입니다." },
description: { type: 'string', description: "자유 형식의 설명입니다." },
category: { type: 'string', description: "카테고리입니다." },
style: { type: 'string', description: "시각 스타일입니다." },
sourceImageUrl: { type: 'string', description: "대표 이미지 URL입니다." },
imageProvider: { type: 'string | null', description: "대표 이미지의 이미지 모델입니다." },
referencePhotos: { type: 'LocationReferencePhoto[]', description: "무드 보드 사진으로, 최대 20장입니다." },
canonicalDescription: { type: 'string', description: "프롬프트에 쓰이는 설명입니다." },
styleLock: { type: 'boolean', description: "베리언트를 승인된 스타일로 유지합니다." },
boards: { type: 'Array<{ name, url, type?, sourceImages? }>', description: "장소의 레퍼런스 보드입니다." },
selectedAssetByVariant: { type: 'Record<string, string>', description: "베리언트마다 선택한 테이크입니다." },
piiConsentAt: { type: 'string', description: "사용자가 레퍼런스 사진에 대한 권리가 있음을 확인한 시각을 기록하는 ISO 8601 시각입니다. referencePhotos를 처음 첨부할 때 설정하세요." },
expectedUpdatedAt: { type: 'string', description: "읽어 온 updatedAt 값입니다. 그 뒤로 장소가 바뀌었다면 업데이트는 409 concurrent_modification으로 실패합니다." },
}}
/>

```ts
await client.locations.update(locationId, {
canonicalDescription: "A narrow, rain-soaked alley lit by neon signs",
styleLock: false,
piiConsentAt: new Date().toISOString(),
expectedUpdatedAt: location.updatedAt,
})
```

`409 concurrent_modification`은 해당 `code`를 가진 일반 `NodaroError`로 전달됩니다. 장소를 다시 읽고 변경 사항을 병합한 뒤 다시 시도하세요.

### delete(id) 및 restore(id)
`delete()`는 장소를 보관하고, `restore()`는 다시 복원합니다. 영구 삭제는 Nodaro 앱에서만 할 수 있습니다. 복원한 이름이 대소문자 구분 없이 활성 장소의 이름과 같으면, 서버가 `(restored)`를 붙이고 실제로 사용한 이름을 반환합니다.

```ts
delete(id: string): Promise<{ success: true; archived: true }>
restore(id: string): Promise<{ id: string; name: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
}}
/>

```ts
await client.locations.delete(locationId)
const { name } = await client.locations.restore(locationId)
```

### generate(input)
설정 샷 후보를 생성합니다(`POST /v1/generate-location`). `count`가 1보다 크면 어떤 작업도 시작하기 전에 모든 작업을 먼저 예약하므로, 도중에 실패하면 일괄 처리 전체가 되돌려집니다.

```ts
generate(input: GenerateLocationInput): Promise<{ jobIds: string[]; jobId?: string }>
```

<TypeTable
type={{
name: { type: 'string', required: true, description: "장소의 이름입니다." },
description: { type: 'string', description: "장소가 어떻게 보이는지입니다." },
userPrompt: { type: 'string', description: "추가 지시 사항입니다." },
category: { type: '"indoor" | "outdoor" | "urban" | "nature" | "fantasy" | "sci-fi" | "historical" | "futuristic" | "other"', description: "카테고리입니다." },
style: { type: '"realistic" | "anime" | "3d-pixar" | "illustration"', description: "시각 스타일입니다." },
sourceImageUrl: { type: 'string', description: "샷의 바탕이 될 사진입니다." },
provider: { type: 'string', description: "이미지 모델 ID입니다." },
count: { type: 'number', description: "생성할 후보 수입니다." },
quality: { type: 'string', description: "품질 설정이 있는 모델에서 medium, high, basic 중 하나입니다. 가격이 달라집니다." },
resolution: { type: 'string', description: "지원하는 모델에서 1K, 2K, 4K, 0.5 MP, 1 MP, 2 MP, 4 MP 중 하나입니다. 가격이 달라집니다." },
attachToLocationId: { type: 'string', description: "단일 결과를 기록할 장소입니다." },
}}
/>

```ts
// One candidate, written to the location when it completes
const { jobIds: [jobId] } = await client.locations.generate({
name: "Rainy Tokyo Alley",
description: "Neon-soaked alley with vending machines",
attachToLocationId: locationId,
})

// Four candidates to choose from
const { jobIds } = await client.locations.generate({ name: "Rainy Tokyo Alley", count: 4 })
```

`attachToLocationId`를 지정하고 `count`가 1이면, 작업이 완료될 때 결과가 대표 이미지가 됩니다. 그렇지 않으면 `approveMainImage()`로 후보를 고르세요. `quality`와 `resolution`의 가격은 [이미지 생성](https://nodaro.ai/docs/nodes/image/generate-image)과 같습니다. 모델이 지원하지 않는 값은 무시됩니다.

### generateAsset(input)
장소의 베리언트를 하나 생성합니다(`POST /v1/generate-location-asset`). `attachToLocationId`, `attachToColumn`, `attachName`을 지정하면, 작업이 완료될 때 결과가 해당 컬렉션에 추가됩니다.

```ts
generateAsset(input: GenerateLocationAssetInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
assetType: { type: '"timeOfDay" | "weather" | "seasons" | "angles" | "lighting" | "custom"', required: true, description: "베리언트의 종류입니다." },
variant: { type: 'string', required: true, description: "골든 아워, 폭풍, 겨울, 항공 샷, 네온 같은 베리언트입니다." },
name: { type: 'string', required: true, description: "장소의 이름입니다." },
description: { type: 'string', description: "장소의 설명입니다." },
userPrompt: { type: 'string', description: "추가 지시 사항입니다." },
sourceImageUrl: { type: 'string', description: "시작할 이미지입니다. 보통 대표 이미지입니다." },
provider: { type: 'string', description: "이미지 모델 ID입니다." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "이미지의 화면 비율입니다." },
quality: { type: 'string', description: "generate()와 같습니다. 가격이 달라집니다." },
resolution: { type: 'string', description: "generate()와 같습니다. 가격이 달라집니다." },
attachToLocationId: { type: 'string', description: "결과를 추가할 장소입니다." },
attachToColumn: { type: 'string', description: "컬렉션입니다. time_of_day, weather, seasons, angles, lighting, atmosphere_motions, sheets, detail_closeups 중 하나입니다." },
attachName: { type: 'string', description: "새 항목의 이름입니다." },
}}
/>

```ts
const { jobId } = await client.locations.generateAsset({
name: "Rainy Tokyo Alley",
assetType: "weather",
variant: "storm",
attachToLocationId: locationId,
attachToColumn: "weather",
attachName: "storm",
})
```

### generateSurroundContinuation(input)
이전 뷰에서 이어지는 360도 링의 다음 뷰를 생성합니다(`POST /v1/generate-surround-continuation`). 서버는 이전 뷰의 절반을 그대로 유지하고 나머지 절반을 그린 뒤 색을 맞추므로, 이웃한 뷰가 이음새 없이 이어집니다. 이 메서드는 Nodaro Cloud에서 작동합니다. 다른 에디션은 처리를 시작하기 전에 `403 edition_required`로 응답하며, SDK는 이를 `ForbiddenError`로 발생시킵니다.

```ts
generateSurroundContinuation(input: GenerateSurroundContinuationInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
referenceImageUrl: { type: 'string', required: true, description: "링의 이전 뷰입니다." },
direction: { type: '"right" | "up" | "down"', required: true, description: "링이 이어지는 방향입니다." },
degrees: { type: 'number', description: "뷰가 회전하는 각도로, 예를 들면 45입니다." },
carriedFraction: { type: 'number', default: '0.5', description: "이전 뷰에서 바꾸지 않고 가져오는 비율입니다." },
userPrompt: { type: 'string', description: "추가 지시 사항입니다." },
provider: { type: 'string', description: "이미지 모델 ID입니다." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "이미지의 화면 비율입니다." },
attachToLocationId: { type: 'string', description: "뷰를 추가할 장소입니다." },
attachToColumn: { type: 'string', description: "컬렉션입니다. 스튜디오는 angles를 사용합니다." },
attachName: { type: 'string', description: "새 항목의 이름으로, 예를 들면 Surround 45°입니다." },
}}
/>

```ts
const { jobId } = await client.locations.generateSurroundContinuation({
referenceImageUrl: previousRingView,
direction: "right",
degrees: 45,
provider: "nano-banana-pro",
aspectRatio: "16:9",
attachToLocationId: locationId,
attachToColumn: "angles",
attachName: "Surround 45°",
})
```

### generateMotion(input)
[동영상 생성](https://nodaro.ai/docs/nodes/video/generate-video)의 이미지로 동영상 만들기 모드로 장소의 이미지를 분위기 클립으로 애니메이션화합니다(`POST /v1/generate-location-motion`). 장소에는 모션 컬렉션이 하나뿐이므로, 클립은 항상 `atmosphereMotions`에 들어가며 `attachToColumn`은 없습니다.

```ts
generateMotion(input: GenerateLocationMotionInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
motionPrompt: { type: 'string', required: true, description: "장면에서 무엇이 움직이는지입니다." },
sourceImageUrl: { type: 'string', required: true, description: "애니메이션화할 이미지로, 보통 대표 이미지입니다." },
name: { type: 'string', required: true, description: "장소의 이름입니다." },
provider: { type: 'string', description: "kling 같은 동영상 모델 ID입니다." },
category: { type: 'string', description: "카테고리입니다." },
style: { type: '"realistic" | "anime" | "3d-pixar" | "illustration"', description: "시각 스타일입니다." },
canonicalDescription: { type: 'string', description: "장소의 설명입니다." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "클립의 화면 비율입니다." },
attachToLocationId: { type: 'string', description: "클립을 추가할 장소입니다." },
attachName: { type: 'string', description: "새 클립의 이름입니다." },
}}
/>

```ts
const { jobId } = await client.locations.generateMotion({
name: "Rainy Tokyo Alley",
motionPrompt: "Slow dolly-in, neon signs flicker, light rain falling",
sourceImageUrl: location.sourceImageUrl!,
provider: "kling",
attachToLocationId: locationId,
attachName: "neon dolly-in",
})
```

### removeAsset(id, data)
베리언트 컬렉션에서 테이크 하나를 제거합니다(`POST /v1/locations/:id/remove-asset`). 그 `url`을 가진 항목은 모두 한 번에 제거됩니다. 예를 들어 360도 뷰를 다시 생성하기 전에 사용하세요.

```ts
removeAsset(id: string, data: { column: LocationAttachColumn; url: string }): Promise<{ removed: true }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
column: { type: 'LocationAttachColumn', required: true, description: "angles나 weather 같은 컬렉션입니다." },
url: { type: 'string', required: true, description: "제거할 테이크의 URL입니다." },
}}
/>

```ts
await client.locations.removeAsset(locationId, { column: "angles", url: oldViewUrl })
```

URL이 그 컬렉션에 없거나 내 장소가 아니면 `NotFoundError`를 발생시킵니다.

### approveMainImage(id, candidateJobId)
`generate()`로 만든 완료된 후보를 장소의 대표 이미지로 만듭니다. 그다음 비전 모델이 장소의 설명을 작성하며, 메서드는 둘 다 반환합니다.

```ts
approveMainImage(id: string, candidateJobId: string): Promise<{ sourceImageUrl: string; canonicalDescription: string | null }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
candidateJobId: { type: 'string', required: true, description: "완료된 후보의 작업 ID입니다." },
}}
/>

```ts
const { sourceImageUrl, canonicalDescription } = await client.locations.approveMainImage(locationId, jobIds[0])
```

설명을 작성하지 못하면 `canonicalDescription`은 `null`입니다. 그래도 대표 이미지는 설정되므로, `recaption()`을 호출해 다시 시도하세요.

### recaption(id)
현재 대표 이미지를 바탕으로 장소의 설명을 다시 작성합니다.

```ts
recaption(id: string): Promise<{ canonicalDescription: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "장소 ID입니다." },
}}
/>

```ts
const { canonicalDescription } = await client.locations.recaption(locationId)
```

장소에 대표 이미지가 없으면 `400 no_source_image`로 실패하고, 비전 모델이 실패하면 502로 실패합니다.

## 자주 묻는 질문

### Nodaro SDK로 장소를 만들려면 어떻게 하나요?

nodeId와 name을 넣어 client.locations.create를 호출하고, client.locations.generate로 설정 샷을 생성한 다음, client.locations.approveMainImage로 하나를 승인하세요.

### 장소에는 어떤 베리언트를 생성할 수 있나요?

generateAsset으로 시간대, 날씨, 계절, 앵글, 조명 베리언트와 사용자 지정 베리언트를 생성하고, generateMotion으로 분위기 클립을 생성할 수 있습니다. Nodaro Cloud에서는 generateSurroundContinuation으로 360도 뷰를 추가할 수 있습니다.

### 다른 사람이 바꾼 장소를 덮어쓰지 않으려면 어떻게 하나요?

읽어 온 updatedAt 값을 expectedUpdatedAt으로 client.locations.update에 전달하세요. 그사이 장소가 바뀌었다면 호출은 409 concurrent_modification으로 실패합니다. 장소를 다시 읽고 병합한 뒤 다시 시도하세요.

### SDK로 장소를 영구 삭제할 수 있나요?

아니요. delete는 장소를 보관하고, restore는 다시 복원합니다. 영구 삭제는 Nodaro 앱에서만 할 수 있습니다.
