본문으로 건너뛰기
버전: 최신 버전

미리 제작한 프로젝트를 변경하여 동영상으로 내보내기

제공하는 API만을 이용해서 동영상으로 내보내는 것은 현실적으로 불가능합니다. 따라서 프로젝트 에디터를 이용해 미리 프로젝트를 만들어 두고, API를 이용해 해당 프로젝트를 조회 및 변경하여 동영상으로 내보내는 것이 좋습니다.

이 가이드에서는 미리 제작한 프로젝트의 일부를 변경하여 동영상으로 내보내는 과정에 대해 설명합니다.

시나리오
  1. 프로젝트 에디터를 이용하여 프로젝트 제작
  2. API를 이용하여 프로젝트 조회
  3. 조회된 프로젝트를 변경
  4. API를 이용하여 프로젝트를 동영상으로 내보내기
  5. API를 이용하여 완료 여부 체크

1. 프로젝트 제작하기

이번 가이드에서는 프로젝트를 미리 작성하고 이것을 수정하여 동영상을 내보내기 합니다. 프로젝트에 미리 작성할 내용은 다음과 같습니다.

정보
  • 두 개의 씬으로 이루어짐
  • 각 씬의 화면에는 이미지와 텍스트 요소가 삽입되어 있음
  • 각 씬에는 아바타의 대사가 입력되어 있음

또한 이 프로젝트의 내용을 변경할 때 변경해야 할 이미지, 텍스트 요소를 찾기 쉽게 하기 위해 각 요소에 태그를 붙여놓습니다.

1.1. 씬 작성

첫번째 씬두번째 씬
First SceneSecond Scene

두 개의 씬으로 이루어진 프로젝트를 작성합니다. 각 씬의 화면에는 이미지와 텍스트 요소가 삽입되어 있습니다. 각 씬에는 아바타의 대사가 입력되어 있으며 특히 두번째 씬에는 두 아바타의 대화가 입력되어 있습니다.

1.2. 태그 붙이기

Tagging

변경할 요소에는 찾기 쉽도록 태그를 붙여 놓습니다. 이번 가이드에서는 이미지에 parrot-image, 텍스트에 parrot-text 태그를 붙여 놓았습니다.


2. API 키 설정

AI STUDIO V3 내 모든 API 통신 시에는 인증이 필요합니다. 이를 위해 사용되는 것이 API 키입니다. token 변수에 발급받은 API 키를 설정합니다. 아직 발급받은 키가 없다면 API 키 발급하기에서 발급하실 수 있습니다.

const token = '## API key ##'

3. 프로젝트 ID 설정

projectId 변수에 기존에 생성해둔 프로젝트 ID를 설정합니다. 프로젝트 ID는 내 스튜디오 페이지에서 기존에 저장해둔 프로젝트를 클릭하여 편집 화면으로 진입 시 URL을 통해 확인할 수 있습니다. 예를 들어 app.aistudios.com/editor/abcdefg 에서 프로젝트 ID는 abcdefg가 됩니다.

Project Id

const projectId = '## project id ##'

4. 프로젝트 가져오기 API 요청

프로젝트 가져오기 API를 이용하여 변경하여 동영상으로 내보낼 프로젝트를 조회합니다. 2. API 키 설정 단계와 3. 프로젝트 ID 설정 단계에서 설정한 API 키와 프로젝트 아이디를 이용하여 프로젝트를 조회합니다.

리퀘스트 포맷

항목
Endpoint/api/odin/v3/editor/project/<project id>
MethodGET

예시 코드

const API_HOST = 'https://app.aistudios.com'
const GET_PROJECT_API_PATH = '/api/odin/v3/editor/project'

const project = await fetch(
`${API_HOST}${GET_PROJECT_API_PATH}/${projectId}`,
{
method: 'GET',
headers: {
Authorization: token,
},
},
)
.then((response) => response.json())
.then((response) => {
if (response.success == true) {
return response.data.project
}
})

API 공통 팁

API 사용 권한 인증

API 사용 권한의 인증을 위해 리퀘스트 헤더에 API 키를 입력해야 합니다. 리퀘스트 헤더의 Authorization 항목의 값으로 2. API 키 설정 단계에서 설정한 API 키를 지정해야 합니다. 대부분의 API는 사용 권한 인증이 필수이므로 API 키를 누락하지 않아야 합니다.

fetch('## endpoint ##', {
headers: {
Authorization: token,
},
})
리스폰스 포맷

대부분의 API의 리스폰스 바디는 동일한 포맷으로 구성되어 있습니다. 크게 성공/실패 여부와 결과 데이터로 되어 있습니다. 성공/실패 여부는 boolean, 결과 데이터는 API 별로 다를 수 있습니다.

항목타입설명
successboolean결과의 성공/실패 여부
- 성공: true
- 실패: false
dataany결과 데이터
const responseBody: {
success: boolean
data: any
} = await fetch('## endpoint ##')
.then((response) => response.json())

5. 프로젝트 데이터 수정

조회된 프로젝트 데이터에는 프로젝트를 구성하는 대부분의 정보가 포함되어 있습니다. 프로젝트 데이터에 대한 대략적인 구조 설명과 몇가지 요소를 변경하는 과정을 설명하겠습니다.

프로젝트 데이터 구조

project의 1레벨 필드들은 주로 프로젝트 전체에 영향을 주는 항목들로 구성되어 있습니다. 프로젝트의 이름, 화면의 방향, 씬 리스트 등으로 이루어져 있습니다.

project 필드

항목타입설명
project.namestring프로젝트의 이름
project.orientation'landscape' | 'portrait'화면의 방향
1920x1080을 기준으로 방향에 따라 가로/세로가 바뀝니다.
project.scenesscene[] (json[])각 씬의 화면 구성, 아바타의 대사

project.scenes에는 씬의 갯수만큼 scene 데이터가 배열 형태로 들어 있습니다. scene은 화면의 구성 요소들과 아바타의 대사로 이루어져 있습니다. 화면의 구성 요소들은 scene.clips에 아바타의 대사들은 scene.scripts에 각각 배열 형태로 들어 있습니다.

화면의 구성 요소는 clip이라는 데이터 구조를 가지고 있습니다. clip의 종류에 따라 구성하고 있는 필드가 다릅니다. 다음은 clip의 공통 또는 주요 필드들에 대한 설명입니다.

clip 필드

항목타입설명
clip.type'image' | 'textImage' | ...clip의 종류
clip.leftnumberclipleft
(단위: px)
clip.topnumbercliptop
(단위: px)
clip.widthnumberclipwidth
(단위: px)
clip.heightnumberclipheight
(단위: px)
clip.scaleXnumberclip의 가로 스케일
이 값과 clip.width를 이용해 실제 너비가 계산됨
(단위: 1을 기준으로 하는 비율 값. e.g. 1.5)
clip.scaleYnumberclip의 세로 스케일
이 값과 clip.width를 이용해 실제 높이가 계산됨
(단위: 1을 기준으로 하는 비율 값. e.g. 1.5)
clip.tagstring | undefined에디터에서 사용자가 지정한 태그
노트

clip의 종류별 상세한 포맷은 Clip 속성 문서를 참고해 주세요.

아바타의 대사는 script라는 데이터 구조를 가지고 있습니다. script를 구성하고 있는 필드는 아바타나 보이스의 종류 등 다양한 조건에 따라 달라집니다. 이번 가이드에서는 일반적인 상황에서 입력된 대사만 수정하는 간단한 작업만 진행합니다.

단일 아바타의 일반적인 경우에는 scriptsscript가 하나만 존재하지만 아바타 간의 대화형인 경우 대화의 수만큼 script가 존재합니다.

script 필드

항목타입설명
script.orgstring아바타의 대사
<p />, <span /> 태그 등을 허용하지만 plain text만 입력하는 것을 권장

5.1. 이미지 교체

첫번째 씬의 이미지를 다른 이미지로 교체합니다. 1.2. 태그 붙이기 단계에서 미리 지정한 태그를 이용해 교체할 이미지를 찾고, 새로운 이미지로 교체합니다.

첫번째 씬 찾기

4. 프로젝트 가져오기 API 요청 단계에서 조회한 project 에서 첫번째 씬을 찾습니다.

const firstScene = project.scenes[0]

이미지 클립 찾기

첫번째 씬에서 교체할 이미지 클립을 찾습니다. 1.2. 태그 붙이기 단계에서 parrot-image라는 태그를 붙여두었습니다. 태그로 parrot-image가 입력된 클립을 찾습니다.

const imageTag = 'parrot-image'
const imageClip = firstScene.clips.find((clip) => clip.tag === imageTag)

이미지 교체하기

이미지 클립은 공통 필드 이외에 이미지 용의 추가 필드를 갖습니다.

이미지 clip 추가 필드
항목타입설명
source_urlstring이미지 주소 (URL)

해당 필드에 교체할 이미지의 주소를 입력합니다.

const replacingImageUrl = '## replacing image url ##'
imageClip.resource_url = replacingImageUrl

이전 이미지의 크기와 새로운 이미지의 크기가 같다면 이미지 주소만 교체하면 됩니다. 하지만 크기가 다르다면 위치, 크기 등의 속성을 알맞게 지정하는 것도 고려해야 합니다.

imageClip.left = 15
imageClip.width = 15
imageClip.scaleX = 1.5
주의

교체할 이미지는 공개된 위치에 존재해야 하고 외부에서의 접근이 가능해야 합니다. 동영상 내보내기 중 이미지에 접근하지 못 하는 경우에는 오류가 발생하거나 결과 동영상에 이미지가 표시되지 않을 수 있습니다.

  • 공개 URL을 가지는지 확인
  • 외부 네트워크에서 접근이 가능한지 확인
  • 접근 권한 등에 의해 차단되지 않는지 확인

5.2. 텍스트 교체

첫번째 씬의 텍스트를 다른 텍스트로 교체합니다.

텍스트 클립 찾기

첫번째 씬에서 교체할 텍스트 클립을 찾습니다. parrot-text가 입력된 클립을 찾습니다.

const textTag = 'parrot-text'
const textClip = firstScene.clips.find((clip) => clip.tag === textTag)

텍스트 교체하기

텍스트 클립은 공통 필드 외에 텍스트 용의 추가 필드를 갖습니다.

텍스트 clip 추가 필드
항목타입설명
textstring입력된 텍스트
fontSizenumber폰트 크기
(단위: pt)

해당 필드에 교체할 텍스트를 입력합니다.

const replacingTextSentence = '## replacing text sentence ##'
textClip.text = replacingTextSentence

교체할 텍스트의 길이가 다르다면 위치, 크기 등의 속성을 적절하게 다시 지정하는 것도 고려해야 합니다.
또한 fontSize도 변경할 수 있습니다. fontSize의 단위는 pt입니다. 하지만 프로젝트의 공통 단위는 px로 주의가 필요합니다. ptpx는 3 : 4 비율입니다.

const changingFontSizePx = 10
const changingFontSizePt = changingFontSizePx * 3 / 4
textClip.fontSize = changingFontSizePt

5.3. 스크립트 교체

첫번째 씬의 대사와 두번째 씬의 두번째 대사를 변경합니다. 대사는 각 씬에 배열로 입력되어 있습니다. 아바타가 하나인 내레이션 유형의 대사인 경우에도 대사는 배열로 입력되어 있으며 이 때는 배열의 아이템이 하나만 존재합니다. 아바타가 둘인 대화 유형의 대사인 경우에는 대화의 수만큼 존재합니다.

첫번째 씬 대사 교체

const replacingFirstSceneScript = '## replacing first scene script ##'
const firstSceneScript = firstScene.scripts[0]
firstSceneScript.org = replacingFirstSceneScript

두번째 씬의 두번째 대사 교체

const secondScene = project.scenes[1]

const replacingSecondSceneScript = '## replacing second scene script ##'
const secondSceneScript = secondScene.scripts[1]
// ^ second script
secondSceneScript.org = replacingSecondSceneScript

6. 프로젝트 내보내기 API 요청

수정한 씬 데이터를 프로젝트 내보내기 API에 요청합니다. '내보내기'란 영상을 생성하기 위한 합성 요청을 의미하며, 프로젝트 내보내기 API 요청 시 보낼 수 있는 전체 데이터 종류는 여기에서 자세히 확인하실 수 있습니다.

리퀘스트 포맷

항목
Endpoint/api/odin/v3/editor/project
MethodPOST
BodyPartial<project> (json)

예시 코드

const GENERATE_PROJECT_API_PATH = '/api/odin/v3/editor/project'

const stringifiedProject = JSON.stringify({
name: project.name,
orientation: project.orientation,
scenes: project.scenes,
})
const generatedProjectId = await fetch(
`${API_HOST}${GENERATE_PROJECT_API_PATH}`
{
method: 'POST',
headers: {
Authorization: token,
'Content-Type': 'application/json',
},
body: stringifiedProject,
},
)
.then((response) => response.json())
.then((response) => {
if (response.success === true) {
return response.data.projectId
}
})

7. 프로젝트 진행률 확인 및 완료

동영상을 내보내는 과정에는 시간이 소요됩니다. 6. 프로젝트 내보내기 API 요청 단계는 내보내기를 시작하는 기능입니다. 내보내기 과정의 진행도를 확인하여 완료 여부를 판단해야 합니다.

이번 가이드에서는 1분에 한 번 씩 진행도를 확인하여 완료 여부를 확인합니다.

리퀘스트 포맷

항목
Endpoint/api/odin/v3/editor/progress/<project id>
MethodGET

예시 코드

const GET_PROGRESS_API_PATH = '/api/odin/v3/editor/progress'

const delay = async (ms = 1000 * 60) => {
await new Promise(r => setTimeout(r, ms))
}

let isFinished = false
let videoUrl = ''

while (!isFinished) {
const progressData = await fetch(
`${API_HOST}${GET_PROGRESS_API_PATH}/${generatedProjectId}`,
{
method: 'GET',
headers: {
Authorization: token,
},
},
)
.then((response) => response.json())
.then((response) => {
if (response.success === true) {
return response.data
}
})

if (progressData.progress < 100) {
await delay()
}
else {
videoUrl = progressData.downloadUrl
isFinished = true
}
}

전체 코드

const API_HOST = 'https://app.aistudios.com'
const GET_PROJECT_API_PATH = '/api/odin/v3/editor/project'
const GENERATE_PROJECT_API_PATH = '/api/odin/v3/editor/project'
const GET_PROGRESS_API_PATH = '/api/odin/v3/editor/progress'

const token = '## API key ##'
const projectId = '## project id ##'
const replacingImageUrl = '## replacing image url ##'
const replacingTextSentence = '## replacing text sentence ##'
const replacingFirstSceneScript = '## replacing first scene script ##'
const replacingSecondSceneScript = '## replacing second scene script ##'

const delay = async (ms = 1000 * 60) => {
await new Promise(r => setTimeout(r, ms))
}

const main = async () => {
const project = await fetch(
`${API_HOST}${GET_PROJECT_API_PATH}/${projectId}`,
{
method: 'GET',
headers: {
Authorization: token,
},
},
)
.then((response) => response.json())
.then((response) => {
if (response.success == true) {
return response.data.project
}
})

const imageTag = 'parrot-image'
const imageClip = firstScene.clips.find((clip) => clip.tag === imageTag)

imageClip.resource_url = replacingImageUrl

const textTag = 'parrot-text'
const textClip = firstScene.clips.find((clip) => clip.tag === textTag)

textClip.text = replacingTextSentence

const firstSceneScript = firstScene.scripts[0]
firstSceneScript.org = replacingFirstSceneScript

const secondScene = project.scenes[1]

const secondSceneScript = secondScene.scripts[1]
secondSceneScript.org = replacingSecondSceneScript

const stringifiedProject = JSON.stringify({
name: project.name,
orientation: project.orientation,
scenes: project.scenes,
})
const generatedProjectId = await fetch(
`${API_HOST}${GENERATE_PROJECT_API_PATH}`
{
method: 'POST',
headers: {
Authorization: token,
'Content-Type': 'application/json',
},
body: stringifiedProject,
},
)
.then((response) => response.json())
.then((response) => {
if (response.success === true) {
return response.data.projectId
}
})

let isFinished = false
let videoUrl = ''

while (!isFinished) {
const progressData = await fetch(
`${API_HOST}${GET_PROGRESS_API_PATH}/${generatedProjectId}`,
{
method: 'GET',
headers: {
Authorization: token,
},
},
)
.then((response) => response.json())
.then((response) => {
if (response.success === true) {
return response.data
}
})

if (progressData.progress < 100) {
await delay()
}
else {
videoUrl = progressData.downloadUrl
isFinished = true
}
}

console.log(videoUrl)
}

main()