Claude Fable 5 is now on CometAPI — state-of-the-art performance in coding, agents, and scientific research. Try it now

SaaS 앱에 AI 영상 생성 기능 추가하기

CometAPI
AnnaJun 5, 2026
SaaS 앱에 AI 영상 생성 기능 추가하기

앱에 동영상 생성을 추가하는 것은 이미지 생성을 추가하는 것과는 다릅니다. API 호출은 즉시 반환되지만 — 동영상은 아직 준비되지 않습니다. 작업 ID를 받고, 완료될 때까지 “다 됐나요?”라고 계속 확인해야 합니다.

대부분의 개발자는 처음으로 비디오 API를 호출할 때 응답 본문에서 비디오 URL을 기대하지만, 대신 작업 ID를 받게 됩니다. 이 가이드는 전체 플로우를 안내합니다: 작업 제출, 결과 폴링, 실패 처리, 그리고 URL이 만료되기 전에 출력 저장까지.

이번에 구축할 것

텍스트 프롬프트 또는 이미지를 받아 비디오 생성 작업을 제출하고, 완료될 때까지 폴링한 후 최종 비디오 URL을 반환하는 백엔드 서비스. 하나의 API 키로 Veo 3 Fast, Sora 2, Kling Video, Runway — 네 가지 모델을 다룹니다.

사전 준비 사항:

  • Python 3.8+ 또는 Node.js 18+
  • CometAPI
  • REST API에 대한 기본 이해

동영상 생성이 다른 이유 이해하기

이미지 생성은 요청을 보내면 같은 응답에서 이미지를 바로 받습니다. 동영상 생성은 비동기 작업 큐를 사용합니다:

  1. Submit 생성 요청을 보냄 → task_id를 받음
  2. 몇 초 간격으로 상태 엔드포인트를 Poll
  3. 상태가 종료 상태에 도달하면 비디오 URL을 받음
  4. 비디오를 Download and store — URL은 일시적임

동영상 생성을 이미지 생성처럼 취급하고, 첫 응답에 비디오가 포함되길 기다리면 매번 요청이 타임아웃됩니다.

프로덕션 웹 서비스에서는 이 폴링 루프를 요청 처리기에서 돌리지 말고 백그라운드 워커(Celery, Bull 등)에서 실행해야 합니다. 아래 예제는 동기 폴링을 사용합니다 — 스크립트나 프로토타입에는 괜찮지만, 동시 사용자를 처리하기에는 적합하지 않습니다.

모델 선택

ModelProviderMax durationPrice (via CometAPI)Best for
Veo 3 FastGoogle8 sec$0.05/sec빠른 프로토타이핑, 소셜 클립
Sora 2OpenAI (via CometAPI model ID)~10 sec$0.08/sec고품질 크리에이티브 쇼츠
Kling VideoKuaishou10 sec$0.13–$2.64/task마케팅 콘텐츠, 세밀한 제어
Runway Gen-3A TurboRunway5 or 10 sec$0.32/task이미지 투 비디오, 커머셜 콘텐츠

Source*: CometAPI model pages, May 2026. Note: "Sora 2" is CometAPI's model* identifier — refer to their model page for the underlying model details.

  • Veo 3 Fast는 텍스트 투 비디오와 이미지 투 비디오를 모두 지원합니다. 초당 비용이 가장 낮아 시작점으로 적합합니다.
  • Sora 2는 비디오와 함께 오디오를 네이티브로 생성합니다 — 별도의 TTS 없이 대사, 앰비언트 사운드, 효과음을 포함합니다.
  • Kling Videonegative_prompt, cfg_scale, 카메라 무브먼트 설정, pro 모드를 제공합니다. 네 가지 중 제어가 가장 세밀합니다.
  • Runway는 CometAPI를 통한 이미지 투 비디오 전용입니다. 정지 이미지를 주고 모션 설명을 추가하면 이를 애니메이션화합니다.

Veo 작업 제출

Veo는 multipart/form-data를 사용합니다. Python requests에서는 올바르게 전송하려면 files=를 사용하세요 — data=dictapplication/x-www-form-urlencoded로 전송되며, 이는 동일하지 않습니다:

import requestsimport osfrom dotenv import load_dotenv​load_dotenv()​def submit_veo_task(prompt: str, size: str = "16x9") -> str:    """Submit a Veo 3 Fast text-to-video task. Returns task_id."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    response = requests.post(        "https://api.cometapi.com/v1/videos",        headers={"Authorization": f"Bearer {api_key}"},        files={            "prompt": (None, prompt),            "model": (None, "veo3-fast"),            "size": (None, size)        },        timeout=30    )    response.raise_for_status()    return response.json()["id"]​​task_id = submit_veo_task("A paper kite drifting above a wheat field on a windy afternoon")print(f"Task submitted: {task_id}")

결과 폴링

import time​def poll_veo_task(task_id: str, interval: int = 10, max_wait: int = 600) -> str:    """Poll until Veo task completes. Returns video URL."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    headers = {"Authorization": f"Bearer {api_key}"}    url = f"https://api.cometapi.com/v1/videos/{task_id}"    elapsed = 0​    while elapsed < max_wait:        response = requests.get(url, headers=headers, timeout=30)        response.raise_for_status()        result = response.json()        status = result.get("status")​        if status == "succeeded":            return result["output"][0]        elif status in ("failed", "cancelled"):            raise RuntimeError(                f"Task {task_id} failed with status '{status}': "                f"{result.get('error', 'no error detail returned')}"            )​        time.sleep(interval)        elapsed += interval​    raise TimeoutError(f"Task {task_id} did not complete within {max_wait} seconds")​​video_url = poll_veo_task(task_id)print(f"Video ready: {video_url}")

더 많은 제어를 위해 Kling Video 사용

Kling은 다른 엔드포인트 구조를 사용하며 JSON을 사용합니다. Kling의 종료 상태 문자열은 "succeed"입니다(표준 "succeeded"가 아님) — 이는 API의 실제 응답 형식을 반영합니다:

def submit_kling_task(prompt: str, duration: str = "5", mode: str = "std") -> str:    """Submit a Kling text-to-video task. Returns task_id."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    response = requests.post(        "https://api.cometapi.com/kling/v1/videos/text2video",        headers={            "Authorization": f"Bearer {api_key}",            "Content-Type": "application/json"        },        json={            "model_name": "kling-v1-6",            "prompt": prompt,            "negative_prompt": "blurry, low quality, watermark",            "cfg_scale": 0.5,            "mode": mode,         # "std" or "pro"            "aspect_ratio": "16:9",            "duration": duration  # "5" or "10"        },        timeout=30    )    response.raise_for_status()    return response.json()["data"]["task_id"]​​def poll_kling_task(task_id: str, interval: int = 10, max_wait: int = 600) -> str:    """Poll Kling task until complete. Returns video URL."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    headers = {"Authorization": f"Bearer {api_key}"}    url = f"https://api.cometapi.com/kling/v1/videos/text2video/{task_id}"    elapsed = 0​    while elapsed < max_wait:        response = requests.get(url, headers=headers, timeout=30)        response.raise_for_status()        result = response.json()        status = result["data"]["task_status"]​        if status == "succeed":  # Kling uses "succeed", not "succeeded"            return result["data"]["task_result"]["videos"][0]["url"]        elif status == "failed":            error_detail = result.get("data", {}).get("task_result", "no detail")            raise RuntimeError(                f"Kling task {task_id} failed: {error_detail}"            )​        time.sleep(interval)        elapsed += interval​    raise TimeoutError(f"Kling task {task_id} timed out after {max_wait}s")

Source*:* CometAPI Kling Video docs

Runway로 정적 이미지에 애니메이션 적용

Runway는 이미지 투 비디오 전용입니다. 추가 헤더(X-Runway-Version)가 필요합니다:

def submit_runway_task(image_url: str, motion_prompt: str, duration: int = 5) -> str:    """Submit a Runway image-to-video task. Returns task_id."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    response = requests.post(        "https://api.cometapi.com/runwayml/v1/image_to_video",        headers={            "Authorization": f"Bearer {api_key}",            "X-Runway-Version": "2024-11-06",            "Content-Type": "application/json"        },        json={            "model": "gen3a_turbo",            "promptImage": image_url,  # must be a stable HTTPS URL            "promptText": motion_prompt,            "duration": duration,            "ratio": "1280:720",            "watermark": False        },        timeout=30    )    response.raise_for_status()    return response.json()["id"]​​def poll_runway_task(task_id: str, interval: int = 5, max_wait: int = 600) -> str:    """Poll Runway task. Returns video URL when done."""    api_key = os.getenv("COMETAPI_KEY")    if not api_key:        raise ValueError("COMETAPI_KEY environment variable is not set")​    headers = {        "Authorization": f"Bearer {api_key}",        "X-Runway-Version": "2024-11-06"    }    url = f"https://api.cometapi.com/runwayml/v1/tasks/{task_id}"    elapsed = 0​    while elapsed < max_wait:        response = requests.get(url, headers=headers, timeout=30)        response.raise_for_status()        result = response.json()        status = result.get("status")​        if status == "task_not_exist":            # CometAPI-specific: task is still initializing, retry after a few seconds            time.sleep(interval)            elapsed += interval            continue        elif status == "succeeded":            return result["output"][0]        elif status in ("failed", "cancelled"):            raise RuntimeError(f"Runway task {task_id} failed: {result.get('error', 'no detail')}")​        time.sleep(interval)        elapsed += interval​    raise TimeoutError(f"Runway task {task_id} timed out after {max_wait}s")

Source*:* CometAPI Runway docs

URL이 만료되기 전에 비디오 저장

생성 API의 비디오 URL은 일시적입니다. 즉시 파일을 다운로드하고, 제어 가능한 저장소에 보관하세요:

import requestsimport pathlib​def download_video(url: str, output_path: str) -> None:    """Download video from URL to local file using streaming."""    out = pathlib.Path(output_path)    if out.parent != pathlib.Path("."):        out.parent.mkdir(parents=True, exist_ok=True)​    with requests.get(url, stream=True, timeout=60) as r:        r.raise_for_status()        with open(out, "wb") as f:            for chunk in r.iter_content(chunk_size=8192):                f.write(chunk)    print(f"Saved to {output_path}")​​# Full flowtask_id = submit_veo_task("A timelapse of clouds moving over a city skyline")video_url = poll_veo_task(task_id)download_video(video_url, "output/city_timelapse.mp4")

프로덕션에서는 로컬 파일 쓰기 대신 S3, Cloudflare R2 등 원하는 스토리지로 업로드하세요. 스트리밍 패턴은 동일합니다 — 전체 비디오를 메모리에 올리지 말고 바이트 스트림을 바로 파이프하세요.

실패 처리

SymptomLikely causeFix
Task stuck in queued for 10+ min서버 부하 또는 모델 사용 불가다른 모델로 재시도
task_not_exist on first Runway poll작업이 아직 초기화 중5초 대기 후 재시도 — CometAPI의 문서화된 동작
failed with no error message프롬프트가 콘텐츠 필터에 걸림프롬프트를 바꿔 표현
Video URL returns 403다운로드 전에 URL이 만료됨URL을 받은 직후 바로 다운로드
Timeout after 10 min생성에 너무 오래 걸림max_wait 증가 또는 Veo 3 Fast로 전환
Kling returns "succeed" not "succeeded"Kling API의 비표준 상태 문자열정상입니다 — 위의 Kling 폴링 코드를 참고하세요

Source: CometAPI video generation docs

Node.js 버전

Node.js 18+에는 fetchFormData가 기본 포함됩니다. 이 예제는 네 가지 모델을 모두 다룹니다:

// Node.js 18+ — no extra packages needed​const API_KEY = process.env.COMETAPI_KEY;if (!API_KEY) throw new Error('COMETAPI_KEY is not set');​// --- Veo 3 Fast ---async function submitVeoTask(prompt, size = '16x9') {  const form = new FormData();  form.append('prompt', prompt);  form.append('model', 'veo3-fast');  form.append('size', size);​  const res = await fetch('https://api.cometapi.com/v1/videos', {    method: 'POST',    headers: { 'Authorization': `Bearer ${API_KEY}` },    body: form  });  if (!res.ok) throw new Error(`Veo submit failed: ${res.status}`);  return (await res.json()).id;}​async function pollVeoTask(taskId, intervalMs = 10000, maxWaitMs = 600000) {  let elapsed = 0;  while (elapsed < maxWaitMs) {    const res = await fetch(`https://api.cometapi.com/v1/videos/${taskId}`, {      headers: { 'Authorization': `Bearer ${API_KEY}` }    });    if (!res.ok) throw new Error(`Poll failed: ${res.status}`);    const result = await res.json();​    if (result.status === 'succeeded') return result.output[0];    if (['failed', 'cancelled'].includes(result.status)) {      throw new Error(`Task ${taskId} failed: ${result.error ?? 'no detail'}`);    }    await new Promise(r => setTimeout(r, intervalMs));    elapsed += intervalMs;  }  throw new Error(`Task ${taskId} timed out`);}​// --- Kling Video ---async function submitKlingTask(prompt, duration = '5', mode = 'std') {  const res = await fetch('https://api.cometapi.com/kling/v1/videos/text2video', {    method: 'POST',    headers: {      'Authorization': `Bearer ${API_KEY}`,      'Content-Type': 'application/json'    },    body: JSON.stringify({      model_name: 'kling-v1-6',      prompt,      negative_prompt: 'blurry, low quality, watermark',      cfg_scale: 0.5,      mode,      aspect_ratio: '16:9',      duration    })  });  if (!res.ok) throw new Error(`Kling submit failed: ${res.status}`);  return (await res.json()).data.task_id;}​async function pollKlingTask(taskId, intervalMs = 10000, maxWaitMs = 600000) {  let elapsed = 0;  while (elapsed < maxWaitMs) {    const res = await fetch(      `https://api.cometapi.com/kling/v1/videos/text2video/${taskId}`,      { headers: { 'Authorization': `Bearer ${API_KEY}` } }    );    if (!res.ok) throw new Error(`Kling poll failed: ${res.status}`);    const result = await res.json();    const status = result.data.task_status;​    if (status === 'succeed') return result.data.task_result.videos[0].url;    if (status === 'failed') {      throw new Error(`Kling task ${taskId} failed: ${JSON.stringify(result.data.task_result ?? 'no detail')}`);    }    await new Promise(r => setTimeout(r, intervalMs));    elapsed += intervalMs;  }  throw new Error(`Kling task ${taskId} timed out`);}​// --- Runway (image-to-video) ---async function submitRunwayTask(imageUrl, motionPrompt, duration = 5) {  const res = await fetch('https://api.cometapi.com/runwayml/v1/image_to_video', {    method: 'POST',    headers: {      'Authorization': `Bearer ${API_KEY}`,      'X-Runway-Version': '2024-11-06',      'Content-Type': 'application/json'    },    body: JSON.stringify({      model: 'gen3a_turbo',      promptImage: imageUrl,      promptText: motionPrompt,      duration,      ratio: '1280:720',      watermark: false    })  });  if (!res.ok) throw new Error(`Runway submit failed: ${res.status}`);  return (await res.json()).id;}​async function pollRunwayTask(taskId, intervalMs = 5000, maxWaitMs = 600000) {  let elapsed = 0;  while (elapsed < maxWaitMs) {    const res = await fetch(      `https://api.cometapi.com/runwayml/v1/tasks/${taskId}`,      { headers: { 'Authorization': `Bearer ${API_KEY}`, 'X-Runway-Version': '2024-11-06' } }    );    if (!res.ok) throw new Error(`Runway poll failed: ${res.status}`);    const result = await res.json();    const status = result.status;​    if (status === 'task_not_exist') {      // CometAPI-specific: task still initializing      await new Promise(r => setTimeout(r, intervalMs));      elapsed += intervalMs;      continue;    }    if (status === 'succeeded') return result.output[0];    if (['failed', 'cancelled'].includes(status)) {      throw new Error(`Runway task ${taskId} failed: ${result.error ?? 'no detail'}`);    }    await new Promise(r => setTimeout(r, intervalMs));    elapsed += intervalMs;  }  throw new Error(`Runway task ${taskId} timed out`);}​// Usage exampleconst taskId = await submitVeoTask('A paper kite drifting above a wheat field');const videoUrl = await pollVeoTask(taskId);console.log('Video ready:', videoUrl);

다음 단계

네 가지 비디오 모델에 대한 동작하는 코드, 실패를 처리하는 폴링 루프, 그리고 생성된 콘텐츠를 잃지 않도록 하는 다운로드 단계까지 준비되었습니다.

대부분의 개발자가 다음에 겪는 문제는 하나의 모델을 하드코딩해 두었기 때문에 더 저렴하거나 빠른 옵션으로 전환하려면 여러 파일을 수정해야 한다는 점입니다. 다음 글에서는 코드를 다시 쓰지 않고 모델 간에 요청을 라우팅하는 방법을 다룹니다.

Next: 코드를 다시 쓰지 않고 AI 모델을 전환하는 방법

FAQ

Q: 왜 API 응답에서 비디오 대신 작업 ID를 받나요?

동영상 생성은 비동기입니다 — Veo, Sora, Kling, Runway 같은 모델은 렌더링에 2–5분이 걸릴 수 있습니다. 요청이 타임아웃되지 않도록 API는 즉시 작업 ID를 반환합니다. 작업이 종료 상태(succeeded, succeed, failed)에 도달할 때까지 별도의 상태 엔드포인트를 폴링합니다.

Q: 생성된 비디오 URL은 얼마나 오래 유효한가요?

생성 API의 비디오 URL은 일시적입니다. URL을 받은 직후 파일을 다운로드해, 직접 관리하는 스토리지(S3, Cloudflare R2 등)에 저장하세요. URL만 저장해 두고 몇 시간 후에도 동작하리라 기대하지 마세요.

Q: Veo 3 Fast와 Kling Video의 차이는 무엇인가요?

Veo 3 Fast는 더 저렴하고($0.05/sec), 더 빠르며 호출이 간단합니다. Kling Video는 negative_prompt, cfg_scale, 카메라 무브먼트 설정, pro 품질 모드 등 더 많은 제어를 제공합니다. 출력을 미세 조정해야 한다면 Kling을, 속도와 비용이 중요하다면 Veo 3 Fast를 사용하세요.

Q: 텍스트 프롬프트 대신 이미지로 비디오를 생성할 수 있나요?

가능합니다. Veo는 input_reference 파일을 전달해 이미지 투 비디오를 지원합니다. Kling은 image 파라미터(URL 또는 base64)를 사용하는 /kling/v1/videos/image2video 엔드포인트로 지원합니다. Runway는 이미지 투 비디오 전용이며 — CometAPI를 통해 텍스트 전용 프롬프트는 받지 않습니다.

Q: 왜 Runway는 첫 폴링에서 task_not_exist 를 반환하나요?

문서화된 CometAPI 동작입니다 — 백엔드에서 작업이 아직 초기화 중입니다. 몇 초 기다린 뒤 재시도하세요. 오류가 아닙니다. 위의 폴링 코드는 이를 자동으로 처리합니다.

Q: 왜 Kling은 "succeed" 를 사용하고 "succeeded" 는 사용하지 않나요?

이는 Kling의 실제 API 응답 형식입니다. 오타가 아닙니다. Veo와 Runway는 "succeeded"를 사용하고 — Kling은 "succeed"를 사용합니다. 통합 폴링 래퍼를 만든다면 두 문자열을 모두 처리해야 합니다.

Q: 동기 폴링 루프를 웹 서버에서 사용해도 안전한가요?

아니요. 이 가이드의 폴링 루프는 수 분 동안 스레드를 블로킹합니다. 동시 사용자가 있는 실제 웹 서비스에서는 폴링을 백그라운드 워커(Celery for Python, Bull for Node.js)에서 실행하세요. 요청 처리기에서 작업을 제출하고, 클라이언트에게 작업 ID를 반환한 뒤, 워커가 비디오 준비 완료 시 클라이언트에 알리도록 하세요.

AI 개발 비용을 20% 절감할 준비가 되셨나요?

몇 분 안에 무료로 시작하세요. 무료 체험 크레딧 제공. 신용카드 불필요.

더 보기