Skip to content

[GPU] 영상 생성 Agent API 설계와 Python 클라이언트 구현 ​

개요 ​

이전 글에서 MiniMax-H3의 원시 추론 성능을 GPU 구성과 정밀도별로 튜닝했습니다. 그런데 추론 서버 하나만 떠 있다고 실제 애플리케이션에서 바로 쓸 수 있는 건 아닙니다. 작업을 접수하고, 상태를 조회하고, 완성된 영상을 내려받고, 필요하면 4K까지 해상도를 올리고, 로딩된 모델 모드를 바꾸는 절차가 전부 API 레벨에서 정리되어 있어야 합니다. 이번 글은 그 Agent API를 실제로 설계하고 붙여본 기록입니다.

SGLang이 직접 노출하는 추론 엔드포인트(포트 30010)와, 이 글에서 다루는 Agent API(포트 7002)는 다른 서비스입니다. 두 포트의 API를 혼용하면 안 됩니다. 이 구분을 먼저 짚고 시작합니다.

1. SGLang 직접 호출: 가장 단순한 경로 ​

먼저 SGLang에 직접 붙는 가장 단순한 형태부터 확인했습니다. 이 경로는 초해상도 후처리 없이 모델이 생성한 해상도(기본 짧은 변 768px) 그대로 결과를 돌려줍니다.

POST /v1/videos          작업 접수와 ID 반환
GET  /v1/videos/{id}     작업 상태
GET  /v1/videos/{id}/content   MP4 다운로드
GET  /health             추론 서비스 준비 상태

제출부터 다운로드까지 하나의 셸 스크립트로 흘려보면 이렇습니다.

bash
# 1) 작업을 제출하고 video_id 받기
video_id=$(curl -fsS -X POST http://127.0.0.1:30010/v1/videos \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "MiniMaxAI/MiniMax-H3",
    "prompt": "A vast canyon at sunrise, realistic documentary cinematography. The camera makes one slow stable aerial push forward. Natural wind ambience, synchronized stereo audio, no text, no cuts.",
    "seconds": 10,
    "task": "t2va",
    "conditions": ,
    "target": {"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 10.0},
    "num_outputs_per_prompt": 1,
    "num_inference_steps": 20,
    "flow_shift": 12.0,
    "audio_flow_shift": 3.0,
    "seed": 20260811
  }' | jq -r '.id')
echo "작업 접수: $video_id"

# 2) 완료 또는 실패까지 상태 조회
while true; do
  status=$(curl -fsS "http://127.0.0.1:30010/v1/videos/$video_id" | jq -r '.status')
  echo "상태: $status"
  [ "$status" = completed ] && break
  [ "$status" = failed ] && { echo "생성 실패"; exit 1; }
  sleep 10
done

# 3) 완성 영상 다운로드
curl -fsSL "http://127.0.0.1:30010/v1/videos/$video_id/content" -o output.mp4
echo "output.mp4 저장 완료"

여기서 바로 확인해야 할 점이 하나 있습니다. POST 성공은 생성 완료가 아니라 접수 완료입니다. 위 예제는 실패 처리는 있지만 총 대기시간 제한이 없는 형태라서, 운영 클라이언트에는 최대 대기시간과 네트워크 오류 처리를 반드시 추가해야 합니다.

macOS 기본 zsh를 쓴다면 주의할 게 있습니다. status는 zsh의 예약된 읽기 전용 변수라서 위 스크립트를 그대로 붙여 넣으면 충돌합니다. 전체를 파일로 저장해 bash script.sh로 실행하거나, 먼저 bash로 서브셸을 열고 실행하는 방식을 씁니다.

첫 프레임/마지막 프레임, 다중 참조 조건 ​

SGLang 경로는 task 값에 따라 조건 입력을 받습니다. fl2va는 첫 프레임과 마지막 프레임을 지정하는 방식입니다.

json
{
  "model": "MiniMaxAI/MiniMax-H3",
  "prompt": "...",
  "seconds": 5,
  "task": "fl2va",
  "conditions": [
    {"type": "image", "uri": "file:///data/minimax-h3/first.png",
     "role": "keyframe", "frame_index": 0}
  ],
  "target": {"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 5.0},
  "num_inference_steps": 20,
  "flow_shift": 12.0,
  "audio_flow_shift": 3.0,
  "seed": 20260811
}

여기서 file://는 API를 호출한 로컬 PC의 파일이 아니라 서버 프로세스가 읽는 파일입니다. 호스트의 /srv/minimax-h3/sglang/media/first.png가 컨테이너 안에서는 /data/minimax-h3/first.png로 보이는 마운트 구조라서, 경로를 헷갈리면 파일을 못 찾는 게 아니라 엉뚱한 파일을 읽는 사고로 이어질 수 있습니다. 첫 프레임은 frame_index: 0, 마지막 프레임은 -1이고, 두 장을 동시에 줄 수도 있습니다.

ref2va는 인물/동작/소리를 각각 다른 참조 파일에서 가져오는 다중 참조 방식입니다.

json
{
  "model": "MiniMaxAI/MiniMax-H3",
  "prompt": "Use <Picture 1> for subject identity, <Video 1> only for body motion, <Audio 1> for ambience.",
  "seconds": 5,
  "task": "ref2va",
  "conditions": [
    {"type": "image", "uri": "file:///data/minimax-h3/identity.png", "role": "reference"},
    {"type": "video", "uri": "file:///data/minimax-h3/motion.mp4",  "role": "reference"},
    {"type": "audio", "uri": "file:///data/minimax-h3/ambience.wav", "role": "reference"}
  ],
  "target": {"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 5.0},
  "num_inference_steps": 20,
  "flow_shift": 12.0,
  "audio_flow_shift": 3.0,
  "seed": 20260811
}

프롬프트 안의 <Picture 1>, <Video 1>, <Audio 1>이 각각 인물/동작/소리 중 무엇을 담당하는지 명시적으로 적어야 합니다. 참조 파일을 조건에 붙이는 것만으로 역할이 자동으로 구분되지는 않습니다.

입력 한도도 실측해서 확인했습니다.

항목값
이미지 / 영상 / 오디오 개수각각 최대 9 / 3 / 3개
총 참조 수12개 이하
참조 영상/오디오 길이각 2–15초, 총 참조 시간 15초 이하 기준 확인
오디오만 입력지원하지 않음. 이미지 또는 영상 기준점 필요

해상도 쪽에서도 함정이 하나 있습니다. 첫 프레임/마지막 프레임/참조가 있는 H3 경로에서는 1280×720을 피하고 1344×768 같은 지원 크기를 써야 합니다. 모델의 공간 압축 배수와 맞지 않으면 오류가 납니다. 832×480은 다른 워크플로에서는 쓸 수 있어도, 뒤에서 다룰 Agent는 short_edge=480을 받지 않습니다. 720p 납품이 필요하다면 지원 크기로 먼저 생성한 뒤 별도로 변환하는 편이 안전합니다. SGLang의 출력은 /srv/minimax-h3/sglang/output/에 쌓입니다.

2. 초해상도 처리: 768px 생성 뒤 MPS가 붙는다 ​

SGLang 직접 호출로는 짧은 변 768px가 기본 출력입니다. "고해상도로 달라"고 요청해도 H3가 처음부터 4K로 생성하는 게 아니라, 실제로는 다음 단계를 거칩니다.

  1. 입력 검증 (프롬프트/파일/모드 확인)
  2. H3/SGLang이 짧은 변 768px로 기본 생성
  3. MPS가 목표 규격에 맞춰 초해상도 처리
  4. 완성 결과 다운로드 URL 반환

요청한 short_edge 값에 따라 실제로 어떤 경로를 타는지도 구간별로 정리되어 있습니다.

short_edge 요청처리 방식확인할 결과
768H3 직접 생성, 후처리 없음기본 해상도 MP4
768 초과, 1440 미만768 생성 → 1080p 계열 후처리최종 resolution 확인
1440 이상, 2160 미만768 생성 → 2K 계열 후처리최종 resolution 확인
2160768 생성 → 4K 계열 후처리최종 resolution 확인
768 미만 또는 2160 초과요청 거부unsafe_resolution

1024처럼 구간 중간값을 요청할 수는 있지만, 후처리는 그 값에 딱 맞추는 게 아니라 구간별 규격 중 하나를 선택합니다. 그래서 요청값 echo와 실제 결과 해상도가 같다고 가정하면 안 되고, 응답의 resolution 필드와 실제 MP4를 직접 확인해야 합니다. 초해상도는 픽셀 수를 늘리는 과정이지, 실제 촬영처럼 모든 디테일을 복원한다고 보장하는 과정이 아닙니다.

초해상도 처리는 GPU 안에서 끝나지 않고 MPS라는 외부 처리와 데이터 이동을 포함하므로, MPS 접근 권한과 과금, 입력 업로드/결과 전달 경로가 미리 구성되어 있어야 합니다. 민감한 참조 파일이나 생성 영상을 외부로 전송해도 되는지도 미리 확인해야 할 부분입니다.

이 초해상도 기능을 포함해서 실제로 호출하는 창구가 바로 다음 절에서 다룰 Agent(http://127.0.0.1:7002)입니다. 먼저 h3-use fl2va로 SGLang을 준비하고, Agent의 /healthz와 /v1/partition으로 상태를 확인한 뒤 요청을 보냅니다.

bash
# 1) short_edge=1080으로 초해상도 처리 요청
video_id=$(curl -fsS -X POST http://127.0.0.1:7002/v1/videos \
  -H 'Content-Type: application/json' \
  -d '{
    "prompt": "A vast canyon at sunrise, realistic documentary cinematography. Slow stable aerial push forward.",
    "task": "t2va",
    "seconds": 5,
    "target": {"short_edge": 1080, "aspect_ratio": "16:9"}
  }' | jq -r '.id')
echo "작업 접수: $video_id"

# 2) 생성과 초해상도 처리 동안 running, 완료까지 상태 조회
while true; do
  resp=$(curl -fsS "http://127.0.0.1:7002/v1/videos/$video_id")
  status=$(echo "$resp" | jq -r '.status')
  echo "상태: $status"
  [ "$status" = completed ] && { echo "$resp" | jq '{content_url, resolution, expires_at}'; break; }
  [ "$status" = failed ] && { echo "실패: $resp"; break; }
  sleep 10
done

여기서 확인한 중요한 동작 하나: running 상태는 생성과 후처리를 모두 포함합니다. GPU 쪽 생성이 끝나도 MPS 후처리가 진행 중이면 상태는 여전히 running이고, 그동안 GPU는 다음 생성 작업을 처리할 수 있습니다. 후처리 시간은 영상 길이와 출력 크기에 따라 달라지고, CDN 결과 링크는 7일 안에 받아둬야 합니다. 작업과 로컬 결과의 기본 보관 기간도 7일입니다.

3. Agent API 전체 명세 ​

이제 SGLang이 아니라 이 Agent(7002)가 노출하는 API를 정리합니다.

메서드경로용도
POST/v1/videos영상 생성 접수 / 비멱등
GET/v1/videos/{id}상태/결과/echo 조회
GET/v1/videos조건별 목록과 커서 페이지
GET/v1/videos/{id}/contentMP4 또는 CDN 리다이렉트
DELETE/v1/videos/{id}대기 작업 취소 또는 종료 작업 삭제
GET/v1/partition현재 모델과 준비 상태
POST/v1/partition모델 변경 접수
GET/healthzAgent 및 의존 서비스 상태

요청은 UTF-8 JSON이고 기본 최대 본문 크기는 64MiB입니다. 선언되지 않은 필드를 넣으면 invalid_json으로 거부될 수 있습니다. 일반 응답은 JSON이고, content 다운로드만 video/mp4 바이너리 또는 302 리다이렉트입니다. *_at 계열 시간 필드는 전부 Unix 초 단위입니다. 오류 분기는 반드시 error.code로 판단해야 하고, 추적에는 request_id를 씁니다. message 문자열 일치에 의존하면 메시지 문구가 바뀔 때 그대로 깨집니다.

공통 오류 응답 형태는 이렇습니다.

json
{
  "error": {
    "code":    "invalid_task",
    "message": "task must be one of t2va, fl2va, ref2va, got \"foo\"",
    "param":   "task"
  },
  "request_id": "reqid-6c9f0..."
}

작업 상태 흐름은 queued → running → completed가 정상 경로이고, 예외 경로는 다음과 같습니다.

  • queued → canceled: 대기 작업만 취소 가능
  • queued / running → failed: 처리 실패 또는 제한시간 초과
  • running에서 DELETE 시도: 409 cannot_cancel

DELETE의 의미도 상태에 따라 달라집니다. queued 작업의 DELETE는 취소이고, completed/failed/canceled 작업의 DELETE는 기록과 로컬 결과/입력 파일을 실제로 삭제합니다. running은 취소할 수 없습니다. 만료된 작업은 404가 될 수 있으므로, 보관해야 할 결과는 만료 전에 받아둬야 합니다.

POST /v1/videos 요청 필드 ​

필드형식 / 기본값제약
promptstring / 필수앞뒤 공백 제거 후 비어 있지 않은 문자열
taskenum / 필수t2va / fl2va / ref2va
secondsinteger / 기본 54–15초. duration_seconds와 하나만 사용 권장
targetobjectshort_edge, aspect_ratio, duration_seconds
conditionsarraytask별 조건에 맞는 입력 파일
num_inference_stepsinteger / 기본 200보다 큰 정수. 50 이상은 처리 시간 경고
flow_shiftnumber / 기본 12.0영상 생성 흐름 설정
audio_flow_shiftnumber / 기본 3.0오디오 생성 흐름 설정
seedint64 / 생략 시 생성기본 자동 seed는 [0, 2^31) 범위, echo.seed로 반환
modelstringMiniMaxAI/MiniMax-H3
x_metadataJSON 값업무 메타데이터. 생성에는 사용하지 않고 저장만 함

target 내부 필드는 다음 기본값/허용값을 씁니다.

target 필드기본값허용값
short_edge768768 또는 768 초과~2160 이하
aspect_ratio16:921:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16
duration_seconds미지정정수 초. 0은 미지정 취급

seconds와 target.duration_seconds를 동시에 주면 값이 같아야 합니다. 다르면 conflicting_duration을 기준으로 처리하되, 실제 HTTP 상태 코드는 API 구현 버전에 따라 확인이 필요합니다.

입력 조건(conditions) 쪽 규칙도 task별로 다릅니다.

task입력 규칙
t2vaconditions를 생략하거나 빈 배열
fl2vaimage 1–2개, keyframe으로 첫 프레임 또는 마지막 프레임 지정
ref2va이미지 ≤9, 영상 ≤3, 오디오 ≤3, 총 ≤12, 오디오만으로 구성 불가

keyframe과 reference를 한 요청에 섞을 수 없습니다. 영상에 keyframe 역할을 주거나 reference에 frame_index를 주는 조합도 허용되지 않습니다. 파일 형식별 상한도 확인했습니다.

입력확장자크기 상한크기/시간 조건
이미지jpg / jpeg / png / webp / heic / heif30MiB각 변 256–5760px, 가로/세로 비 0.4–2.5. HEIC/HEIF는 PNG 변환
영상mp4 / mov50MiB2–15초, 23–60fps, 각 변 256–5760px
오디오wav / mp315MiB2–15초

파일 다운로드/디코딩/ffprobe 검증은 running 단계에서 비동기로 진행될 수 있습니다. 그래서 접수 응답이 200으로 왔더라도, 크기나 포맷이 규칙에 안 맞으면 나중에 작업이 failed로 끝날 수 있습니다.

상태 조회와 응답 예시 ​

GET /v1/videos/{id}는 다음 필드를 돌려줍니다.

필드의미
id / task / status작업 ID, 요청 유형, 현재 상태
created_at / started_at / completed_at접수 / 실행 시작 / 종료 Unix 시간
expires_at작업 또는 결과의 만료 시간
queue_positionqueued일 때 1부터 시작하는 대기 위치
content_url / resolutioncompleted일 때 다운로드 URL과 실제 결과 크기
errorfailed일 때 code와 message
warnings긴 길이/높은 step 수 등 비치명적 경고
echo기본값을 채운 적용 인수와 자동 생성된 seed

완료 응답 예시입니다.

json
{
  "id":           "vid_01M0CPRXFZW2CSSA1SVP6BVEQS",
  "status":       "completed",
  "task":         "t2va",
  "created_at":   1787132933,
  "started_at":   1787132934,
  "completed_at": 1787132937,
  "expires_at":   1787737733,
  "content_url":  "https://cdn.example.com/final/vid_....mp4",
  "resolution":   "1344x768",
  "echo":         { "...": "..." },
  "warnings":
}

여기서 중요한 점 하나: echo.target.short_edge는 요청 의도를 보존하는 값일 뿐이고, 실제 생성 크기와 다를 수 있습니다. 그리고 같은 seed를 재사용해도 엔진/정밀도/병렬 구성이 다르면 동일한 영상이 나온다는 보장은 없습니다.

목록 조회(GET /v1/videos)는 limit(기본 20), after(이전 응답의 next_cursor), order(desc 기본), task, status 파라미터를 받습니다. 목록 응답에는 echo가 포함되지 않고, has_more로 다음 페이지 여부를 판단합니다. 이 구현의 after는 생성 시각이 더 이른 작업을 기준으로 하므로 기본 desc 사용을 권장하며, status=running은 후처리 중인 작업까지 포함한다는 점도 확인했습니다.

다운로드(GET /v1/videos/{id}/content)는 후처리가 없는 로컬 결과라면 video/mp4와 Content-Disposition으로 직접 반환되고 Range를 지원합니다. 초해상도를 거친 결과는 302로 CDN URL로 리다이렉트될 수 있으므로 리다이렉트를 따라가야 합니다. 아직 완료되지 않았으면 409 content_not_ready, 작업이 없으면 404, 로컬 결과가 이미 사라졌으면 500이 나올 수 있습니다.

모델 전환: /v1/partition ​

MiniMax-H3는 fl2va/ref2va 중 하나의 모드로 로딩되어 있고, 이 로딩 상태를 확인/전환하는 게 /v1/partition입니다.

json
{
  "current": "fl2va",
  "ready":   true,
  "state":   "ready",
  "allowed": ["fl2va", "ref2va"]
}

전환 중이거나 GPU 점유가 충돌한 경우의 응답도 각각 다릅니다.

json
{"current": "ref2va", "ready": false, "state": "switching", "allowed": ["fl2va","ref2va"]}
{"current": "", "ready": false, "state": "conflicted", "allowed": ["fl2va","ref2va"]}

state는 ready / switching / conflicted / error 중 하나이고, 우선순위는 conflicted → switching → error → ready 순으로 판단해야 합니다. ready 필드 하나만 보지 말고, ready=true + current=원하는 모드 + state=ready 세 조건을 함께 확인해야 안전합니다.

모델을 바꾸는 요청은 이렇습니다.

bash
curl -fsS -X POST http://127.0.0.1:7002/v1/partition \
  -H 'Content-Type: application/json' -d '{"partition":"ref2va"}'
curl -fsS http://127.0.0.1:7002/v1/partition

전환을 새로 접수하면 202, 이미 준비된 상태면 200이 돌아옵니다. accepted 필드로 새 전환 작업이 실제로 시작됐는지 구분합니다. 이전 모드에 queued/running 작업이 남아 있으면 전환 자체가 거부될 수 있고, 전환 중에는 10~15초 간격으로 폴링하며 작업 제출을 기다려야 합니다. GPU를 점유하는 ComfyUI 같은 다른 프로세스가 있다면, 모델 전환이 그 작업에 영향을 주는지도 먼저 확인해야 합니다.

헬스체크와 오류 코드 ​

GET /healthz는 Agent 자체뿐 아니라 의존 서비스 상태까지 함께 돌려줍니다.

json
{
  "status": "ok",
  "dependencies": {
    "moderation": "ok",
    "database":   "ok",
    "partition":  "ok"
  },
  "queue":     {"queued": 2, "limit": 20},
  "partition": "fl2va"
}

moderation, database, partition 중 필요한 의존 서비스가 degraded면 전체 HTTP 상태가 503으로 떨어집니다. queue.queued와 queue.limit도 같이 모니터링해야 합니다.

오류 코드는 이 정도로 폭넓게 확인했습니다. 실무에서 자주 마주칠 것들만 추려서 정리합니다.

응답code확인할 것
400invalid_jsonJSON 문법, 선언되지 않은 필드, 목록 limit/status 확인
400missing_prompt공백이 아닌 prompt 필요
400invalid_taskt2va / fl2va / ref2va 확인
400conflicting_durationseconds와 duration_seconds 불일치 해결
400unsafe_resolution / invalid_aspect_ratio해상도와 화면 비율 확인
404not_foundID 또는 만료 여부 확인
409content_not_ready완료까지 기다림
409cannot_cancelrunning 취소 불가
409partition_switching / partition_has_pending_jobs전환 대기 또는 기존 작업 정리
429queue_fullRetry-After 60초. 제출 빈도 조정
503moderation_unavailable / partition_detect_failedRetry-After 값에 맞춰 재확인
500internal_errorrequest_id와 서버 로그 대조

미디어 진단 단계에서는 media_invalid, media_fetch_failed, media_too_large 같은 세부 원인이 보일 수 있지만, 최종 작업 오류는 generation_failed로 정규화될 수 있습니다. 그래서 외부 클라이언트는 안정적인 code를 기준으로 분기하고, 운영자는 request_id와 상세 로그를 기준으로 원인을 분석하는 역할 분담이 필요합니다.

4. Python 클라이언트 구현 ​

이 API를 표준 라이브러리만으로 감싼 클라이언트를 실제로 만들어서 끝까지 흘려봤습니다. 영상 생성 POST는 비멱등 요청이라는 전제가 중요합니다. 전송 후 응답을 받기 전에 연결이 끊기면 서버가 이미 접수를 완료했을 수 있으므로, 업무 요청 ID를 저장해두고 작업 기록을 대조한 다음에 재제출 여부를 결정해야 합니다. x_metadata는 연관 정보를 저장할 뿐, 그 자체로 중복 방지 키가 되지는 않습니다.

python
import json
import random
import time
import urllib.error
import urllib.request
from pathlib import Path

BASE = "http://127.0.0.1:7002"
OUTPUT = Path("cvd-output-1080p.mp4")
DEADLINE_SECONDS = 7200
deadline = time.monotonic + DEADLINE_SECONDS

def pause(seconds):
    remaining = deadline - time.monotonic
    if remaining <= 0:
        raise TimeoutError("Client deadline reached; the server job may still be running")
    time.sleep(min(seconds, remaining))

def api(method, path, payload=None):
    data = None if payload is None else json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        BASE + path, data=data, method=method,
        headers={"Content-Type": "application/json"},
    )
    while time.monotonic < deadline:
        try:
            with urllib.request.urlopen(req, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as exc:
            body = exc.read.decode("utf-8", errors="replace")
            # Only GET is automatically retried. Never blindly replay POST.
            if method == "GET" and exc.code in (429, 503):
                retry_after = exc.headers.get("Retry-After", "15")
                try:
                    delay = max(1, float(retry_after))
                except ValueError:
                    delay = 15
                pause(delay * random.uniform(1.0, 1.2))
                continue
            raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
        except (urllib.error.URLError, TimeoutError) as exc:
            if method != "GET":
                raise RuntimeError(
                    "Submission outcome unknown. Reconcile job records before retrying."
                ) from exc
            pause(15 * random.uniform(1.0, 1.2))
    raise TimeoutError("API polling deadline reached")

if OUTPUT.exists:
    raise FileExistsError(OUTPUT)

partition = api("GET", "/v1/partition")
if not (partition.get("ready") and partition.get("current") == "fl2va"
        and partition.get("state") == "ready"):
    raise RuntimeError(f"FL2VA is not ready: {partition}")

job = api("POST", "/v1/videos", {
    "prompt": "A vast canyon at sunrise, slow aerial push forward, natural wind ambience.",
    "task": "t2va",
    "seconds": 5,
    "target": {"short_edge": 1080, "aspect_ratio": "16:9"},
    "x_metadata": {"biz_id": "replace-with-your-unique-business-request-id"},
})
video_id = job.get("id")
if not isinstance(video_id, str) or not video_id:
    raise RuntimeError(f"Missing job ID: {job}")
print("Submitted:", video_id, flush=True)

while time.monotonic < deadline:
    job = api("GET", f"/v1/videos/{video_id}")
    status = job.get("status")
    print(video_id, status, flush=True)
    if status == "completed":
        break
    if status in ("failed", "canceled"):
        raise RuntimeError(f"Job ended: {job}")
    if status not in ("queued", "running"):
        raise RuntimeError(f"Unexpected status: {job}")
    pause(10 * random.uniform(1.0, 1.2))
else:
    raise TimeoutError(f"Polling timed out; inspect job {video_id} before resubmitting")

# urllib follows the Agent's CDN redirect. Stream the result instead of loading it in RAM.
req = urllib.request.Request(BASE + f"/v1/videos/{video_id}/content")
with urllib.request.urlopen(req, timeout=60) as response, OUTPUT.open("xb") as target:
    while True:
        if time.monotonic >= deadline:
            raise TimeoutError(f"Download deadline reached; partial file: {OUTPUT}")
        chunk = response.read(1024 * 1024)
        if not chunk:
            break
        target.write(chunk)

print("Saved:", OUTPUT, "resolution:", job.get("resolution"),
      "expires_at:", job.get("expires_at"))

이 클라이언트에서 짚어둘 설계 포인트가 몇 가지 있습니다.

  • GET만 자동 재시도합니다. POST는 비멱등이므로 429/503을 받아도 자동으로 다시 보내지 않고, 네트워크 오류(URLError/TimeoutError)가 나면 "제출 결과를 알 수 없다"는 예외를 던져서 호출자가 작업 기록부터 대조하도록 강제합니다.
  • 클라이언트 데드라인과 서버 작업은 별개입니다. 클라이언트 쪽 제한시간이 끝났다고 서버 작업이 취소되는 게 아니므로, video_id를 반드시 보관해뒀다가 나중에 다시 상태를 확인해야 합니다.
  • 다운로드는 스트리밍합니다. 응답 전체를 메모리에 올리지 않고 1MiB 청크로 끊어서 파일에 씁니다. 다운로드 중 실패하면 부분 파일이 남으므로, 파일을 검증한 뒤 필요하면 새 경로로 다시 받아야 합니다.
  • 운영 서비스라면 업무 DB에 요청 ID, video_id, 상태, 결과 보관 위치를 기록해두는 게 안전합니다.

5. 출력 파일 검증 ​

HTTP 200과 파일 확장자만으로 영상 생성이 성공했다고 판정하면 안 됩니다. 실제로 받은 파일을 ffprobe로 다시 확인했습니다.

bash
ffprobe -v error \
  -show_entries format=duration:stream=index,codec_type,codec_name,width,height,r_frame_rate \
  -of json cvd-output-1080p.mp4

영상 트랙 유무, 오디오 트랙 필요 여부, 실제 길이와 해상도까지 확인하고, 필요하면 샘플 프레임을 뽑아 육안으로도 검사해야 합니다. 모델이 생성한 콘텐츠 자체의 품질 검사는 API 호출 성공 여부와는 별개의 문제입니다.

정리 ​

  1. SGLang이 직접 노출하는 추론 API(30010)와 이 글에서 다룬 Agent API(7002)는 서로 다른 서비스입니다. Agent는 SGLang 생성 결과에 MPS 초해상도 후처리와 모델(partition) 전환까지 얹은 상위 계층입니다.
  2. 초해상도는 짧은 변 768px 생성을 전제로, 요청한 short_edge 구간(1440 미만/2160 미만/2160)에 따라 1080p/2K/4K 계열 후처리로 매핑됩니다. 요청값과 실제 결과 해상도가 다를 수 있으므로 응답의 resolution을 직접 확인해야 합니다.
  3. running 상태는 GPU 생성과 MPS 후처리를 모두 포함합니다. 후처리 중에도 GPU는 다음 작업을 받을 수 있습니다.
  4. POST /v1/videos는 비멱등 요청입니다. 네트워크 오류로 응답을 못 받았다고 곧바로 재제출하면 중복 작업이 생길 수 있으므로, 클라이언트는 요청 ID를 보관하고 기존 작업 기록과 대조한 뒤 재시도 여부를 정해야 합니다.
  5. /v1/partition의 상태는 ready 단일 필드가 아니라 ready/current/state 세 값을 함께 확인해야 정확합니다. 우선순위는 conflicted → switching → error → ready입니다.
  6. 오류 처리는 message 문자열이 아니라 error.code를 기준으로 분기해야 하고, 429/503 계열은 Retry-After 값을 그대로 활용하는 게 안전합니다.
  7. API 성공과 콘텐츠 품질은 별개입니다. 다운로드한 파일은 ffprobe로 스트림 구조와 길이/해상도를 재검증해야 합니다.

이어지는 실측은 다음 글에서 다룹니다. 이번 글에서 구현한 파이프라인을 기준으로, 구성별 처리 시간과 실제 비용을 측정합니다.

基于 VitePress 构建 · 部署于腾讯云 EdgeOne Pages