장면 검색
검색은 색인된 영상의 장면을 대상으로 실행하며, 장면 단위 일치 결과를 상위 영상별로 묶어 반환합니다. 세 엔드포인트가 같은 응답 형태를 사용합니다.
| 엔드포인트 | 질의 | 사용 시점 | 레퍼런스 |
|---|---|---|---|
POST /api/v1/search | 텍스트(JSON) | 기본값입니다. 캡션과, 선택적으로 트랜스크립트·멀티모달 임베딩으로 장면 점수를 매깁니다. | 시맨틱 검색 |
POST /api/v1/search/deep | 텍스트(JSON) | 요청은 같고, 상위 장면에 멀티모달 재순위화를 한 번 더 적용합니다. 품질이 더 높고 보통 2~5초가 더 걸립니다. | 딥 검색 |
POST /api/v1/search/multimodal | 이미지 또는 오디오 파일(multipart) | 찾는 대상을 텍스트로 설명하기 어려울 때 사용합니다. 결과는 omni 열에만 담깁니다. | 멀티모달 검색 |
모든 예제는 https://video.pyler.ai와 $VUF_API_KEY에 담긴 API 키를 사용합니다. 영상은 작업이 status: "done"을 보고한 뒤부터 검색할 수 있습니다. 영상 색인 생성을 참고하세요.
요청 필드
POST /api/v1/search와 POST /api/v1/search/deep은 같은 JSON 본문을 받습니다.
querystringbodydefault: 찾으려는 순간을 자연어로 설명합니다.
modalitystringbodydefault: both검색에 사용할 임베딩입니다. caption, transcript, omni, both, all 중 하나입니다. 모달리티를 참고하세요.
kintegerbodydefault: 20모달리티 열마다 반환할 영상 수입니다. 1~100입니다.
scene_poolintegerbodydefault: 60영상으로 묶기 전에 가져오는 후보 장면 수입니다. 10~200입니다.
iab_t1string[]body지정한 IAB Tier-1 콘텐츠 카테고리의 영상으로 범위를 좁힙니다.
garm_min_levelstringbody브랜드 세이프티 하한입니다. low, medium, high 중 하나입니다.
duration_min_secintegerbody최소 영상 길이(초)입니다.
duration_max_secintegerbody최대 영상 길이(초)입니다.
length_bucketstringbodyshort, medium, long 중 하나입니다.
video_idstring (uuid)body한 영상의 장면으로 검색 범위를 제한합니다.
judgestringbodydefault: nonetext로 설정하면 AI 관련성 판정이 켜지고 응답이 NDJSON 스트림으로 바뀝니다. modality: "caption"이 필요합니다. AI 판정을 참고하세요.
min_relevanceintegerbodydefault: 3judge: "text"일 때 장면이 summary 줄에 남기 위해 필요한 0~10 관련성 점수입니다.
deepbooleanbodydefault: false본문에도 있는 필드입니다. 재순위화를 요청하는 공식 방법은 POST /api/v1/search/deep입니다.
모달리티
modality | 비교 대상 | 채워지는 열 |
|---|---|---|
caption | 장면에서 일어나는 일을 서술한 설명 | caption |
transcript | 발화 원문(무음 장면 제외) | transcript |
omni | 시각·오디오 임베딩 | omni |
both(기본값) | 캡션과 omni를 나란히 | caption, omni |
all | 캡션, 트랜스크립트, omni | 세 열 모두 |
선택한 모달리티에 속하지 않는 열은 빈 배열로 반환됩니다.
첫 검색
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/search \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "two people having a coffee shop conversation",
"modality": "both",
"k": 10,
"iab_t1": ["Food & Drink"],
"garm_min_level": "medium",
"duration_max_sec": 600
}'
import os, requests
BASE = "https://video.pyler.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"}
r = requests.post(f"{BASE}/search", headers=HEADERS, json={
"query": "two people having a coffee shop conversation",
"modality": "both",
"k": 10,
"iab_t1": ["Food & Drink"],
"garm_min_level": "medium",
"duration_max_sec": 600,
})
r.raise_for_status()
result = r.json()
for hit in result["caption"]:
v = hit["video"]
print(f'{hit["score"]:.2f} {v["title"]} ({v["id"]})')
for s in hit["scenes"]:
sc = s["scene"]
print(f' {s["score"]:.2f} scene {sc["scene_index"]} {sc["start_ms"]}–{sc["end_ms"]} ms {sc["caption"]}')
결과 형태
응답에는 caption, transcript, omni 세 열이 있습니다. 각 열은 영상 결과(VideoMatch) 목록이고, 영상 결과마다 그 안에서 일치한 장면(SceneMatch) 목록이 있습니다.
{
"caption": [
{
"score": 0.82,
"video": {
"id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"title": "Morning at the corner cafe",
"duration_ms": 412000,
"summary": "Two friends catch up over coffee...",
"genre": ["drama"],
"iab_content_leaf": ["Food & Drink"],
"garm_video": [],
"keyframe_url": "https://...",
"created_at": "2026-10-01T10:00:00Z",
"is_user_upload": true,
"org_id": "org_...",
"status": "done",
"external_content_id": null,
"done_scenes": 38,
"total_scenes": 38
},
"scenes": [
{
"score": 0.83,
"scene": {
"id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"scene_index": 7,
"start_ms": 12000,
"end_ms": 16500,
"caption": "Two people sit across a small table with coffee cups, talking.",
"transcript": "So how did the interview go?",
"text_on_screen": [],
"valence": 3,
"arousal": 2,
"music_mood": null,
"audio_events": ["speech"],
"garm_categories": [],
"brands": [],
"product_categories_visible": ["coffee"],
"people": [],
"locations": ["cafe"],
"action": ["talking"],
"human_activity": ["conversation"],
"weather": null,
"keyframe_url": "https://...",
"clip_url": "https://..."
}
}
]
}
],
"transcript": [],
"omni": [ ]
}
caption / transcript / omniVideoMatch[]모달리티 열마다 하나씩 있는 목록입니다. 요청한 modality에 속하지 않는 열은 비어 있습니다.
VideoMatch.scorenumber집계된 영상 점수입니다. 강하게 일치한 장면 하나를 우대하면서도, 여러 장면이 일치한 영상이 경계선에 걸친 단일 일치 영상보다 위로 올라올 수 있게 합니다. 점수는 한 응답 안에서만 비교하세요. 집계 방식은 릴리스마다 바뀔 수 있습니다.
VideoMatch.videoVideoLite간략한 영상 요약입니다. id, title, duration_ms, summary, genre, iab_content_leaf, garm_video, keyframe_url, created_at, is_user_upload, org_id, status, external_content_id, done_scenes, total_scenes를 담습니다.
VideoMatch.scenesSceneMatch[]이 영상 안에서 일치한 장면입니다. 장면마다 자체 score와 전체 scene 레코드가 있습니다.
SceneMatch.sceneSceneLite타임코드는 start_ms / end_ms(영상 시작부터의 밀리초)이고, scene_index는 0부터 시작하는 위치입니다. 레코드에는 caption, transcript, text_on_screen, 분위기(valence, arousal, music_mood), audio_events, garm_categories, brands, product_categories_visible, people, locations, action, human_activity, weather, 그리고 서명된 keyframe_url / clip_url도 담깁니다.
플레이어에서 결과 위치로 이동하려면 scene.start_ms로 탐색하세요. 화면에 보여 주려면 scene.keyframe_url을 렌더링하거나 scene.clip_url을 재생합니다. 영상 전체가 필요하면 video.id로 GET /api/v1/videos/{video_id}를 호출하세요.
한도와 페이지네이션
검색에는 커서가 없습니다. 한 요청은 열마다 최대 k개 영상(1100, 기본값 20)을 반환하며, 이 영상은 200, 기본값 60)에서 뽑습니다. 다른 결과가 필요하면 페이지를 넘기는 대신 요청 범위를 좁히세요.scene_pool개 후보 장면(10
- 결과가 더 필요하면 상한 안에서
k와scene_pool을 올립니다. iab_t1,garm_min_level,duration_min_sec/duration_max_sec,length_bucket으로 필터링합니다.- 한 영상 안에서 검색하려면
video_id를 지정합니다.
딥 검색
POST /api/v1/search/deep은 같은 본문을 받고 같은 형태를 반환합니다. 상위 장면에 멀티모달 재순위화를 한 번 더 적용하므로 순서가 더 정확한 대신 지연 시간이 늘어납니다(보통 2~5초).
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/search/deep \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "the climactic moment the lead looks straight at the camera", "modality": "both", "k": 20}'
r = requests.post(f"{BASE}/search/deep", headers=HEADERS, json={
"query": "the climactic moment the lead looks straight at the camera",
"modality": "both",
"k": 20,
})
딥 검색에는 일반 검색보다 긴 클라이언트 타임아웃을 지정하세요. 타임아웃과 재시도를 참고하세요.
멀티모달 검색
POST /api/v1/search/multimodal은 JSON 대신 multipart/form-data를 받습니다. 업로드한 파일이 질의이며, 결과는 omni 열에만 담깁니다.
| 필드 | 타입 | 기본값 | 비고 |
|---|---|---|---|
file | file | 필수 | 질의로 사용할 이미지 또는 오디오 파일입니다. |
modality | string | image | file의 종류로, image 또는 audio입니다. |
k | integer | 20 | 반환할 영상 수입니다. |
scene_pool | integer | 60 | 묶기 전에 가져오는 후보 장면 수입니다. |
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/search/multimodal \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@query-frame.jpg" \
-F "modality=image" \
-F "k=10"
with open("query-frame.jpg", "rb") as f:
r = requests.post(f"{BASE}/search/multimodal", headers=HEADERS,
files={"file": f}, data={"modality": "image", "k": 10})
r.raise_for_status()
hits = r.json()["omni"]
AI 판정(NDJSON)
judge: "text"(modality: "caption" 필요)를 지정하면 POST /api/v1/search가 후보 장면마다 질의와의 관련성을 채점하고, 응답은 한 줄에 JSON 객체 하나씩 담긴 application/x-ndjson 스트림이 됩니다.
type | 내용 |
|---|---|
meta | 일반 검색 결과(query, judge, caption / transcript / omni 열, verify_scene_ids, total)입니다. |
result | 판정한 장면마다 한 줄입니다. scene_id, video_id, score(0~10 관련성), reason, error를 담습니다. |
summary | min_relevance(기본값 3) 이상인 장면만 videos로 담습니다. 최고 관련성, 그다음 최고 코사인 유사도 순으로 정렬하며, score는 그대로 두고 상위 k개 영상만 남깁니다. empty: true는 일치 결과가 없다는 뜻이고, all_errors: true는 판정 자체가 실패했다는 뜻입니다. errors는 실패한 판정 수입니다. |
done | 스트림의 끝입니다. |
- cURL
- Python
curl -N -X POST https://video.pyler.ai/api/v1/search \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "child playing in snow", "modality": "caption", "k": 10, "judge": "text", "min_relevance": 5}'
import json
body = {"query": "child playing in snow", "modality": "caption", "k": 10,
"judge": "text", "min_relevance": 5}
with requests.post(f"{BASE}/search", headers=HEADERS, json=body, stream=True) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line:
continue
evt = json.loads(line)
if evt["type"] == "result":
print(evt["scene_id"], evt["score"], evt["reason"])
elif evt["type"] == "summary":
kept = [] if evt["empty"] else evt["videos"]
elif evt["type"] == "done":
break
{"type":"meta","query":"child playing in snow","judge":"text","caption":[...],"transcript":[],"omni":[],"verify_scene_ids":[...],"total":14}
{"type":"result","scene_id":"9c0d...","video_id":"3f6c...","score":8,"reason":"A child packs snow in a backyard.","error":false}
{"type":"summary","min_relevance":5,"videos":[...],"empty":false,"errors":0,"all_errors":false}
{"type":"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/your-clip.mp4", "title": "demo clip"}'# {"video_id": "3f6c...", "job_id": "a1b2...", "created": true}
- 작업이 끝날 때까지 폴링while true; dostatus=$(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 5done
- 영상 안의 순간 검색
새
video_id를 넘기면 그 영상 안에서만 검색합니다. 생략하면 라이브러리 전체를 검색합니다.- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/search \-H "Authorization: Bearer $VUF_API_KEY" \-H "Content-Type: application/json" \-d '{"query": "child playing in snow", "modality": "both", "k": 10, "video_id": "'"$VIDEO_ID"'"}'r = requests.post(f"{BASE}/search", headers=HEADERS, json={"query": "child playing in snow", "modality": "both", "k": 10, "video_id": video_id,})for hit in r.json()["caption"]:for s in hit["scenes"]:sc = s["scene"]print(sc["start_ms"], sc["end_ms"], sc["clip_url"])각
scenes[*].scene은keyframe_url과clip_url을 바로 렌더링할 수 있는 서명된 URL로 제공합니다. 약 1시간 뒤 만료되므로 저장하지 말고 바로 렌더링하세요. 새 링크가 필요하면 다시 검색하거나 장면을 다시 조회합니다.
질의 작성법
모달리티가 질의를 무엇과 비교할지 정합니다. 따라서 해당 색인이 쓰인 방식에 맞춰 질의를 작성하세요.
caption: 캡션처럼 보이는 것과 일어나는 일을 묘사합니다. 예: "two people having a coffee shop conversation", "child playing in snow". 추상적인 라 벨보다 주체, 행동, 배경이 더 잘 맞습니다.transcript: 실제로 말했을 법한 단어를 사용합니다. 발화 원문과 비교하며 무음 장면은 건너뜁니다.omni: 말로 표현하기 어려운 모습이나 소리에 사용합니다. 또는 멀티모달 검색으로 전환해 참고 프레임이나 오디오 클립을 업로드합니다.both/all: 어느 색인에 원하는 순간이 있는지 확실하지 않을 때 여러 열을 나란히 실행하고, 점수가 높은 열을 확인합니다.- 영상을 이미 알고 있다면
video_id를, 후보 풀을 관련성 있게 유지하려면iab_t1/garm_min_level/ 길이 필터를 추가합니다. - 지연 시간보다 순서가 중요하면
/search/deep을, 장면별 0~10 관련성과 보여 줄 수 있는 이유가 필요하면judge: "text"를 사용합니다.