[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 추론 서비스 준비 상태제출부터 다운로드까지 하나의 셸 스크립트로 흘려보면 이렇습니다.
# 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는 첫 프레임과 마지막 프레임을 지정하는 방식입니다.
{
"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는 인물/동작/소리를 각각 다른 참조 파일에서 가져오는 다중 참조 방식입니다.
{
"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로 생성하는 게 아니라, 실제로는 다음 단계를 거칩니다.
- 입력 검증 (프롬프트/파일/모드 확인)
- H3/SGLang이 짧은 변 768px로 기본 생성
- MPS가 목표 규격에 맞춰 초해상도 처리
- 완성 결과 다운로드 URL 반환
요청한 short_edge 값에 따라 실제로 어떤 경로를 타는지도 구간별로 정리되어 있습니다.
| short_edge 요청 | 처리 방식 | 확인할 결과 |
|---|---|---|
| 768 | H3 직접 생성, 후처리 없음 | 기본 해상도 MP4 |
| 768 초과, 1440 미만 | 768 생성 → 1080p 계열 후처리 | 최종 resolution 확인 |
| 1440 이상, 2160 미만 | 768 생성 → 2K 계열 후처리 | 최종 resolution 확인 |
| 2160 | 768 생성 → 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으로 상태를 확인한 뒤 요청을 보냅니다.
# 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}/content | MP4 또는 CDN 리다이렉트 |
| DELETE | /v1/videos/{id} | 대기 작업 취소 또는 종료 작업 삭제 |
| GET | /v1/partition | 현재 모델과 준비 상태 |
| POST | /v1/partition | 모델 변경 접수 |
| GET | /healthz | Agent 및 의존 서비스 상태 |
요청은 UTF-8 JSON이고 기본 최대 본문 크기는 64MiB입니다. 선언되지 않은 필드를 넣으면 invalid_json으로 거부될 수 있습니다. 일반 응답은 JSON이고, content 다운로드만 video/mp4 바이너리 또는 302 리다이렉트입니다. *_at 계열 시간 필드는 전부 Unix 초 단위입니다. 오류 분기는 반드시 error.code로 판단해야 하고, 추적에는 request_id를 씁니다. message 문자열 일치에 의존하면 메시지 문구가 바뀔 때 그대로 깨집니다.
공통 오류 응답 형태는 이렇습니다.
{
"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 요청 필드
| 필드 | 형식 / 기본값 | 제약 |
|---|---|---|
prompt | string / 필수 | 앞뒤 공백 제거 후 비어 있지 않은 문자열 |
task | enum / 필수 | t2va / fl2va / ref2va |
seconds | integer / 기본 5 | 4–15초. duration_seconds와 하나만 사용 권장 |
target | object | short_edge, aspect_ratio, duration_seconds |
conditions | array | task별 조건에 맞는 입력 파일 |
num_inference_steps | integer / 기본 20 | 0보다 큰 정수. 50 이상은 처리 시간 경고 |
flow_shift | number / 기본 12.0 | 영상 생성 흐름 설정 |
audio_flow_shift | number / 기본 3.0 | 오디오 생성 흐름 설정 |
seed | int64 / 생략 시 생성 | 기본 자동 seed는 [0, 2^31) 범위, echo.seed로 반환 |
model | string | MiniMaxAI/MiniMax-H3 |
x_metadata | JSON 값 | 업무 메타데이터. 생성에는 사용하지 않고 저장만 함 |
target 내부 필드는 다음 기본값/허용값을 씁니다.
| target 필드 | 기본값 | 허용값 |
|---|---|---|
short_edge | 768 | 768 또는 768 초과~2160 이하 |
aspect_ratio | 16:9 | 21: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 | 입력 규칙 |
|---|---|
t2va | conditions를 생략하거나 빈 배열 |
fl2va | image 1–2개, keyframe으로 첫 프레임 또는 마지막 프레임 지정 |
ref2va | 이미지 ≤9, 영상 ≤3, 오디오 ≤3, 총 ≤12, 오디오만으로 구성 불가 |
keyframe과 reference를 한 요청에 섞을 수 없습니다. 영상에 keyframe 역할을 주거나 reference에 frame_index를 주는 조합도 허용되지 않습니다. 파일 형식별 상한도 확인했습니다.
| 입력 | 확장자 | 크기 상한 | 크기/시간 조건 |
|---|---|---|---|
| 이미지 | jpg / jpeg / png / webp / heic / heif | 30MiB | 각 변 256–5760px, 가로/세로 비 0.4–2.5. HEIC/HEIF는 PNG 변환 |
| 영상 | mp4 / mov | 50MiB | 2–15초, 23–60fps, 각 변 256–5760px |
| 오디오 | wav / mp3 | 15MiB | 2–15초 |
파일 다운로드/디코딩/ffprobe 검증은 running 단계에서 비동기로 진행될 수 있습니다. 그래서 접수 응답이 200으로 왔더라도, 크기나 포맷이 규칙에 안 맞으면 나중에 작업이 failed로 끝날 수 있습니다.
상태 조회와 응답 예시
GET /v1/videos/{id}는 다음 필드를 돌려줍니다.
| 필드 | 의미 |
|---|---|
id / task / status | 작업 ID, 요청 유형, 현재 상태 |
created_at / started_at / completed_at | 접수 / 실행 시작 / 종료 Unix 시간 |
expires_at | 작업 또는 결과의 만료 시간 |
queue_position | queued일 때 1부터 시작하는 대기 위치 |
content_url / resolution | completed일 때 다운로드 URL과 실제 결과 크기 |
error | failed일 때 code와 message |
warnings | 긴 길이/높은 step 수 등 비치명적 경고 |
echo | 기본값을 채운 적용 인수와 자동 생성된 seed |
완료 응답 예시입니다.
{
"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입니다.
{
"current": "fl2va",
"ready": true,
"state": "ready",
"allowed": ["fl2va", "ref2va"]
}전환 중이거나 GPU 점유가 충돌한 경우의 응답도 각각 다릅니다.
{"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 세 조건을 함께 확인해야 안전합니다.
모델을 바꾸는 요청은 이렇습니다.
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 자체뿐 아니라 의존 서비스 상태까지 함께 돌려줍니다.
{
"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 | 확인할 것 |
|---|---|---|
| 400 | invalid_json | JSON 문법, 선언되지 않은 필드, 목록 limit/status 확인 |
| 400 | missing_prompt | 공백이 아닌 prompt 필요 |
| 400 | invalid_task | t2va / fl2va / ref2va 확인 |
| 400 | conflicting_duration | seconds와 duration_seconds 불일치 해결 |
| 400 | unsafe_resolution / invalid_aspect_ratio | 해상도와 화면 비율 확인 |
| 404 | not_found | ID 또는 만료 여부 확인 |
| 409 | content_not_ready | 완료까지 기다림 |
| 409 | cannot_cancel | running 취소 불가 |
| 409 | partition_switching / partition_has_pending_jobs | 전환 대기 또는 기존 작업 정리 |
| 429 | queue_full | Retry-After 60초. 제출 빈도 조정 |
| 503 | moderation_unavailable / partition_detect_failed | Retry-After 값에 맞춰 재확인 |
| 500 | internal_error | request_id와 서버 로그 대조 |
미디어 진단 단계에서는 media_invalid, media_fetch_failed, media_too_large 같은 세부 원인이 보일 수 있지만, 최종 작업 오류는 generation_failed로 정규화될 수 있습니다. 그래서 외부 클라이언트는 안정적인 code를 기준으로 분기하고, 운영자는 request_id와 상세 로그를 기준으로 원인을 분석하는 역할 분담이 필요합니다.
4. Python 클라이언트 구현
이 API를 표준 라이브러리만으로 감싼 클라이언트를 실제로 만들어서 끝까지 흘려봤습니다. 영상 생성 POST는 비멱등 요청이라는 전제가 중요합니다. 전송 후 응답을 받기 전에 연결이 끊기면 서버가 이미 접수를 완료했을 수 있으므로, 업무 요청 ID를 저장해두고 작업 기록을 대조한 다음에 재제출 여부를 결정해야 합니다. x_metadata는 연관 정보를 저장할 뿐, 그 자체로 중복 방지 키가 되지는 않습니다.
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로 다시 확인했습니다.
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 호출 성공 여부와는 별개의 문제입니다.
정리
- SGLang이 직접 노출하는 추론 API(30010)와 이 글에서 다룬 Agent API(7002)는 서로 다른 서비스입니다. Agent는 SGLang 생성 결과에 MPS 초해상도 후처리와 모델(
partition) 전환까지 얹은 상위 계층입니다. - 초해상도는 짧은 변 768px 생성을 전제로, 요청한
short_edge구간(1440 미만/2160 미만/2160)에 따라 1080p/2K/4K 계열 후처리로 매핑됩니다. 요청값과 실제 결과 해상도가 다를 수 있으므로 응답의resolution을 직접 확인해야 합니다. running상태는 GPU 생성과 MPS 후처리를 모두 포함합니다. 후처리 중에도 GPU는 다음 작업을 받을 수 있습니다.- POST /v1/videos는 비멱등 요청입니다. 네트워크 오류로 응답을 못 받았다고 곧바로 재제출하면 중복 작업이 생길 수 있으므로, 클라이언트는 요청 ID를 보관하고 기존 작업 기록과 대조한 뒤 재시도 여부를 정해야 합니다.
/v1/partition의 상태는ready단일 필드가 아니라ready/current/state세 값을 함께 확인해야 정확합니다. 우선순위는conflicted → switching → error → ready입니다.- 오류 처리는
message문자열이 아니라error.code를 기준으로 분기해야 하고, 429/503 계열은Retry-After값을 그대로 활용하는 게 안전합니다. - API 성공과 콘텐츠 품질은 별개입니다. 다운로드한 파일은
ffprobe로 스트림 구조와 길이/해상도를 재검증해야 합니다.
이어지는 실측은 다음 글에서 다룹니다. 이번 글에서 구현한 파이프라인을 기준으로, 구성별 처리 시간과 실제 비용을 측정합니다.