영상 색인
색인 생성은 영상을 장면으로 나누고 캡션, 트랜스크립트, 임베딩, 브랜드 세이프티 평가를 만듭니다. 어떤 제출 경로든 video_id와 job_id를 바로 반환하고, 파이프라인(장면 감지 → 캡션 생성 → 임베딩 → 검수)은 비동기로 실행됩니다. 작업을 폴링한 뒤 결과를 읽으세요.
모든 예제는 https://video.pyler.ai와 $VUF_API_KEY에 담긴 API 키를 사용합니다. 키 형식은 인증을 참고하세요.
제출 경로 고르기
| 가진 것 | 호출 | 레퍼런스 |
|---|---|---|
| 공개 URL 하나 | POST /api/v1/videos | 영상 제출 |
| 로컬 파일 하나 | POST /api/v1/videos/upload | 파일 업로드 |
| 파싱한 URL 목록(최대 500개) | kind: "url"로 POST /api/v1/videos/batch | 배치 생성 |
| 로컬 파일 여러 개 | POST /api/v1/videos/batch/upload | 배치 업로드 |
URL이 담긴 .csv 또는 .xlsx | POST /api/v1/videos/batch/upload-sheet | 시트 업로드 |
| MRSS 피드 | 대시보드에서 등록한 뒤 /api/v1/mrss-feeds로 관리 | MRSS 피드 |
어떤 경로를 쓰든 video_id를 보관하세요. video_id는 영상이 존재하는 동안 바뀌지 않지만, job_id는 재색인할 때마다 새로 바뀝니다.
URL 하나 제출하기
POST /api/v1/videos는 공개적으로 가져올 수 있는 http(s) URL과 선택 항목인 title을 받습니다(title이 없으면 URL의 마지막 경로 세그먼트를 씁니다). 202 Accepted를 반환합니다.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/clip.mp4", "title": "demo clip"}'
import os, requests
BASE = "https://video.pyler.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"}
r = requests.post(f"{BASE}/videos", headers=HEADERS,
json={"url": "https://example.com/clip.mp4", "title": "demo clip"})
r.raise_for_status()
submit = r.json()
video_id, job_id = submit["video_id"], submit["job_id"]
{
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"created": true
}
아직 색인 중인 URL을 다시 제출해도 파이프라인이 하나 더 시작되지 않습니다. 기존 video_id와 job_id가 created: false와 함께 똑같이 202로 돌아옵니다. 따라서 카탈로그 전체를 다시 제출해도 안전합니다. 실제로 새로 만들어진 영상 수는 true 값을 세어 확인하세요.
파일 업로드하기
POST /api/v1/videos/upload는 로컬 파일(mp4, mov 등)을 위한 같은 비동기 흐름입니다. file 필드와 선택 항목인 title을 담아 multipart/form-data로 보내세요.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/upload \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@clip.mp4" \
-F "title=demo clip"
with open("clip.mp4", "rb") as f:
r = requests.post(f"{BASE}/videos/upload", headers=HEADERS,
files={"file": f}, data={"title": "demo clip"})
r.raise_for_status()
upload = r.json() # video_id, job_id, storage_path
202 응답 본문에는 video_id, job_id, storage_path(업로드가 저장된 내부 객체 키)가 담깁니다. 파일이 많다면 파일마다 이 호출을 반복하거나 배치 업로드를 쓰세요.
작업 폴링하기
GET /api/v1/jobs/{job_id}는 현재 파이프라인 상태를 반환합니다. status는 pending → running → done 또는 failed 순으로 바뀌며, 실행 중에는 done_scenes / total_scenes로 진행률을 알 수 있습니다.
- cURL
- Python
while true; do
status=$(curl -s https://video.pyler.ai/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $VUF_API_KEY" | jq -r .status)
echo " $status"
[ "$status" = "done" ] && break
[ "$status" = "failed" ] && { echo "job failed"; exit 1; }
sleep 5
done
import time
while True:
job = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS).json()
print(job["status"], job.get("done_scenes", 0), "/", job.get("total_scenes", 0))
if job["status"] in ("done", "failed"):
break
time.sleep(5)
if job["status"] == "failed":
raise RuntimeError(f"{job['reason_code']}: {job['reason']}")
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"status": "running",
"total_scenes": 42,
"done_scenes": 18,
"error": null,
"reason_code": null,
"reason": null,
"started_at": "2026-10-01T10:23:01Z",
"completed_at": null
}
failed일 때는 error를 파싱하지 말고 안정적인 원인 값인 reason_code(예: source_forbidden, url_unreachable, media_unreadable, processing_stalled, internal_error)로 분기하세요. 완료된 작업에도 이전 시도의 error 텍스트가 남아 있을 수 있습니다. 영상의 status가 failed이면 GET /api/v1/videos/{video_id}에도 같은 reason_code가 나옵니다.
폴링하고 싶지 않다면 GET /api/v1/videos/{video_id}/scenes/stream을 쓰세요. 장면마다 캡션이 준비되는 즉시 보내 주고, 마지막에 done 줄로 끝납니다. 아래 따라 하기를 참고하세요.
따라 하기: 영상을 색인하고 장면 읽기
- 영상 제출curl -s -X POST https://video.pyler.ai/api/v1/videos \-H "Authorization: Bearer $VUF_API_KEY" \-H "Content-Type: application/json" \-d '{"url": "https://example.com/clip.mp4", "title": "demo clip"}'# {"video_id": "3f6c...", "job_id": "a1b2...", "created": true}export VIDEO_ID=3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90export JOB_ID=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
- 작업 완료 기다리기
status가done이 될 때까지GET /api/v1/jobs/$JOB_ID를 폴링하세요(위 반복문 참고).failed이면reason_code를 확인하세요. - 색인 결과 읽기
GET /api/v1/videos/{video_id}는 영상 전체 결과를 반환합니다. AIsummary,genre, IAB 카테고리(iab_content_leaf,iab_audience_interest_leaf,iab_audience_purchase_intent_leaf,iab_ad_product_leaf),garm_video의 GARM 평가,transcript_coverage,scenes의 첫 페이지, 모든 장면에 대한 가벼운scene_markers,aggregations(브랜드, 제품, 인물, 장소, 오디오 이벤트, 음악 분위기, 화면 속 텍스트, 분위기 타임라인, 행동, 인간 활동, 날씨)가 담깁니다.curl -s "https://video.pyler.ai/api/v1/videos/$VIDEO_ID?scene_limit=100" \-H "Authorization: Bearer $VUF_API_KEY"Response (200, truncated){"id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90","title": "demo clip","duration_ms": 61234,"source_url": "https://example.com/clip.mp4","created_at": "2026-10-01T10:00:00Z","status": "done","summary": "A family builds a snowman in a suburban backyard...","genre": ["family"],"iab_content_leaf": ["Family and Relationships"],"garm_video": [],"external_content_id": null,"reason_code": null,"indexed_at": "2026-10-01T10:05:12Z","index_trigger": "initial","job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","reindex_run_id": null,"transcript_coverage": 0.82,"scene_count": 42,"scenes": [{"id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f","video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90","scene_index": 0,"start_ms": 0,"end_ms": 4200,"caption": "A child in a red coat packs snow into a ball in a backyard.","transcript": "Okay, let's make the bottom one really big.","text_on_screen": [],"valence": 4,"arousal": 3,"music_mood": null,"audio_events": ["laughter"],"garm_categories": [],"brands": [],"product_categories_visible": [],"people": [],"locations": ["backyard"],"action": ["packing snow"],"human_activity": ["playing"],"weather": "snow","keyframe_url": "https://...","clip_url": "https://..."}],"scene_markers": [{ "id": "9c0d1e2f-...", "scene_index": 0, "start_ms": 0, "end_ms": 4200,"garm_level": "none", "garm_categories": [] }],"aggregations": {"brands": [],"products": [],"locations": [{ "name": "backyard", "count": 12 }],"people": [],"audio_events": [{ "name": "laughter", "count": 5 }],"music_moods": [],"text_on_screen": [],"mood_timeline": [{ "scene_index": 0, "start_ms": 0, "valence": 4, "arousal": 3 }],"actions": [{ "name": "packing snow", "count": 3 }],"human_activities": [{ "name": "playing", "count": 20 }],"weather_counts": [{ "name": "snow", "count": 42 }]}}scenes에는 처음scene_limit개의 장면이 담깁니다(1–200, 기본값 50,scene_offset으로 오프셋 지정).scene_markers는 항상 모든 장면을 포함합니다.source_url은 http(s) 출처일 때만 채워집니다. - 나머지 장면 페이지 넘기기
GET /api/v1/videos/{video_id}/scenes는 위와 같은 형태의 전체 장면을offset/limit(1–200, 기본값 50),total과 함께 반환합니다.- cURL
- Python
curl -s "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes?offset=50&limit=50" \-H "Authorization: Bearer $VUF_API_KEY"scenes, offset, limit = [], 0, 200while True:page = requests.get(f"{BASE}/videos/{video_id}/scenes", headers=HEADERS,params={"offset": offset, "limit": limit}).json()scenes.extend(page["items"])offset += limitif offset >= page["total"]:break경고알 수 없는
video_id는404가 아니라 빈 페이지(total: 0)를 반환합니다. "없는 영상"과 "장면이 없는 영상"을 구분해야 한다면GET /api/v1/videos/{video_id}로 영상이 있는지 확인하세요. - 선택: 색인 중에 장면 스트리밍
GET /api/v1/videos/{video_id}/scenes/stream은 한 줄에 객체 하나씩 오는 NDJSON이며, 영상 전체가 끝날 때까지 기다리지 않습니다.{"event": "scene", "scene": {...}}— 새로 분석된 장면(/scenes항목과 같은 형태, 반복되지 않음){"event": "keepalive"}— 유휴 상태일 때 전송되며 무시하면 됩니다{"event": "done", "job_status": "done"}— 색인 생성 완료.job_status는failed일 수도 있고, 색인 작업이 없는 영상이면null입니다. 이 줄 다음에 연결이 닫힙니다{"event": "timeout"}— 서버 측 최대 스트림 시간에 도달했습니다. 다시 연결해 새 스냅숏을 받으세요
- cURL
- Python
curl -N "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes/stream" \-H "Authorization: Bearer $VUF_API_KEY"import jsonwith requests.get(f"{BASE}/videos/{video_id}/scenes/stream", headers=HEADERS, stream=True) as r:for line in r.iter_lines():if not line:continueevt = json.loads(line)if evt["event"] == "scene":print(evt["scene"]["scene_index"], evt["scene"]["caption"])elif evt["event"] in ("done", "timeout"):break장면 순서는
scene_index가 아니라 분석이 끝난 순서를 따릅니다. 이미 색인된 영상에 연결하면 모든 장면을 즉시 다시 보내고done으로 닫습니다. 종료 줄 없이 연결이 끊기면 다시 연결하세요. 이미 분석된 장면이 먼저 다시 전송됩니다. 여기서는 알 수 없는video_id에404를 반환합니다.
배치
URL 목록
kind: "url"로 POST /api/v1/videos/batch를 호출하면 한 번에 최대 500행을 제출합니다. 각 행에는 url(최대 2048자)과 선택 항목인 title(최대 500자)이 있습니다. 201 응답의 items 배열은 요청과 1:1로, 요청 순서대로 대응하므로 추가 호출 없이 source_url → video_id를 매핑할 수 있습니다.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/batch \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "url",
"items": [
{"url": "https://example.com/a.mp4", "title": "A"},
{"url": "https://example.com/b.mp4"}
]
}'
rows = [{"url": "https://example.com/a.mp4", "title": "A"},
{"url": "https://example.com/b.mp4"}]
r = requests.post(f"{BASE}/videos/batch", headers=HEADERS,
json={"kind": "url", "items": rows})
r.raise_for_status()
batch = r.json()
by_url = {it["source_url"]: it["video_id"] for it in batch["items"]}
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"declared_total": null,
"accepted": 2,
"skipped": 0,
"failed": 0,
"items": [
{ "source_url": "https://example.com/a.mp4", "video_id": "3f6c...", "job_id": "a1b2...",
"status": "submitted", "error": null, "created": true },
{ "source_url": "https://example.com/b.mp4", "video_id": "5e7d...", "job_id": "c3d4...",
"status": "submitted", "error": null, "created": false }
]
}
행별 필드:
status는submitted,skipped(검증에서 거부되어 제출되지 않음),failed(검증은 통과했지만 제출되지 않음) 중 하나입니다.error는skipped/failed행에서 안정적인 사유 값(예:duplicate_url,blocked_target,dispatch_failed)이며,submitted행에서는null입니다.created는 이 행이 영상을 새로 만들었을 때true입니다.created: false인submitted행은 이미 색인 중인 영상에 연결된 것입니다.- 한 요청에 두 번 나온 URL은
error: "duplicate_url"로skipped되지만, 첫 행의video_id/job_id를 그대로 담습니다.
accepted는 기존 작업에 연결된 행을 포함해 파이프라인에 넘겨진 행 수입니다. 새로 만들어진 수는 items[].created를 세어 확인하세요. submitted는 "색인 완료"가 아니라 "파이프라인에 넘겨짐"을 뜻하며, 기준이 되는 상태는 GET /api/v1/videos/{video_id}입니다. failed 행은 다시 제출하는 것을 전제로 합니다. dispatch_failed URL은 작업이 실패로 표시되므로(best-effort) 아무것도 색인되지 않았습니다.
items가 500개를 넘으면 스키마 검증에 실패해 detail이 필드 오류 배열인 422를 반환합니다. kind가 file / url이 아니거나, kind: "file"인데 items가 비어 있지 않으면 400을 반환합니다.
한 요청에 여러 파일
POST /api/v1/videos/batch/upload는 여러 file 필드를 multipart로 한 번에 업로드합니다. 파일마다 독립적으로 색인되며, 하나가 실패해도 나머지는 중단되지 않습니다.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/batch/upload \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@a.mp4" -F "file=@b.mp4" -F "file=@c.mp4"
paths = ["a.mp4", "b.mp4", "c.mp4"]
files = [("file", (p, open(p, "rb"))) for p in paths]
r = requests.post(f"{BASE}/videos/batch/upload", headers=HEADERS, files=files)
r.raise_for_status()
rollup = r.json() # batch_id, accepted, skipped, failed
202 응답 본문에는 개수만 담깁니다: batch_id, accepted, skipped(잘못된 확장자, 빈 파일 등으로 처음부터 거부됨), failed. 파일별 video_id와 job_id는 GET /api/v1/videos/batch/{batch_id}에서 확인합니다.
권장: 요청당 파일 10개 이하. 더 큰 업로드는 여러 요청으로 나누세요. 파일 수에 엄격한 상한은 없지만, 500개를 넘으면 413을 반환하는 안전 장치가 있습니다. 이 수는 요청 단위로, 그리고 재사용한 batch_id 전체에 걸쳐 셉니다.
중간에 실패한 요청(예: 연결 끊김)에서 복구하기:
- 먼저
GET /api/v1/videos/batch/{batch_id}를 폴링하세요. 업로드에는 자연스러운 중복 제거 키가 없으므로, 이미 기록된 파일을 다시 보내면 중복으로 처리되지 않습니다. - 아직 빠진 파일만 다시 보내세요. 이때 롤업에서
error가dispatch_failed인 항목은 제외하세요. 해당 영상 행은 이미 커밋되어 있어 다시 보내면 파일이 두 번 색인됩니다. - 업로드 호출이 응답을 반환했다면 배치 롤업보다 그 응답의
accepted/skipped/failed개수를 믿으세요. 롤업의 항목 수가accepted보다 적다면 기록 쓰기가 유실된 것이지, 파일이 제출되지 않은 것이 아닙니다.
업로드 호출이 아예 응답하지 않더라도 알려진 ID로 폴링하려면, POST /api/v1/videos/batch(kind: "file", 참고용 선택 항목 total 0–500)로 빈 배치를 먼저 만들고 그 batch_id를 업로드의 폼 필드로 넘기세요.
URL이 담긴 csv 또는 xlsx 시트
POST /api/v1/videos/batch/upload-sheet는 url 열(과 선택 항목인 title 열)이 있는 .csv 또는 .xlsx를 파싱해 유효한 행을 한 번에 모두 제출합니다.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/batch/upload-sheet \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@catalogue.csv"
with open("catalogue.csv", "rb") as f:
r = requests.post(f"{BASE}/videos/batch/upload-sheet", headers=HEADERS, files={"file": f})
r.raise_for_status()
sheet = r.json()
for row in sheet["rejected_rows"]:
print("dropped row", row["row"], row["reason"], row["value"])
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"accepted": 2,
"skipped": 1,
"failed": 0,
"rejected_before_submit": 1,
"rejected_rows": [
{ "row": 4, "value": "https://example.com/a.mp4", "reason": "duplicate_url" }
],
"items": [
{ "source_url": "https://example.com/a.mp4", "video_id": "3f6c...", "job_id": "a1b2...",
"status": "submitted", "error": null, "created": true },
{ "source_url": "https://example.com/b.mp4", "video_id": "5e7d...", "job_id": "c3d4...",
"status": "submitted", "error": null, "created": true }
]
}
URL 형식 검증에 실패한 행, 앞 행과 중복된 행, url/title이 너무 긴 행은 파싱 단계에서 제외됩니다. 이런 행은 rejected_before_submit에 집계되고 skipped에 합산되며, rejected_rows에 최대 100개까지 1부터 시작하는 row 번호(헤더가 1행), 원본 value, duplicate_url이나 url_too_long 같은 안정적인 reason과 함께 나열됩니다. 이 행들은 배치에 기록되지 않으므로 GET /api/v1/videos/batch/{batch_id}는 제출된 행만 셉니다. 제출된 모든 행은 시트 순서대로 URL 배치와 같은 행별 필드를 담아 items로 돌아옵니다.
시트 전용 오류:
| 상태 | 조건 | 본문 |
|---|---|---|
413 | 파일이 시트 크기 상한 10 MB를 넘거나, 유효한 URL 행이 한 배치가 받는 수(현재 500개)보다 많습니다. 여러 시트로 나누세요. | detail, code |
422 | .csv / .xlsx가 아님, url 열 없음, 형식이 잘못되었거나 손상된 파일, 유효한 URL 행 없음, 데이터 행 10000개 초과, 열이 너무 많음, 압축 해제 크기 상한을 넘는 시트. | detail, code |
code가 no_valid_urls이면 오류 본문에 같은 수준의 키 rejected가 하나 더 담깁니다. 파싱 중 제외된 행 수(어떤 배치에도 기록되지 않음)입니다.
{ "detail": "no valid url rows in sheet", "code": "no_valid_urls", "rejected": 17 }
배치 접수 내역 읽기
GET /api/v1/videos/batch/{batch_id}는 POST /api/v1/videos/batch 또는 POST /api/v1/videos/batch/upload로 만든 배치의 제출 접수 내역입니다. counts는 상태별 합계를, items는 각 행의 status, error, video_id, job_id를 보여 줍니다.
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"kind": "file",
"total": 3,
"counts": { "pending": 0, "submitted": 2, "skipped": 0, "failed": 1 },
"items": [
{ "id": "...", "source_url": null, "storage_path": "s3://...", "title": "a.mp4",
"status": "submitted", "error": null, "video_id": "3f6c...", "job_id": "a1b2...",
"indexed_at": null, "index_trigger": null, "reindex_run_id": null }
]
}
이 정보는 색인 진행률이 아닙니다. 각 항목의 status는 제출 시점에 한 번 기록된 뒤 갱신되지 않습니다. submitted는 "색인 완료"가 아니라 "접수됨"을 뜻합니다. 이 엔드포인트를 폴링하며 완료를 기다리면 영원히 기다리게 됩니다. counts.pending은 계산된 값(total에서 기록된 항목 수를 뺀 값, 즉 생성 시 선언했지만 아직 업로드로 채워지지 않은 자리)이며 저절로 줄어들지 않습니다. 빠진 파일을 보내세요.
색인 결과는 항목별 ID로 확인하세요. 영상 상태와 결과는 GET /api/v1/videos/{video_id}, 최근 시도의 진행률과 실패 사유는 GET /api/v1/jobs/{job_id}에서 봅니다. 여기의 job_id는 조회 시점에 확인한 영상의 최신 작업(진행 중인 작업 포함)이므로 재색인 후에는 바뀝니다. indexed_at, index_trigger, reindex_run_id는 가장 최근에 끝난 시도를 나타냅니다.
MRSS 피드
피드는 대시보드에서 등록하고, API로 조회하고 관리합니다. 각 피드는 poll_interval_s 주기로 폴링되며, 피드에서 나온 모든 항목이 색인 대상으로 제출됩니다. 피드로 수집된 영상은 피드 항목의 ID를 external_content_id로 담고 있으며, GET /api/v1/videos?content_id=...로 이 ID와 정확히 일치하는 영상을 찾을 수 있습니다.
| 호출 | 하는 일 |
|---|---|
GET /api/v1/mrss-feeds | 팀에 등록된 피드를 최신순으로 반환합니다: id, feed_url, enabled, plan_blocked, poll_interval_s, last_polled_at, next_poll_at, label. |
GET /api/v1/mrss-feeds/{feed_id} | 피드 하나와 created_at, counts를 반환합니다. counts에는 제출 개수(pending / submitted / failed)와 색인 개수(indexed_done / indexed_running / indexed_failed)가 담깁니다. |
PATCH /api/v1/mrss-feeds/{feed_id} | enabled, poll_interval_s, label을 부분 수정합니다. 하나 이상 보내야 하며, 빈 본문은 422입니다. |
DELETE /api/v1/mrss-feeds/{feed_id} | 동기화를 멈추고 피드를 삭제합니다. 204, 본문 없음. |
GET /api/v1/mrss-feeds/{feed_id}/items | 피드에서 수집한 항목을 status(제출 상태)와/또는 index_status(색인 상태)로 필터링해 limit(1–200, 기본값 50) / offset으로 페이지를 나눠 반환합니다. |
- cURL
- Python
# Switch a feed to daily polling and give it a label
curl -X PATCH https://video.pyler.ai/api/v1/mrss-feeds/$FEED_ID \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"poll_interval_s": 86400, "label": "Partner catalogue"}'
# Items whose indexing failed
curl -s "https://video.pyler.ai/api/v1/mrss-feeds/$FEED_ID/items?index_status=failed&limit=100" \
-H "Authorization: Bearer $VUF_API_KEY"
r = requests.patch(f"{BASE}/mrss-feeds/{feed_id}", headers=HEADERS,
json={"poll_interval_s": 86400, "label": "Partner catalogue"})
r.raise_for_status()
items = requests.get(f"{BASE}/mrss-feeds/{feed_id}/items", headers=HEADERS,
params={"index_status": "failed", "limit": 100}).json()
for it in items["items"]:
print(it["guid"], it["video_id"], it["index_reason_code"])
알아 둘 규칙:
poll_interval_s는3600,21600,86400(매시간 / 6시간마다 / 매일) 중 하나여야 합니다. 그 밖의 값은code: mrss.bad_interval과 함께422입니다. 값을 바꾸면 다음 폴링 시각을 새 UTC 슬롯으로 즉시 다시 계산합니다.label은 최대 200자이며 제어 문자를 쓸 수 없습니다(mrss.bad_label). 비어 있거나 공백만 있는 label은 지워지고, 피드는feed_url을 대신 보여 줍니다.- 피드 URL은 바꿀 수 없습니다. URL이 다르면 다른 피드이므로 새로 등록하세요.
- 요금제에서 더 이상 MRSS를 허용하지 않는 피드를 다시 활성화하면
code: mrss.disabled와 함께422를 반환합니다. 비활성화는 항상 가능합니다. 피드의plan_blocked로 이 상태를 확인할 수 있습니다. - 알 수 없는 ID와 다른 팀 소유의 피드는 모두
404를 반환합니다. 잘못된status/index_status필터 값은mrss.bad_status/mrss.bad_index_status와 함께422를 반환합니다. - 삭제는 영구적입니다. 피드에서 이미 색인된 영상은 라이브러리에 남아 계속 검색되며
external_content_id도 유지됩니다. 삭제 시점에 색인 중이던 항목은 완료될 수도 있습니다. 나중에 같은 URL을 다시 등록하면 새 피드가 만들어지고 모든 항목을 다시 색인합니다.
각 피드 항목(FeedItemOut)에는 guid, title, title_en, source_url, status, error, video_id, delivery_status, delivered_at, delivery_error, index_status, index_error, index_reason_code, first_seen_at, pub_date, duration_s가 담깁니다.
맞춤 요약
영상에 표준 요약이 생기면 POST /api/v1/videos/{video_id}/custom-summaries로 직접 작성한 instructions에 따라 두 번째 요약을 요청할 수 있습니다. 표준 결과는 바뀌지 않습니다.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/$VIDEO_ID/custom-summaries \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instructions": "One-line logline, tone and pacing, setting, themes. Neutral tone.",
"cast": [{"name": "Alex Rivera", "aliases": ["A. Rivera"]}],
"language": "en"
}'
r = requests.post(f"{BASE}/videos/{video_id}/custom-summaries", headers=HEADERS, json={
"instructions": "One-line logline, tone and pacing, setting, themes. Neutral tone.",
"cast": [{"name": "Alex Rivera", "aliases": ["A. Rivera"]}],
"language": "en",
})
r.raise_for_status()
cs_id = r.json()["custom_summary_id"]
while True:
cs = requests.get(f"{BASE}/videos/{video_id}/custom-summaries/{cs_id}", headers=HEADERS).json()
if cs["status"] != "queued":
break
time.sleep(5)
{
"custom_summary_id": "cs_0f3c5a1e9b7d4c2a8e6f1b3d5a7c9e24",
"instruction_id": "ins_7f3a2b1c9d8e4f5a6b7c8d9e0f1a2b3c",
"status": "queued",
"base_version": "std-v3"
}
요청 필드:
instructionsstringbodyrequired자유 텍스트, 최대 2000자. 요약 길이는 서비스가 정합니다.
castCastMember[]bodyname(1–200자)과 선택 항목인 aliases(최대 20개)로 된 항목을 최대 100개까지 넣습니다. 요약에는 여기 있는 인물 이름만 쓸 수 있으며, cast가 없으면 인물 이름을 쓰지 않도록 지시합니다. cast 밖의 이름이 감지된 섹션은 null로 돌아옵니다. 이 검사는 best-effort라 이름을 놓칠 수도 있고, 장소나 브랜드 이름만 있는 섹션을 비울 수도 있습니다. 이름이 중요한 경우에는 결과를 검토하세요.
languagestringbodydefault: en언어 태그(최대 35자). instructions에 적힌 언어보다 우선합니다.
GET /api/v1/videos/{video_id}/custom-summaries/{custom_summary_id}를 폴링하세요. 준비 중에는 status가 queued이고, 이후 ready, rejected(지시문이 범위를 벗어남, message 참고), failed(다시 요청) 중 하나가 됩니다. ready이면 summary에 logline, tone_pacing, setting, themes 섹션이 문자열 또는 null로 담깁니다. notes는 섹션이 null인 이유를 알려 줍니다. base_summary는 요청의 기준이 된 표준 요약이고, base_version은 그 버전입니다(해당 버전이 삭제되면 null).
{
"status": "ready",
"custom_summary_id": "cs_0f3c5a1e9b7d4c2a8e6f1b3d5a7c9e24",
"instruction_id": "ins_7f3a2b1c9d8e4f5a6b7c8d9e0f1a2b3c",
"base_version": "std-v3",
"summary": {
"logline": "A family turns a snow day into a backyard competition.",
"tone_pacing": "Warm and unhurried.",
"setting": null,
"themes": "Family, play, winter."
},
"notes": ["setting: no place is identifiable"],
"message": null,
"base_summary": "A family builds a snowman in a suburban backyard..."
}
GET /api/v1/videos/{video_id}/custom-summaries는 영상에 대한 조직의 요청을 최신순으로 나열하며, limit(1–100, 기본값 50)과 next_cursor에서 받은 cursor로 페이지를 나눕니다.
미리 고려할 동작:
- 같은 지시문이 이미 범위를 벗어난다고 판정된 적이 있으면,
POST가 곧바로status: rejected로 응답하고 아무것도 생성하지 않습니다. code: custom_summary_no_base_summary인422는 영상에 아직 표준 요약이 없다는 뜻입니다.custom_summary_no_org는 키가 어떤 조직에도 속하지 않는다는 뜻이고,quota_exceeded_summaries는 요약 사용 한도를 모두 써서 아무것도 만들어지지 않았다는 뜻입니다.503은 요청이 대기열에 들어가지 않았다는 뜻입니다. 다시 보내세요. 재시도할 때마다 새 요청이 만들어집니다.- 요청 하나는 영상 사용 한도가 아니라 조직의 요약 사용 한도(
GET /api/v1/usage의summaries)에서 1건으로 셉니다.rejected로 끝난 요청도 포함됩니다.failed로 끝났거나, 영상이 삭제될 때 아직queued였던 요청은 사용 한도로 돌려받습니다.
내 영상 목록
GET /api/v1/videos는 내 계정이 소유한 영상을 최신순으로 나열합니다. 내 계정 범위로만 한정되며, 공유 샘플 코퍼스는 제외됩니다.
- cURL
- Python
curl -s "https://video.pyler.ai/api/v1/videos?limit=20&status=done&length_bucket=short" \
-H "Authorization: Bearer $VUF_API_KEY"
cursor, videos = None, []
while True:
page = requests.get(f"{BASE}/videos", headers=HEADERS,
params={"limit": 100, "status": "done", "cursor": cursor}).json()
videos.extend(page["items"])
cursor = page.get("next_cursor")
if not cursor:
break
{
"items": [
{
"id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"title": "demo clip",
"duration_ms": 61234,
"status": "done",
"is_user_upload": true,
"org_id": "org_...",
"created_at": "2026-10-01T10:00:00Z",
"summary": "A family builds a snowman...",
"genre": ["family"],
"iab_content_leaf": ["Family and Relationships"],
"garm_video": [],
"keyframe_url": "https://...",
"external_content_id": null,
"indexed_at": "2026-10-01T10:05:12Z",
"index_trigger": "initial",
"job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"reindex_run_id": null
}
],
"next_cursor": null
}
페이지는 커서 기반으로 나눕니다. limit은 1–100(기본값 50)이며, 이전 응답의 next_cursor를 cursor로 넘기세요. next_cursor가 null이면 마지막 페이지입니다.
| 필터 | 타입 | 설명 |
|---|---|---|
status | string | 영상 상태. |
genre | string[] | 여러 값을 넣으려면 파라미터를 반복하세요. |
iab_t1 | string[] | IAB Tier-1 콘텐츠 카테고리. |
garm_min_level | string | 브랜드 세이프티 하한. |
duration_min_sec, duration_max_sec | integer | 초 단위 길이 범위. |
length_bucket | string | short, medium, long 중 하나. |
search | string | 자유 텍스트 일치. |
content_id | string | MRSS 외부 콘텐츠 ID와 정확히 일치. 알 수 없는 ID는 빈 목록을 반환합니다. |
reindexed_since | date-time | 가장 최근에 끝난 색인 시도가 이 시각 또는 그 이후에 끝난 재색인인 영상. 실패한 재색인도 포함합니다. UTC 오프셋이 필요합니다(2026-10-01T00:00:00Z, +는 %2B로 쓰세요). |
reindex_run_id | uuid | 해당 재색인 실행에 작업이 있는 모든 영상. 아직 실행 중인 영상도 포함합니다. |
한 번만 색인된 영상은 두 재색인 필터 어디에도 걸리지 않습니다. 형식이 잘못된 UUID나 UTC 오프셋이 없는 타임스탬프는 422를 반환합니다.
각 항목에는 색인 이력 필드인 indexed_at, index_trigger(initial 또는 reindex), job_id, reindex_run_id가 담깁니다. 모두 가장 최근에 끝난 시도를 나타내며, 첫 색인이 진행 중일 때는 모두 null입니다. 이미 done이었던 영상은 이후 시도가 실패해도 그 status를 유지합니다. 따라서 indexed_at을 마지막으로 본 값과 비교하고, 결과는 job_id로 확인하세요.