Claude Opus 5 is now live on CometAPI →

Cách thêm tính năng tạo video bằng AI vào ứng dụng SaaS

CometAPI
AnnaJun 5, 2026
Cách thêm tính năng tạo video bằng AI vào ứng dụng SaaS

Việc thêm khả năng tạo video vào ứng dụng không giống với thêm khả năng tạo ảnh. Lời gọi API trả về ngay — nhưng video thì chưa sẵn sàng. Bạn nhận được một task ID và phải tiếp tục hỏi “xong chưa?” cho đến khi hoàn tất.

Hầu hết nhà phát triển gặp tình huống này lần đầu khi gọi API video, chờ phần thân phản hồi chứa URL video, và nhận về task ID thay vào đó. Hướng dẫn này đi qua toàn bộ luồng: gửi tác vụ, thăm dò kết quả, xử lý lỗi, và lưu trữ đầu ra trước khi URL hết hạn.

Bạn sẽ xây dựng gì

Một dịch vụ backend nhận prompt văn bản hoặc ảnh, gửi tác vụ tạo video, thăm dò đến khi hoàn tất, và trả về URL video cuối cùng. Bạn sẽ làm việc với bốn model — Veo 3 Fast, Sora 2, Kling Video và Runway — tất cả dùng chung một API key.

Điều kiện tiên quyết:

  • Python 3.8+ hoặc Node.js 18+
  • Khóa CometAPI
  • Hiểu biết cơ bản về REST API

Hiểu vì sao tạo video khác biệt

Với tạo ảnh, bạn gửi yêu cầu và nhận ảnh trong cùng một phản hồi. Tạo video dùng hàng đợi tác vụ bất đồng bộ:

  1. Gửi yêu cầu tạo → nhận lại task_id
  2. Thăm dò endpoint trạng thái mỗi vài giây
  3. Khi trạng thái đạt trạng thái kết thúc, bạn nhận URL video
  4. Tải xuống và lưu trữ video — URL chỉ tạm thời

Nếu bạn xử lý tạo video như tạo ảnh và chờ phản hồi đầu tiên chứa video, yêu cầu của bạn sẽ luôn bị timeout.

Trong dịch vụ web production, vòng thăm dò này nên chạy trong worker nền (Celery, Bull, hoặc tương tự), không phải trong request handler. Ví dụ dưới đây dùng thăm dò đồng bộ — phù hợp cho script và nguyên mẫu, nhưng không dành cho xử lý người dùng đồng thời.

Chọn model

ModelNhà cung cấpThời lượng tối đaGiá (qua CometAPI)Phù hợp nhất cho
Veo 3 FastGoogle8 sec$0.05/secDựng mẫu nhanh, clip mạng xã hội
Sora 2OpenAI (qua ID model CometAPI)~10 sec$0.08/secShort sáng tạo chất lượng cao
Kling VideoKuaishou10 sec$0.13–$2.64/taskNội dung marketing, kiểm soát chi tiết
Runway Gen-3A TurboRunway5 hoặc 10 sec$0.32/taskImage-to-video, nội dung thương mại

Nguồn*:* Trang model CometAPI, tháng 5/2026. Lưu ý: “Sora 2” là* định danh model của CometAPI — tham khảo trang model để biết chi tiết về model nền tảng.

  • Veo 3 Fast hỗ trợ cả text-to-video và image-to-video. Rẻ nhất theo giây, điểm khởi đầu tốt.
  • Sora 2 tạo âm thanh nội tại cùng với video — hội thoại, âm thanh nền và hiệu ứng mà không cần bước TTS riêng.
  • Kling Video cung cấp negative_prompt, cfg_scale, thiết lập chuyển động camera và chế độ pro. Kiểm soát nhiều nhất trong bốn model.
  • Runway chỉ hỗ trợ image-to-video qua CometAPI. Cung cấp một ảnh tĩnh và mô tả chuyển động, nó sẽ tạo hoạt ảnh.

Gửi tác vụ Veo

Veo dùng multipart/form-data. Hãy dùng files= trong Python requests để gửi đúng — data=dict sẽ gửi application/x-www-form-urlencoded, không phải cùng một thứ:

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}")

Thăm dò kết quả

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}")

Dùng Kling Video để có nhiều quyền kiểm soát hơn

Kling có cấu trúc endpoint khác và dùng JSON. Lưu ý rằng trạng thái kết thúc của Kling là "succeed" (không phải "succeeded") — đúng với định dạng phản hồi của 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")

Nguồn*:* Tài liệu Kling Video của CometAPI

Tạo chuyển động cho ảnh tĩnh bằng Runway

Runway chỉ hỗ trợ image-to-video. Nó cũng yêu cầu một header bổ sung (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")

Nguồn*:* Tài liệu Runway của CometAPI

Lưu video trước khi URL hết hạn

URL video từ các API tạo nội dung chỉ tạm thời. Hãy tải tệp về ngay và lưu ở nơi bạn kiểm soát:

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")

Trong môi trường production, thay ghi tệp cục bộ bằng tải lên S3, Cloudflare R2, hoặc dịch vụ lưu trữ bạn chọn. Mẫu streaming giữ nguyên — chuyển bytes trực tiếp thay vì tải toàn bộ video vào bộ nhớ.

Xử lý lỗi

Triệu chứngNguyên nhân có thểCách khắc phục
Tác vụ kẹt ở queued trên 10 phútTải máy chủ cao hoặc model không sẵn sàngThử lại với model khác
task_not_exist khi thăm dò Runway lần đầuTác vụ vẫn đang khởi tạoChờ 5 giây và thử lại — hành vi được CometAPI ghi nhận
failed mà không có thông báo lỗiPrompt kích hoạt bộ lọc nội dungDiễn đạt lại prompt
URL video trả về 403URL hết hạn trước khi tảiTải ngay sau khi nhận được URL
Timeout sau 10 phútQuá trình tạo mất quá lâuTăng max_wait hoặc chuyển sang Veo 3 Fast
Kling trả về "succeed" không phải "succeeded"API của Kling dùng chuỗi trạng thái không chuẩnĐiều này đúng — xem mã thăm dò Kling ở trên

Nguồn: Tài liệu tạo video CometAPI

Phiên bản Node.js

Node.js 18+ tích hợp sẵn fetchFormData. Ví dụ này bao phủ cả bốn model:

// 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);

Tiếp theo là gì

Giờ bạn đã có mã hoạt động cho bốn model video, một vòng thăm dò xử lý lỗi, và bước tải xuống giúp bạn không mất nội dung đã tạo.

Vấn đề tiếp theo nhiều nhà phát triển gặp phải: họ đã hardcode một model, và việc chuyển sang tùy chọn rẻ hơn hoặc nhanh hơn đòi hỏi sửa nhiều tệp. Bài viết tiếp theo sẽ hướng dẫn cách định tuyến yêu cầu qua nhiều model mà không phải viết lại mã.

Tiếp theo: Cách Chuyển Đổi Giữa Các Model AI Mà Không Cần Viết Lại Mã

Câu hỏi thường gặp

Hỏi: Tại sao tôi nhận được task ID thay vì video trong phản hồi API?

Tạo video là bất đồng bộ — các model như Veo, Sora, Kling, và Runway mất 2–5 phút để render. API trả về task ID ngay để tránh timeout. Bạn thăm dò endpoint trạng thái cho đến khi tác vụ đạt trạng thái kết thúc (succeeded, succeed, failed).

Hỏi: URL video được tạo có hiệu lực trong bao lâu?

URL video từ các API tạo nội dung chỉ tạm thời. Hãy tải tệp về ngay sau khi nhận URL và lưu vào kho của bạn (S3, Cloudflare R2, v.v.). Đừng lưu URL và kỳ vọng nó hoạt động sau vài giờ.

Hỏi: Khác biệt giữa Veo 3 Fast và Kling Video là gì?

Veo 3 Fast rẻ hơn ($0.05/sec), nhanh hơn và gọi đơn giản hơn. Kling Video cho phép bạn kiểm soát nhiều hơn: negative_prompt, cfg_scale, thiết lập chuyển động camera và chế độ chất lượng pro. Nếu bạn cần tinh chỉnh đầu ra, hãy dùng Kling. Nếu bạn cần tốc độ và chi phí thấp, hãy dùng Veo 3 Fast.

Hỏi: Tôi có thể tạo video từ ảnh thay vì prompt văn bản không?

Có. Veo hỗ trợ image-to-video bằng cách truyền một tệp input_reference. Kling hỗ trợ qua endpoint /kling/v1/videos/image2video với tham số image (URL hoặc base64). Runway chỉ hỗ trợ image-to-video — không chấp nhận prompt chỉ văn bản qua CometAPI.

Hỏi: Tại sao Runway trả về task_not_exist ở lần thăm dò đầu tiên?

Đây là hành vi được CometAPI ghi nhận — tác vụ vẫn đang khởi tạo ở backend. Chờ vài giây và thử lại. Đây không phải lỗi. Mã thăm dò ở trên đã xử lý tự động.

Hỏi: Tại sao Kling dùng "succeed" thay vì "succeeded"?

Đó là định dạng phản hồi thực tế của API Kling. Không phải lỗi chính tả. Veo và Runway dùng "succeeded" — Kling dùng "succeed". Nếu bạn xây dựng wrapper thăm dò thống nhất, bạn cần xử lý cả hai chuỗi.

Hỏi: Vòng thăm dò đồng bộ có an toàn để dùng trong web server không?

Không. Vòng thăm dò trong hướng dẫn này chặn luồng trong vài phút. Trong dịch vụ web thực tế với người dùng đồng thời, hãy chạy thăm dò trong worker nền (Celery cho Python, Bull cho Node.js). Gửi tác vụ trong request handler, trả task ID cho client, và để worker thông báo client khi video sẵn sàng.

Sẵn sàng giảm 20% chi phí phát triển AI?

Bắt đầu miễn phí trong vài phút. Bao gồm tín dụng dùng thử miễn phí. Không cần thẻ tín dụng.

Đọc thêm