
튜토리얼
인공지능 비디오 검색의 시작: Twelve Labs API로 검색 쿼리 정밀 결합하기

안키트 카레 (Ankit Khare)
개발자는 Twelve Labs Search API를 사용하여 AND, OR, NOT, THEN 연산자로 여러 자연어 쿼리를 결합함으로써, 특정 자동차 브랜드가 화면에 동시에 나타나는 드리프트 장면을 찾는 것과 같은 복잡한 조건에 정확히 부합하는 비디오의 순간을 정밀하게 찾아낼 수 있습니다. 이 튜토리얼에서는 두 개의 인덱스에 걸친 결합 쿼리를 다루고, 그 결과를 Flask 앱에 표시하는 방법을 소개합니다.
개발자는 Twelve Labs Search API를 사용하여 AND, OR, NOT, THEN 연산자로 여러 자연어 쿼리를 결합함으로써, 특정 자동차 브랜드가 화면에 동시에 나타나는 드리프트 장면을 찾는 것과 같은 복잡한 조건에 정확히 부합하는 비디오의 순간을 정밀하게 찾아낼 수 있습니다. 이 튜토리얼에서는 두 개의 인덱스에 걸친 결합 쿼리를 다루고, 그 결과를 Flask 앱에 표시하는 방법을 소개합니다.

목차
No headings found on page
뉴스레터 구독하기
뉴스레터 구독하기
영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.
영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.
AI로 영상을 검색하고, 분석하고, 탐색하세요.
2023. 4. 18.
16분
링크 복사하기
전제
영화 애호가이자 🎬🍿 콘텐츠 크리에이터로서 🎨🖌️✍️, 저는 소중한 영화 컬렉션을 보관하기 위해 개인 Plex 서버를 구축했습니다. 종종 스토리텔링을 한층 풍부하게 만들고 몰입도 높은 콘텐츠를 제작하기 위해 영화 장면을 일화로 활용하곤 합니다. 예를 들어 동기 부여, 의지력, 역경 극복에 관한 비디오를 제작할 때, 인기 애니메이션인 '드래곤볼 슈퍼' 시리즈의 짜릿한 초사이어인 변신 장면이나 제가 좋아하는 영화 중 하나인 '네버 백 다운'의 운동 및 훈련 장면처럼 관련성 높은 순간들을 보여줄 수 있습니다. 또는 새로운 영화 시나리오를 개발하는 감독이나 작가는 여러 유사한 영화들을 분석하여 코미디 장면의 수, 장면의 지속 시간, 드리프트 레이스의 발생 여부, 머슬카가 노출되는 빈도 등 공통된 테마나 패턴을 식별하고 싶을 것입니다. 방대한 영화 아카이브 속에서, 혹은 단 하나의 영화 안에서조차 특정 장면을 찾아내는 것은 기억력이 아주 뛰어난 사람에게도 꽤나 까다로운 작업입니다. 바로 이 지점에서 비디오 이해(Video Understanding) 기술이 구원투수로 등장합니다 🛟⛑️.
Twelve Labs Search API는 사용자가 간단한 자연어 쿼리를 작성하고 이를 독창적으로 결합하여 관련 비디오 세그먼트를 찾아낼 수 있도록 유연한 비디오 검색 솔루션을 제공합니다. 예를 들어, 결합된 쿼리를 생성하여 주인공이 빨간색 미쓰비시 차량을 운전하는 구체적인 드리프트 장면을 도출해 낼 수 있습니다. 또는 좋아하는 포뮬러 원 차량이 승리의 체커기를 받으며 결승선을 통과하는 스릴 넘치는 순간만을 검색할 수도 있습니다 🏁✌️.

인덱싱된 '패스트 & 퓨리어스: 도쿄 드리프트' 영화에서 결합 쿼리 - 'drift' (검색 옵션: visual) AND 'Mitsubishi' (검색 옵션: logo)로 검색한 결과 😎
소개
이번 튜토리얼 시리즈의 첫 번째 파트에서는 검색 요청에 한 번에 하나의 쿼리만 사용하는 단순 검색을 통해 비디오 내에서 검색을 수행하는 방법을 살펴보았습니다. 이번 후속 튜토리얼을 최대한 활용하기 위해, Twelve Labs Search API의 기본 개념을 이해할 수 있는 이전 튜토리얼을 먼저 복습해 보실 것을 강력히 권장합니다. 기본 개념을 잘 파악하고 계시다면, 이번 튜토리얼에서는 보다 심화된 개념을 소개해 드리겠습니다. 인덱싱된 비디오 내에서 관심 있는 특정 순간을 유연하고 편리하게 찾을 수 있도록 지원하는 Twelve Labs API의 '결합 쿼리' 기능을 자세히 알아보겠습니다. 이를 실전에서 보여주기 위해 포뮬러 원 레이스용 인덱스와 분노의 질주 시리즈 중 하나인 유명한 장편 영화 “도쿄 드리프트”용 인덱스까지 총 두 개의 개별 인덱스를 생성해 보겠습니다. 그런 다음 다양한 연산자를 사용해 검색 쿼리를 결합하여 우리가 찾는 흥미로운 순간들을 식별해 내는 방법을 보여드리겠습니다. 그럼 튜토리얼 개요로 넘어가 이번 가이드를 통해 무엇을 배울 수 있는지 구체적으로 확인해 보겠습니다.
빠른 개요
사전 요구 사항: 이 튜토리얼을 원활하게 진행하기 위해 Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치하세요. 첫 번째 튜토리얼을 확인하는 것도 잊지 마세요!
비디오 업로드: Twelve Labs 플랫폼에 비디오를 전송하면 시스템이 이를 알아서 인덱싱해 줍니다. 인덱싱이 완료되면 복잡한 결합 쿼리를 실행하여 원하는 장면을 정확히 찾아낼 수 있습니다!
결합 쿼리: 이제 진짜 흥미진진한 부분이 시작됩니다! 결합 쿼리는 간단히 말해 두 개 이상의 단순 검색 쿼리를 "or", "and", "not", "then" 등의 연산자를 사용하여 하나의 통합된 쿼리로 결합한 것입니다. 이 연산자들의 이론적인 측면을 가볍게 짚어본 뒤, 두 개 이상의 자연어 쿼리를 효과적으로 결합하는 실전 예제를 다뤄보겠습니다. 이를 통해 인덱싱된 비디오 중에서 결합 쿼리와 의미론적으로(semantically) 일치하는 최적의 장면들을 찾아낼 수 있습니다.
데모 앱 제작하기: 검색 API 결과값을 활용하고 컴퓨터 로컬 폴더에 저장된 비디오에 접근하는 깔끔한 Flask 기반 앱을 구축해 보겠습니다. 그런 다음 맞춤 디자인된 세련된 HTML 페이지를 렌더링하여 검색 결과를 멋지게 보여줍니다.
💡 혹시 개발자가 아니더라도 걱정하지 마세요! 활용하기 쉽게 준비된 Jupyter 노트북 링크를 첨부해 두었습니다. 쿼리와 연산자를 간편하게 수정하고 전체 프로세스를 실행하여 원하는 결과를 바로 얻을 수 있습니다 😄. 재밌게 즐겨보세요!
사전 요구 사항
이번 단계를 진행하기 위한 유일한 사전 요구 사항은 이전 튜토리얼을 숙지하는 것입니다. 이 글이나 이전 글을 읽다가 막히는 부분이 있다면 주저하지 말고 지원을 요청하세요! 저희 Discord 서버에서는 매우 신속하게 답변해 드리고 있습니다 🚅🏎️⚡️. 디스코드 이용이 번거로우시다면 편하게 이메일로 연락해 주셔도 좋습니다. Twelve Labs 계정을 생성한 후 API 대시보드에 접속하여 API 키를 발급받을 수 있습니다. 이번 데모에서는 제가 기존에 사용하던 계정을 활용하겠습니다:
<pre><code class="bash">%env API_KEY=<your_API_key> %env API_URL=https://api.twelvelabs.io/v1.1 </code></pre>
<pre><code class="python">!pip install requests !pip install flask import os import requests import glob from pprint import pprint # Retrieve the URL of the API and my API key API_URL = os.getenv("API_URL") assert API_URL API_KEY = os.getenv("API_KEY") assert API_KEY </code></pre>
비디오 업로드
첫 번째 단계로, 최신 최첨단 비디오 이해 엔진인 "Marengo 2.5"를 사용하여 두 개의 인덱스를 생성하되, 각각 서로 다른 인덱싱 옵션을 지정해 보겠습니다. 포뮬러 원 레이싱 관련 인덱스의 경우 시각 정보(visual)와 대화(conversation) 외에도 비디오 내 텍스트(text-in-video) 및 로고(logo) 옵션을 활성화하는 것이 유리합니다. 레이스 차량, 트랙, 펜스에는 로고가 가득하며 시상식 중 화면에 나타나는 텍스트 분량도 상당하기 때문입니다. 반면 도쿄 드리프트 영화 인덱스에는 텍스트(text-in-video) 옵션을 활성화해도 실질적인 효과가 없을 수 있습니다. 이처럼 다양한 옵션으로 인덱스를 유연하게 조합해 생성하는 기능은 매우 유용합니다. 목적에 맞게 인덱싱 옵션을 정교하게 맞춤 설정함으로써 컴퓨팅 리소스 사용을 최적화하고 최종 비용을 절감할 수 있습니다.
<pre><code class="python"># Construct the URL of the `/indexes` endpoint INDEXES_URL = f"{API_URL}/indexes" # Set the header of the request default_header = { "x-api-key": API_KEY } # Define a function to create an index with a given name def create_index(index_name, index_options, engine): # Declare a dictionary named data data = { "engine_id": engine, "index_options": index_options, "index_name": index_name, } # Create an index response = requests.post(INDEXES_URL, headers=default_header, json=data) # Store the unique identifier of your index INDEX_ID = response.json().get('_id') # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new index was created.") else: print(f"Status code: {response.status_code}") pprint(response.json()) return INDEX_ID # Specify the names of the indexes index_names = ["formula_one", "tokyo_drift"] # Create the indexes index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5") index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5") # Print the created index IDs print(f"Created index IDs: {index_id_formula_one}, {index_id_tokyo_drift}") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8## </code></pre>
동영상 인덱싱 태스크 빌드
특정 폴더 안에 있는 모든 동영상을 자동으로 가져와 파일명과 동일한 이름을 부여하고, 플랫폼에 비동기 방식으로 업로드하도록 코드를 설계했습니다. 인덱스에 포함하고 싶은 모든 동영상을 하나의 폴더에 넣어두기만 하면 됩니다. 비동기 방식으로 'for' 루프를 실행해 개별 스레드 생성 없이 업로드하더라도 시스템 내부적으로는 동시에(동기식으로) 인덱싱 작업을 진행하므로, 총 처리 시간은 가장 긴 동영상의 재생 시간 대비 약 40% 수준에 불과합니다. 나중에 동일한 인덱스에 영상을 추가로 인덱싱하고 싶을 때도 새로운 폴더를 만들 필요가 없습니다. 기존 폴더에 추가하면, 코드가 자동으로 현재 인덱싱을 수행하기 전에 동일한 이름의 연동된 비디오 혹은 대기 중인 태스크가 있는지 먼저 검증합니다. 덕분에 중복 인덱싱 리소스를 아낄 수 있습니다. 꽤 유용하죠? 😄
<pre><code class="python">TASKS_URL = f"{API_URL}/tasks" TASK_ID_LIST = [] video_folder = 'static' # folder containing the video files INDEX_ID = index_id_tom # change this to the other index id while creating the index for lex fridman podcast videos # INDEX_ID = '##38d9c4e4225d1c0eb1e8##' # Iterate through all the video files in the folder for file_name in os.listdir(video_folder): # Validate if a video already exists in the index task_list_response = requests.get( TASKS_URL, headers=default_header, params={"index_id": INDEX_ID, "filename": file_name}, ) if "data" in task_list_response.json(): task_list = task_list_response.json()["data"] if len(task_list) > 0: if task_list[0]['status'] == 'ready': print(f"Video '{file_name}' already exists in index {INDEX_ID}") else: print("task pending or validating") continue #Proceed further to create a new task to index the current video if the video didn't exist in the index already print("Entering task creation code for the file: ", file_name) if file_name.endswith('.mp4'): # Make sure the file is an MP4 video file_path = os.path.join(video_folder, file_name) # Get the full path of the video file with open(file_path, "rb") as file_stream: data = { "index_id": INDEX_ID, "language": "en" } file_param = [ ("video_file", (file_name, file_stream, "application/octet-stream")),] # The video will be indexed on the platform using the same name as the video file itself. response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param) TASK_ID = response.json().get("_id") TASK_ID_LIST.append(TASK_ID) # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new resource was created.") else: print(f"Status code: {response.status_code}") print(f"File name: {file_name}") pprint(response.json()) print("\n") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Entering task creation code for the file: 20211113T190000Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T190000Z.mp4 {'_id': '6438fb6e5b65d57eaecaf8bc'} Entering task creation code for the file: 20211113T193300Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T193300Z.mp4 {'_id': '##38fb755b65d57eaecaf8##'} </code></pre>
인덱싱 진행률 모니터링
현재 인덱싱 중인 비디오의 예상 남은 시간을 보여주도록 모니터링 수식을 설계했습니다. 하나의 인덱싱 작업이 완료되면 병렬 처리 방식에 의해 이미 백그라운드에서 실행하고 있던 다음 비디오 태스크의 모니터링 과정으로 넘어갑니다. 이 과정은 지정된 폴더 내의 모든 비디오 인덱싱이 끝날 때까지 반복됩니다. 마침내 동기 인덱싱에 소요된 최종 시간이 초 단정밀도로 화면에 제공됩니다.
<pre><code class="python">import time def monitor_upload_status(task_id): TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}" while True: response = requests.get(TASK_STATUS_URL, headers=default_header) STATUS = response.json().get("status") if STATUS == "ready": print(f"Task ID: {task_id}, Status code: {STATUS}") break remain_seconds = response.json().get('process', {}).get('remain_seconds', None) upload_percentage = response.json().get('process', {}).get('upload_percentage', None) if remain_seconds is not None: print(f"Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}") else: print('.', end='') time.sleep(10) # Define starting time start = time.time() print("Starting to monitor...") # Monitor the indexing process for all tasks for task_id in TASK_ID_LIST: print("Current Task being monitored: ", task_id) monitor_upload_status(task_id) # Define ending time end = time.time() print("Uploading finished") print("Time elapsed (in seconds): ", end - start) </code></pre>
<pre><code class="language-plaintext">Starting to monitor... Current Task being monitored: ##38fb6e5b65d57eaecaf8## ........Remaining seconds: 264.48919677734375, Upload Percentage: 0 Remaining seconds: 258.351806640625, Upload Percentage: 2 Remaining seconds: 253.1555633544922, Upload Percentage: 4 Remaining seconds: 247.93516540527344, Upload Percentage: 6 Remaining seconds: 242.26431274414062, Upload Percentage: 8 Remaining seconds: 237.22894287109375, Upload Percentage: 10 Remaining seconds: 231.01914978027344, Upload Percentage: 12 Remaining seconds: 224.7932891845703, Upload Percentage: 15 Remaining seconds: 218.599609375, Upload Percentage: 17 </code></pre>
인덱스 내의 모든 동영상 목록 조회
필요한 모든 비디오가 확실히 인덱싱되었는지 확인하기 위해, 인덱스 내에 있는 전체 동영상을 리스트 업하여 최종 검토를 해보겠습니다. 또한 비디오 클립에 할당할 고유 비디오 파일명을 추적할 목적의 전용 연동 매핑 리스트를 빌드합니다. 나중에 이 리스트를 사용하여 추출한 타임스탬프와 파일 매칭 정보를 바탕으로 시각 요소 결과를 표출할 예정입니다.
<pre><code class="python"># List all the videos in an index INDEX_ID='##38d9c4e4225d1c0eb1e8##' INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos" response = requests.get(INDEXES_VIDEOS_URL, headers=default_header) response_json = response.json() pprint(response_json) video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']] print(video_id_name_list) </code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##', 'created_at': '2023-04-17T18:56:51Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T190000Z.mp4', 'fps': 25, 'height': 396, 'size': 415876158, 'width': 704}, 'updated_at': '2023-04-17T19:01:32Z'}, {'_id': '##3d975786daab572f3481##', 'created_at': '2023-04-17T18:56:44Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211114T170000Z.mp4', 'fps': 25, 'height': 396, 'size': 387273943, 'width': 704}, 'updated_at': '2023-04-17T19:00:39Z'}, {'_id': '##3d972e86daab572f3481##', 'created_at': '2023-04-17T18:56:38Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T193000Z.mp4', 'fps': 25, 'height': 396, 'size': 386209689, 'width': 704}, 'updated_at': '2023-04-17T18:59:58Z'}, {'_id': '##3d96d386daab572f3481##', 'created_at': '2023-04-17T18:56:28Z', 'metadata': {'duration': 1800.52, 'engine_id': 'marengo2.5', 'filename': '20211121T133000Z.mp4', 'fps': 25, 'height': 396, 'size': 348611416, 'width': 704}, 'updated_at': '2023-04-17T18:58:27Z'}, {'_id': '##3d96af86daab572f3481##', 'created_at': '2023-04-17T18:56:08Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T200000Z.mp4', 'fps': 25, 'height': 396, 'size': 327766175, 'width': 704}, 'updated_at': '2023-04-17T18:57:51Z'}], 'page_info': {'limit_per_page': 10, 'page': 1, 'total_duration': 9002.76, 'total_page': 1, 'total_results': 5}} [{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'}, {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'}, {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'}, {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'}, {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}] </code></pre>
결합 쿼리
시스템이 비디오 인덱싱과 비디오 임베딩(embedding) 생성을 완료하면, 이제 검색 API를 사용해 의미론적으로 가장 잘 일치하는 순간들을 찾을 준비가 된 것입니다. 이전 튜토리얼에서 단순 쿼리를 사용하는 방법을 살펴보았으니, 여기서는 실용적인 결합 쿼리를 구성하는 데 집중해 보겠습니다.
검색 API는 다음과 같은 연산자들을 주축으로 결합 쿼리 조립을 지원합니다:
AND: 두 단순 쿼리의 교집합을 의미합니다. 예를 들어 "빨간색 자동차"와 "파란색 자동차"라는 두 개의 단순 쿼리를 'and' 연산자로 결합하면, 빨간색 자동차와 파란색 자동차가 동시에 등장하는 모든 장면을 검색합니다.
OR: 두 단순 쿼리의 합집합을 의미합니다. 앞서 든 예시에서 "빨간색 자동차"와 "파란색 자동차"를 'or' 연산자로 결합하면, 빨간색 자동차 혹은 파란색 자동차 중 하나라도 등장하는 모든 장면을 반환합니다.
NOT: 이 연산자를 사용하려면 키가 $not 문자열이고, 값이 origin과 sub라는 두 개의 쿼리로 구성된 딕셔너리 객체를 생성해야 합니다. API는 origin 쿼리 조건에는 부합하나 sub 쿼리 조건에는 부합하지 않는 비디오 세그먼트를 추출합니다. 기존 예시를 적용해보면, origin에 "빨간색 자동차", sub에 "파란색 자동차"를 설정할 경우 빨간색 자동차는 등장하지만 파란색 자동차는 등장하지 않는 비디오 영역을 도출합니다. 여기서 origin과 sub 쿼리 내부에는 하위 쿼리를 원하는 수만큼 추가할 수 있습니다.
THEN: 키가 $then 문자열이고, 값이 객체 배열인 딕셔너리를 생성하여 사용하는 연산자입니다. 배열 내의 각 객체는 하위 쿼리를 나타냅니다. API는 매칭되는 비디오 파편들의 순서가 하위 쿼리들의 명시적 배치 순서와 일치할 때에만 결과를 반환합니다. 따라서 해당 연산을 빌드하면 빨간색 자동차가 먼저 노출되고 이어서 파란색 자동차가 명확한 시퀀스로 관측되는 비디오 장면들을 검출할 수 있습니다.
여기까지가 기본적인 이론이었습니다. 이제 결합 쿼리를 이용해 첫 번째 검색을 실행해 보면서 더 재미있는 응용 단계로 넘어가 보겠습니다. 이 결합 쿼리는 서로 다른 검색 옵션을 가진 두 개의 단순 쿼리를 “AND” 연산자로 결합한 형태입니다. 첫 번째 쿼리는 오디오와 비주얼 모두에서 "트로피를 차지하다(winning trophy)"라는 개념과 의미론적으로 유사한 장면을 찾는 것이고, 두 번째 쿼리는 "crypto.com"이라는 텍스트나 로고가 포함된 장면을 찾는 것입니다. 이 두 쿼리를 결합함으로써 두 기준을 동시에 만족하는 비디오 세그먼트를 찾을 수 있습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "winning trophy", "search_options": ["visual"] }, { "text": "crypto.com", "search_options": ["text_in_video"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 20, 'score': 92.28, 'start': 18, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 43, 'score': 92.28, 'start': 42, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 71, 'score': 92.28, 'start': 61, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 62, 'score': 92.28, 'start': 61, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 67, 'score': 92.28, 'start': 65, 'video_id': '##3d96af86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1', 'page_expired_at': '2023-04-17T23:56:00Z', 'total_results': 110}, 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##', 'total_count': 5, 'total_duration': 9003}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
이 부분은 비디오 이해 모델이 가진 고도화된 지능을 고스란히 체감할 수 있어 매우 흥미롭습니다. 모델은 사람이 비디오 콘텐츠를 이해하는 것과 매우 유사한 방식을 보여줍니다. 위의 스크린샷에서 볼 수 있듯이, 시스템은 제가 지목하고자 했던 정확한 장면을 완벽하게 짚어냈습니다.
이번에는 전체 도쿄 드리프트 영화를 담고 있는 두 번째 인덱스에 대해 간단한 쿼리들을 결합하여 정밀 검색을 시도해 보겠습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "drift", "search_options": ["visual"] }, { "text": "mitsubishi", "search_options": ["logo"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 3710, 'score': 92.28, 'start': 3705, 'video_id': '##3e3ace86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'page_expired_at': '2023-04-18T09:09:59Z', 'total_results': 1}, 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##', 'total_count': 1, 'total_duration': 6246}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
빙고! 이번에도 역시 시스템이 완벽한 시점을 정확하게 잡아냈습니다. 해당 장면은 주인공 숀(루카스 블랙 수 분)이 빨간색 미쓰비시 차량을 몰고 노련하게 드리프트를 구사하는 씬입니다.
각 비디오의 ID, 파일명, 검색된 시작/종료 타임스탬프를 포함하는 파이썬 리스트 구조를 도출해 보겠습니다. 이 리스트 정보를 다음 단계에서 Flask 앱으로 연동하여 웹페이지상에 검색 결과를 표출할 예정입니다.
<pre><code class="python">response_data = response.json() # Extract unique video IDs unique_video_ids = list(set([item['video_id'] for item in response_data['data']])) # Create empty start and end instances lists for each video ID video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids} def find_video_name(video_id, video_id_name_list): for video in video_id_name_list: if video['video_id'] == video_id: return video['video_name'] return None # Append start and end instances to their respective lists for item in response_data['data']: video_id = item['video_id'] video_start_end_lists[video_id]['starts'].append(item['start']) video_start_end_lists[video_id]['ends'].append(item['end']) for video_id, timestamps in video_start_end_lists.items(): video_name = find_video_name(video_id, video_id_name_list) if video_name: timestamps['video_name'] = video_name else: print(f"No video name found for ID '{video_id}'") # Print the result pprint(video_start_end_lists) </code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114], 'starts': [18, 61, 65, 111], 'video_name': '20211113T200000Z.mp4'}, '643d972e86daab572f34810d': {'ends': [84], 'starts': [68], 'video_name': '20211113T193000Z.mp4'}, '643d975786daab572f34810e': {'ends': [79], 'starts': [70], 'video_name': '20211114T170000Z.mp4'}, '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95], 'starts': [42, 61, 84, 91], 'video_name': '20211113T190000Z.mp4'}} </code></pre>
추후 Flask 앱에서 불러올 수 있도록 해당 파이썬 리스트를 로컬 파일에 직렬화(pickle 처리)하여 저장하겠습니다:
<pre><code class="python">import pickle with open("lists.pkl", "wb") as f: pickle.dump(video_start_end_lists, f) </code></pre>
데모 앱 구축하기
이제 마지막 단계입니다. 수신한 JSON 응답값을 활용해 시작 및 종료 지점을 수동으로 직접 재생할 필요 없이 비디오 세그먼트를 깔끔하게 불러와 표시해 보겠습니다. 로컬 드라이브에서 가져온 영상에 해당 타임스탬프를 적용하고, 이를 브라우저상에서 작동하게 할 웹페이지를 제작할 것입니다. 결과적으로 웹페이지 안에 당사 검색 쿼리에 완벽하게 부합하며 시각적으로 훌륭히 편집된 비디오 클립 컬렉션이 예쁘게 나타나게 됩니다.
프로젝트 작업 경로의 폴더 구조 구조는 아래와 같습니다:
<pre><code class="markdown">my_flask_app/ │ app.py │ sample_notebook.ipynb └───templates/ │ │ index.html └───static/ │ <your_video_1.mp4> │ <your_video_2.mp4> │ <your_video_3.mp4> . . . </code></pre>
Flask 애플리케이션 코드
"app.py" 파일에 구현할 코드 구조입니다:
<pre><code class="python"> from flask import Flask, render_template import pickle with open("lists.pkl", "rb") as f: video_start_end_lists = pickle.load(f) app = Flask(__name__) @app.route("/") def index(): return render_template("index_local.html", video_start_end_lists=video_start_end_lists) if __name__ == "__main__": app.run(debug=True) </code></pre>
HTML 템플릿
Jinja2를 지원하는 마크업 정의 파일의 예시입니다. 앞서 빌드해 둔 정렬 데이터를 파싱하고, 로컬 디렉토리에서 대상 원본 영상들을 매핑하며, 최종 브라우저 콘솔상에서 결합 검색 결과를 정확히 슬라이싱해 표출해 줍니다.
<pre><code class="language-html"><!DOCTYPE html> <html> <head> <link rel="shortcut icon" href="#" /> <style> body { background-color: #FFE0B2; /* Light Orange */ font-family: Arial, sans-serif; text-align: center; margin: 0; } h1 { font-size: 3em; color: #000000; /* Black */ background-color: #9ACD32; /* Light Green */ padding: 20px; margin: 0; } h2 { font-size: 2em; color: #000000; /* Black */ margin-bottom: 20px; text-align: left; padding-left: 20px; } .video-container { display: flex; flex-wrap: wrap; padding: 2px; justify-content: space-evenly; gap: 2px; } .video-item { display: flex; flex-direction: column; align-items: center; width: 45%; height: 450px; margin: 20px; text-align: center; background-color: #FFFFFF; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 10px; padding: 20px; } .video-item video { width: 100%; height: 380px; margin: 0; /* Remove bottom margin */ border-radius: 5px; } .video-item p { font-size: 16px; margin-top: 10px; font-weight: bold; color: #212121; /* Dark Grey */ } .video-item span { color: #9ACD32; /* Light Green */ } </style> </head> <body> <h1>My Favorite Scenes</h1> {% for video_id, segments in video_start_end_lists.items() %} <div class="video-section"> <h2>Scenes from {{segments['video_name']}}</h2> <div class="video-container"> {% for i in range(segments['starts']|length) %} <div class="video-item"> <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls> <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4"> Your browser does not support the video tag. </video> <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p> <script> document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() { this.currentTime = {{ segments['starts'][i] }}; }); document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() { if (this.currentTime >= {{ segments['ends'][i] }}) { this.pause(); } }); </script> </div> {% endfor %} </div> </div> {% endfor %} </body> </html> </code></pre>
전제
영화 애호가이자 🎬🍿 콘텐츠 크리에이터로서 🎨🖌️✍️, 저는 소중한 영화 컬렉션을 보관하기 위해 개인 Plex 서버를 구축했습니다. 종종 스토리텔링을 한층 풍부하게 만들고 몰입도 높은 콘텐츠를 제작하기 위해 영화 장면을 일화로 활용하곤 합니다. 예를 들어 동기 부여, 의지력, 역경 극복에 관한 비디오를 제작할 때, 인기 애니메이션인 '드래곤볼 슈퍼' 시리즈의 짜릿한 초사이어인 변신 장면이나 제가 좋아하는 영화 중 하나인 '네버 백 다운'의 운동 및 훈련 장면처럼 관련성 높은 순간들을 보여줄 수 있습니다. 또는 새로운 영화 시나리오를 개발하는 감독이나 작가는 여러 유사한 영화들을 분석하여 코미디 장면의 수, 장면의 지속 시간, 드리프트 레이스의 발생 여부, 머슬카가 노출되는 빈도 등 공통된 테마나 패턴을 식별하고 싶을 것입니다. 방대한 영화 아카이브 속에서, 혹은 단 하나의 영화 안에서조차 특정 장면을 찾아내는 것은 기억력이 아주 뛰어난 사람에게도 꽤나 까다로운 작업입니다. 바로 이 지점에서 비디오 이해(Video Understanding) 기술이 구원투수로 등장합니다 🛟⛑️.
Twelve Labs Search API는 사용자가 간단한 자연어 쿼리를 작성하고 이를 독창적으로 결합하여 관련 비디오 세그먼트를 찾아낼 수 있도록 유연한 비디오 검색 솔루션을 제공합니다. 예를 들어, 결합된 쿼리를 생성하여 주인공이 빨간색 미쓰비시 차량을 운전하는 구체적인 드리프트 장면을 도출해 낼 수 있습니다. 또는 좋아하는 포뮬러 원 차량이 승리의 체커기를 받으며 결승선을 통과하는 스릴 넘치는 순간만을 검색할 수도 있습니다 🏁✌️.

인덱싱된 '패스트 & 퓨리어스: 도쿄 드리프트' 영화에서 결합 쿼리 - 'drift' (검색 옵션: visual) AND 'Mitsubishi' (검색 옵션: logo)로 검색한 결과 😎
소개
이번 튜토리얼 시리즈의 첫 번째 파트에서는 검색 요청에 한 번에 하나의 쿼리만 사용하는 단순 검색을 통해 비디오 내에서 검색을 수행하는 방법을 살펴보았습니다. 이번 후속 튜토리얼을 최대한 활용하기 위해, Twelve Labs Search API의 기본 개념을 이해할 수 있는 이전 튜토리얼을 먼저 복습해 보실 것을 강력히 권장합니다. 기본 개념을 잘 파악하고 계시다면, 이번 튜토리얼에서는 보다 심화된 개념을 소개해 드리겠습니다. 인덱싱된 비디오 내에서 관심 있는 특정 순간을 유연하고 편리하게 찾을 수 있도록 지원하는 Twelve Labs API의 '결합 쿼리' 기능을 자세히 알아보겠습니다. 이를 실전에서 보여주기 위해 포뮬러 원 레이스용 인덱스와 분노의 질주 시리즈 중 하나인 유명한 장편 영화 “도쿄 드리프트”용 인덱스까지 총 두 개의 개별 인덱스를 생성해 보겠습니다. 그런 다음 다양한 연산자를 사용해 검색 쿼리를 결합하여 우리가 찾는 흥미로운 순간들을 식별해 내는 방법을 보여드리겠습니다. 그럼 튜토리얼 개요로 넘어가 이번 가이드를 통해 무엇을 배울 수 있는지 구체적으로 확인해 보겠습니다.
빠른 개요
사전 요구 사항: 이 튜토리얼을 원활하게 진행하기 위해 Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치하세요. 첫 번째 튜토리얼을 확인하는 것도 잊지 마세요!
비디오 업로드: Twelve Labs 플랫폼에 비디오를 전송하면 시스템이 이를 알아서 인덱싱해 줍니다. 인덱싱이 완료되면 복잡한 결합 쿼리를 실행하여 원하는 장면을 정확히 찾아낼 수 있습니다!
결합 쿼리: 이제 진짜 흥미진진한 부분이 시작됩니다! 결합 쿼리는 간단히 말해 두 개 이상의 단순 검색 쿼리를 "or", "and", "not", "then" 등의 연산자를 사용하여 하나의 통합된 쿼리로 결합한 것입니다. 이 연산자들의 이론적인 측면을 가볍게 짚어본 뒤, 두 개 이상의 자연어 쿼리를 효과적으로 결합하는 실전 예제를 다뤄보겠습니다. 이를 통해 인덱싱된 비디오 중에서 결합 쿼리와 의미론적으로(semantically) 일치하는 최적의 장면들을 찾아낼 수 있습니다.
데모 앱 제작하기: 검색 API 결과값을 활용하고 컴퓨터 로컬 폴더에 저장된 비디오에 접근하는 깔끔한 Flask 기반 앱을 구축해 보겠습니다. 그런 다음 맞춤 디자인된 세련된 HTML 페이지를 렌더링하여 검색 결과를 멋지게 보여줍니다.
💡 혹시 개발자가 아니더라도 걱정하지 마세요! 활용하기 쉽게 준비된 Jupyter 노트북 링크를 첨부해 두었습니다. 쿼리와 연산자를 간편하게 수정하고 전체 프로세스를 실행하여 원하는 결과를 바로 얻을 수 있습니다 😄. 재밌게 즐겨보세요!
사전 요구 사항
이번 단계를 진행하기 위한 유일한 사전 요구 사항은 이전 튜토리얼을 숙지하는 것입니다. 이 글이나 이전 글을 읽다가 막히는 부분이 있다면 주저하지 말고 지원을 요청하세요! 저희 Discord 서버에서는 매우 신속하게 답변해 드리고 있습니다 🚅🏎️⚡️. 디스코드 이용이 번거로우시다면 편하게 이메일로 연락해 주셔도 좋습니다. Twelve Labs 계정을 생성한 후 API 대시보드에 접속하여 API 키를 발급받을 수 있습니다. 이번 데모에서는 제가 기존에 사용하던 계정을 활용하겠습니다:
<pre><code class="bash">%env API_KEY=<your_API_key> %env API_URL=https://api.twelvelabs.io/v1.1 </code></pre>
<pre><code class="python">!pip install requests !pip install flask import os import requests import glob from pprint import pprint # Retrieve the URL of the API and my API key API_URL = os.getenv("API_URL") assert API_URL API_KEY = os.getenv("API_KEY") assert API_KEY </code></pre>
비디오 업로드
첫 번째 단계로, 최신 최첨단 비디오 이해 엔진인 "Marengo 2.5"를 사용하여 두 개의 인덱스를 생성하되, 각각 서로 다른 인덱싱 옵션을 지정해 보겠습니다. 포뮬러 원 레이싱 관련 인덱스의 경우 시각 정보(visual)와 대화(conversation) 외에도 비디오 내 텍스트(text-in-video) 및 로고(logo) 옵션을 활성화하는 것이 유리합니다. 레이스 차량, 트랙, 펜스에는 로고가 가득하며 시상식 중 화면에 나타나는 텍스트 분량도 상당하기 때문입니다. 반면 도쿄 드리프트 영화 인덱스에는 텍스트(text-in-video) 옵션을 활성화해도 실질적인 효과가 없을 수 있습니다. 이처럼 다양한 옵션으로 인덱스를 유연하게 조합해 생성하는 기능은 매우 유용합니다. 목적에 맞게 인덱싱 옵션을 정교하게 맞춤 설정함으로써 컴퓨팅 리소스 사용을 최적화하고 최종 비용을 절감할 수 있습니다.
<pre><code class="python"># Construct the URL of the `/indexes` endpoint INDEXES_URL = f"{API_URL}/indexes" # Set the header of the request default_header = { "x-api-key": API_KEY } # Define a function to create an index with a given name def create_index(index_name, index_options, engine): # Declare a dictionary named data data = { "engine_id": engine, "index_options": index_options, "index_name": index_name, } # Create an index response = requests.post(INDEXES_URL, headers=default_header, json=data) # Store the unique identifier of your index INDEX_ID = response.json().get('_id') # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new index was created.") else: print(f"Status code: {response.status_code}") pprint(response.json()) return INDEX_ID # Specify the names of the indexes index_names = ["formula_one", "tokyo_drift"] # Create the indexes index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5") index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5") # Print the created index IDs print(f"Created index IDs: {index_id_formula_one}, {index_id_tokyo_drift}") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8## </code></pre>
동영상 인덱싱 태스크 빌드
특정 폴더 안에 있는 모든 동영상을 자동으로 가져와 파일명과 동일한 이름을 부여하고, 플랫폼에 비동기 방식으로 업로드하도록 코드를 설계했습니다. 인덱스에 포함하고 싶은 모든 동영상을 하나의 폴더에 넣어두기만 하면 됩니다. 비동기 방식으로 'for' 루프를 실행해 개별 스레드 생성 없이 업로드하더라도 시스템 내부적으로는 동시에(동기식으로) 인덱싱 작업을 진행하므로, 총 처리 시간은 가장 긴 동영상의 재생 시간 대비 약 40% 수준에 불과합니다. 나중에 동일한 인덱스에 영상을 추가로 인덱싱하고 싶을 때도 새로운 폴더를 만들 필요가 없습니다. 기존 폴더에 추가하면, 코드가 자동으로 현재 인덱싱을 수행하기 전에 동일한 이름의 연동된 비디오 혹은 대기 중인 태스크가 있는지 먼저 검증합니다. 덕분에 중복 인덱싱 리소스를 아낄 수 있습니다. 꽤 유용하죠? 😄
<pre><code class="python">TASKS_URL = f"{API_URL}/tasks" TASK_ID_LIST = [] video_folder = 'static' # folder containing the video files INDEX_ID = index_id_tom # change this to the other index id while creating the index for lex fridman podcast videos # INDEX_ID = '##38d9c4e4225d1c0eb1e8##' # Iterate through all the video files in the folder for file_name in os.listdir(video_folder): # Validate if a video already exists in the index task_list_response = requests.get( TASKS_URL, headers=default_header, params={"index_id": INDEX_ID, "filename": file_name}, ) if "data" in task_list_response.json(): task_list = task_list_response.json()["data"] if len(task_list) > 0: if task_list[0]['status'] == 'ready': print(f"Video '{file_name}' already exists in index {INDEX_ID}") else: print("task pending or validating") continue #Proceed further to create a new task to index the current video if the video didn't exist in the index already print("Entering task creation code for the file: ", file_name) if file_name.endswith('.mp4'): # Make sure the file is an MP4 video file_path = os.path.join(video_folder, file_name) # Get the full path of the video file with open(file_path, "rb") as file_stream: data = { "index_id": INDEX_ID, "language": "en" } file_param = [ ("video_file", (file_name, file_stream, "application/octet-stream")),] # The video will be indexed on the platform using the same name as the video file itself. response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param) TASK_ID = response.json().get("_id") TASK_ID_LIST.append(TASK_ID) # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new resource was created.") else: print(f"Status code: {response.status_code}") print(f"File name: {file_name}") pprint(response.json()) print("\n") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Entering task creation code for the file: 20211113T190000Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T190000Z.mp4 {'_id': '6438fb6e5b65d57eaecaf8bc'} Entering task creation code for the file: 20211113T193300Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T193300Z.mp4 {'_id': '##38fb755b65d57eaecaf8##'} </code></pre>
인덱싱 진행률 모니터링
현재 인덱싱 중인 비디오의 예상 남은 시간을 보여주도록 모니터링 수식을 설계했습니다. 하나의 인덱싱 작업이 완료되면 병렬 처리 방식에 의해 이미 백그라운드에서 실행하고 있던 다음 비디오 태스크의 모니터링 과정으로 넘어갑니다. 이 과정은 지정된 폴더 내의 모든 비디오 인덱싱이 끝날 때까지 반복됩니다. 마침내 동기 인덱싱에 소요된 최종 시간이 초 단정밀도로 화면에 제공됩니다.
<pre><code class="python">import time def monitor_upload_status(task_id): TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}" while True: response = requests.get(TASK_STATUS_URL, headers=default_header) STATUS = response.json().get("status") if STATUS == "ready": print(f"Task ID: {task_id}, Status code: {STATUS}") break remain_seconds = response.json().get('process', {}).get('remain_seconds', None) upload_percentage = response.json().get('process', {}).get('upload_percentage', None) if remain_seconds is not None: print(f"Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}") else: print('.', end='') time.sleep(10) # Define starting time start = time.time() print("Starting to monitor...") # Monitor the indexing process for all tasks for task_id in TASK_ID_LIST: print("Current Task being monitored: ", task_id) monitor_upload_status(task_id) # Define ending time end = time.time() print("Uploading finished") print("Time elapsed (in seconds): ", end - start) </code></pre>
<pre><code class="language-plaintext">Starting to monitor... Current Task being monitored: ##38fb6e5b65d57eaecaf8## ........Remaining seconds: 264.48919677734375, Upload Percentage: 0 Remaining seconds: 258.351806640625, Upload Percentage: 2 Remaining seconds: 253.1555633544922, Upload Percentage: 4 Remaining seconds: 247.93516540527344, Upload Percentage: 6 Remaining seconds: 242.26431274414062, Upload Percentage: 8 Remaining seconds: 237.22894287109375, Upload Percentage: 10 Remaining seconds: 231.01914978027344, Upload Percentage: 12 Remaining seconds: 224.7932891845703, Upload Percentage: 15 Remaining seconds: 218.599609375, Upload Percentage: 17 </code></pre>
인덱스 내의 모든 동영상 목록 조회
필요한 모든 비디오가 확실히 인덱싱되었는지 확인하기 위해, 인덱스 내에 있는 전체 동영상을 리스트 업하여 최종 검토를 해보겠습니다. 또한 비디오 클립에 할당할 고유 비디오 파일명을 추적할 목적의 전용 연동 매핑 리스트를 빌드합니다. 나중에 이 리스트를 사용하여 추출한 타임스탬프와 파일 매칭 정보를 바탕으로 시각 요소 결과를 표출할 예정입니다.
<pre><code class="python"># List all the videos in an index INDEX_ID='##38d9c4e4225d1c0eb1e8##' INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos" response = requests.get(INDEXES_VIDEOS_URL, headers=default_header) response_json = response.json() pprint(response_json) video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']] print(video_id_name_list) </code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##', 'created_at': '2023-04-17T18:56:51Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T190000Z.mp4', 'fps': 25, 'height': 396, 'size': 415876158, 'width': 704}, 'updated_at': '2023-04-17T19:01:32Z'}, {'_id': '##3d975786daab572f3481##', 'created_at': '2023-04-17T18:56:44Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211114T170000Z.mp4', 'fps': 25, 'height': 396, 'size': 387273943, 'width': 704}, 'updated_at': '2023-04-17T19:00:39Z'}, {'_id': '##3d972e86daab572f3481##', 'created_at': '2023-04-17T18:56:38Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T193000Z.mp4', 'fps': 25, 'height': 396, 'size': 386209689, 'width': 704}, 'updated_at': '2023-04-17T18:59:58Z'}, {'_id': '##3d96d386daab572f3481##', 'created_at': '2023-04-17T18:56:28Z', 'metadata': {'duration': 1800.52, 'engine_id': 'marengo2.5', 'filename': '20211121T133000Z.mp4', 'fps': 25, 'height': 396, 'size': 348611416, 'width': 704}, 'updated_at': '2023-04-17T18:58:27Z'}, {'_id': '##3d96af86daab572f3481##', 'created_at': '2023-04-17T18:56:08Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T200000Z.mp4', 'fps': 25, 'height': 396, 'size': 327766175, 'width': 704}, 'updated_at': '2023-04-17T18:57:51Z'}], 'page_info': {'limit_per_page': 10, 'page': 1, 'total_duration': 9002.76, 'total_page': 1, 'total_results': 5}} [{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'}, {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'}, {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'}, {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'}, {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}] </code></pre>
결합 쿼리
시스템이 비디오 인덱싱과 비디오 임베딩(embedding) 생성을 완료하면, 이제 검색 API를 사용해 의미론적으로 가장 잘 일치하는 순간들을 찾을 준비가 된 것입니다. 이전 튜토리얼에서 단순 쿼리를 사용하는 방법을 살펴보았으니, 여기서는 실용적인 결합 쿼리를 구성하는 데 집중해 보겠습니다.
검색 API는 다음과 같은 연산자들을 주축으로 결합 쿼리 조립을 지원합니다:
AND: 두 단순 쿼리의 교집합을 의미합니다. 예를 들어 "빨간색 자동차"와 "파란색 자동차"라는 두 개의 단순 쿼리를 'and' 연산자로 결합하면, 빨간색 자동차와 파란색 자동차가 동시에 등장하는 모든 장면을 검색합니다.
OR: 두 단순 쿼리의 합집합을 의미합니다. 앞서 든 예시에서 "빨간색 자동차"와 "파란색 자동차"를 'or' 연산자로 결합하면, 빨간색 자동차 혹은 파란색 자동차 중 하나라도 등장하는 모든 장면을 반환합니다.
NOT: 이 연산자를 사용하려면 키가 $not 문자열이고, 값이 origin과 sub라는 두 개의 쿼리로 구성된 딕셔너리 객체를 생성해야 합니다. API는 origin 쿼리 조건에는 부합하나 sub 쿼리 조건에는 부합하지 않는 비디오 세그먼트를 추출합니다. 기존 예시를 적용해보면, origin에 "빨간색 자동차", sub에 "파란색 자동차"를 설정할 경우 빨간색 자동차는 등장하지만 파란색 자동차는 등장하지 않는 비디오 영역을 도출합니다. 여기서 origin과 sub 쿼리 내부에는 하위 쿼리를 원하는 수만큼 추가할 수 있습니다.
THEN: 키가 $then 문자열이고, 값이 객체 배열인 딕셔너리를 생성하여 사용하는 연산자입니다. 배열 내의 각 객체는 하위 쿼리를 나타냅니다. API는 매칭되는 비디오 파편들의 순서가 하위 쿼리들의 명시적 배치 순서와 일치할 때에만 결과를 반환합니다. 따라서 해당 연산을 빌드하면 빨간색 자동차가 먼저 노출되고 이어서 파란색 자동차가 명확한 시퀀스로 관측되는 비디오 장면들을 검출할 수 있습니다.
여기까지가 기본적인 이론이었습니다. 이제 결합 쿼리를 이용해 첫 번째 검색을 실행해 보면서 더 재미있는 응용 단계로 넘어가 보겠습니다. 이 결합 쿼리는 서로 다른 검색 옵션을 가진 두 개의 단순 쿼리를 “AND” 연산자로 결합한 형태입니다. 첫 번째 쿼리는 오디오와 비주얼 모두에서 "트로피를 차지하다(winning trophy)"라는 개념과 의미론적으로 유사한 장면을 찾는 것이고, 두 번째 쿼리는 "crypto.com"이라는 텍스트나 로고가 포함된 장면을 찾는 것입니다. 이 두 쿼리를 결합함으로써 두 기준을 동시에 만족하는 비디오 세그먼트를 찾을 수 있습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "winning trophy", "search_options": ["visual"] }, { "text": "crypto.com", "search_options": ["text_in_video"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 20, 'score': 92.28, 'start': 18, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 43, 'score': 92.28, 'start': 42, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 71, 'score': 92.28, 'start': 61, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 62, 'score': 92.28, 'start': 61, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 67, 'score': 92.28, 'start': 65, 'video_id': '##3d96af86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1', 'page_expired_at': '2023-04-17T23:56:00Z', 'total_results': 110}, 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##', 'total_count': 5, 'total_duration': 9003}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
이 부분은 비디오 이해 모델이 가진 고도화된 지능을 고스란히 체감할 수 있어 매우 흥미롭습니다. 모델은 사람이 비디오 콘텐츠를 이해하는 것과 매우 유사한 방식을 보여줍니다. 위의 스크린샷에서 볼 수 있듯이, 시스템은 제가 지목하고자 했던 정확한 장면을 완벽하게 짚어냈습니다.
이번에는 전체 도쿄 드리프트 영화를 담고 있는 두 번째 인덱스에 대해 간단한 쿼리들을 결합하여 정밀 검색을 시도해 보겠습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "drift", "search_options": ["visual"] }, { "text": "mitsubishi", "search_options": ["logo"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 3710, 'score': 92.28, 'start': 3705, 'video_id': '##3e3ace86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'page_expired_at': '2023-04-18T09:09:59Z', 'total_results': 1}, 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##', 'total_count': 1, 'total_duration': 6246}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
빙고! 이번에도 역시 시스템이 완벽한 시점을 정확하게 잡아냈습니다. 해당 장면은 주인공 숀(루카스 블랙 수 분)이 빨간색 미쓰비시 차량을 몰고 노련하게 드리프트를 구사하는 씬입니다.
각 비디오의 ID, 파일명, 검색된 시작/종료 타임스탬프를 포함하는 파이썬 리스트 구조를 도출해 보겠습니다. 이 리스트 정보를 다음 단계에서 Flask 앱으로 연동하여 웹페이지상에 검색 결과를 표출할 예정입니다.
<pre><code class="python">response_data = response.json() # Extract unique video IDs unique_video_ids = list(set([item['video_id'] for item in response_data['data']])) # Create empty start and end instances lists for each video ID video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids} def find_video_name(video_id, video_id_name_list): for video in video_id_name_list: if video['video_id'] == video_id: return video['video_name'] return None # Append start and end instances to their respective lists for item in response_data['data']: video_id = item['video_id'] video_start_end_lists[video_id]['starts'].append(item['start']) video_start_end_lists[video_id]['ends'].append(item['end']) for video_id, timestamps in video_start_end_lists.items(): video_name = find_video_name(video_id, video_id_name_list) if video_name: timestamps['video_name'] = video_name else: print(f"No video name found for ID '{video_id}'") # Print the result pprint(video_start_end_lists) </code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114], 'starts': [18, 61, 65, 111], 'video_name': '20211113T200000Z.mp4'}, '643d972e86daab572f34810d': {'ends': [84], 'starts': [68], 'video_name': '20211113T193000Z.mp4'}, '643d975786daab572f34810e': {'ends': [79], 'starts': [70], 'video_name': '20211114T170000Z.mp4'}, '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95], 'starts': [42, 61, 84, 91], 'video_name': '20211113T190000Z.mp4'}} </code></pre>
추후 Flask 앱에서 불러올 수 있도록 해당 파이썬 리스트를 로컬 파일에 직렬화(pickle 처리)하여 저장하겠습니다:
<pre><code class="python">import pickle with open("lists.pkl", "wb") as f: pickle.dump(video_start_end_lists, f) </code></pre>
데모 앱 구축하기
이제 마지막 단계입니다. 수신한 JSON 응답값을 활용해 시작 및 종료 지점을 수동으로 직접 재생할 필요 없이 비디오 세그먼트를 깔끔하게 불러와 표시해 보겠습니다. 로컬 드라이브에서 가져온 영상에 해당 타임스탬프를 적용하고, 이를 브라우저상에서 작동하게 할 웹페이지를 제작할 것입니다. 결과적으로 웹페이지 안에 당사 검색 쿼리에 완벽하게 부합하며 시각적으로 훌륭히 편집된 비디오 클립 컬렉션이 예쁘게 나타나게 됩니다.
프로젝트 작업 경로의 폴더 구조 구조는 아래와 같습니다:
<pre><code class="markdown">my_flask_app/ │ app.py │ sample_notebook.ipynb └───templates/ │ │ index.html └───static/ │ <your_video_1.mp4> │ <your_video_2.mp4> │ <your_video_3.mp4> . . . </code></pre>
Flask 애플리케이션 코드
"app.py" 파일에 구현할 코드 구조입니다:
<pre><code class="python"> from flask import Flask, render_template import pickle with open("lists.pkl", "rb") as f: video_start_end_lists = pickle.load(f) app = Flask(__name__) @app.route("/") def index(): return render_template("index_local.html", video_start_end_lists=video_start_end_lists) if __name__ == "__main__": app.run(debug=True) </code></pre>
HTML 템플릿
Jinja2를 지원하는 마크업 정의 파일의 예시입니다. 앞서 빌드해 둔 정렬 데이터를 파싱하고, 로컬 디렉토리에서 대상 원본 영상들을 매핑하며, 최종 브라우저 콘솔상에서 결합 검색 결과를 정확히 슬라이싱해 표출해 줍니다.
<pre><code class="language-html"><!DOCTYPE html> <html> <head> <link rel="shortcut icon" href="#" /> <style> body { background-color: #FFE0B2; /* Light Orange */ font-family: Arial, sans-serif; text-align: center; margin: 0; } h1 { font-size: 3em; color: #000000; /* Black */ background-color: #9ACD32; /* Light Green */ padding: 20px; margin: 0; } h2 { font-size: 2em; color: #000000; /* Black */ margin-bottom: 20px; text-align: left; padding-left: 20px; } .video-container { display: flex; flex-wrap: wrap; padding: 2px; justify-content: space-evenly; gap: 2px; } .video-item { display: flex; flex-direction: column; align-items: center; width: 45%; height: 450px; margin: 20px; text-align: center; background-color: #FFFFFF; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 10px; padding: 20px; } .video-item video { width: 100%; height: 380px; margin: 0; /* Remove bottom margin */ border-radius: 5px; } .video-item p { font-size: 16px; margin-top: 10px; font-weight: bold; color: #212121; /* Dark Grey */ } .video-item span { color: #9ACD32; /* Light Green */ } </style> </head> <body> <h1>My Favorite Scenes</h1> {% for video_id, segments in video_start_end_lists.items() %} <div class="video-section"> <h2>Scenes from {{segments['video_name']}}</h2> <div class="video-container"> {% for i in range(segments['starts']|length) %} <div class="video-item"> <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls> <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4"> Your browser does not support the video tag. </video> <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p> <script> document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() { this.currentTime = {{ segments['starts'][i] }}; }); document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() { if (this.currentTime >= {{ segments['ends'][i] }}) { this.pause(); } }); </script> </div> {% endfor %} </div> </div> {% endfor %} </body> </html> </code></pre>
전제
영화 애호가이자 🎬🍿 콘텐츠 크리에이터로서 🎨🖌️✍️, 저는 소중한 영화 컬렉션을 보관하기 위해 개인 Plex 서버를 구축했습니다. 종종 스토리텔링을 한층 풍부하게 만들고 몰입도 높은 콘텐츠를 제작하기 위해 영화 장면을 일화로 활용하곤 합니다. 예를 들어 동기 부여, 의지력, 역경 극복에 관한 비디오를 제작할 때, 인기 애니메이션인 '드래곤볼 슈퍼' 시리즈의 짜릿한 초사이어인 변신 장면이나 제가 좋아하는 영화 중 하나인 '네버 백 다운'의 운동 및 훈련 장면처럼 관련성 높은 순간들을 보여줄 수 있습니다. 또는 새로운 영화 시나리오를 개발하는 감독이나 작가는 여러 유사한 영화들을 분석하여 코미디 장면의 수, 장면의 지속 시간, 드리프트 레이스의 발생 여부, 머슬카가 노출되는 빈도 등 공통된 테마나 패턴을 식별하고 싶을 것입니다. 방대한 영화 아카이브 속에서, 혹은 단 하나의 영화 안에서조차 특정 장면을 찾아내는 것은 기억력이 아주 뛰어난 사람에게도 꽤나 까다로운 작업입니다. 바로 이 지점에서 비디오 이해(Video Understanding) 기술이 구원투수로 등장합니다 🛟⛑️.
Twelve Labs Search API는 사용자가 간단한 자연어 쿼리를 작성하고 이를 독창적으로 결합하여 관련 비디오 세그먼트를 찾아낼 수 있도록 유연한 비디오 검색 솔루션을 제공합니다. 예를 들어, 결합된 쿼리를 생성하여 주인공이 빨간색 미쓰비시 차량을 운전하는 구체적인 드리프트 장면을 도출해 낼 수 있습니다. 또는 좋아하는 포뮬러 원 차량이 승리의 체커기를 받으며 결승선을 통과하는 스릴 넘치는 순간만을 검색할 수도 있습니다 🏁✌️.

인덱싱된 '패스트 & 퓨리어스: 도쿄 드리프트' 영화에서 결합 쿼리 - 'drift' (검색 옵션: visual) AND 'Mitsubishi' (검색 옵션: logo)로 검색한 결과 😎
소개
이번 튜토리얼 시리즈의 첫 번째 파트에서는 검색 요청에 한 번에 하나의 쿼리만 사용하는 단순 검색을 통해 비디오 내에서 검색을 수행하는 방법을 살펴보았습니다. 이번 후속 튜토리얼을 최대한 활용하기 위해, Twelve Labs Search API의 기본 개념을 이해할 수 있는 이전 튜토리얼을 먼저 복습해 보실 것을 강력히 권장합니다. 기본 개념을 잘 파악하고 계시다면, 이번 튜토리얼에서는 보다 심화된 개념을 소개해 드리겠습니다. 인덱싱된 비디오 내에서 관심 있는 특정 순간을 유연하고 편리하게 찾을 수 있도록 지원하는 Twelve Labs API의 '결합 쿼리' 기능을 자세히 알아보겠습니다. 이를 실전에서 보여주기 위해 포뮬러 원 레이스용 인덱스와 분노의 질주 시리즈 중 하나인 유명한 장편 영화 “도쿄 드리프트”용 인덱스까지 총 두 개의 개별 인덱스를 생성해 보겠습니다. 그런 다음 다양한 연산자를 사용해 검색 쿼리를 결합하여 우리가 찾는 흥미로운 순간들을 식별해 내는 방법을 보여드리겠습니다. 그럼 튜토리얼 개요로 넘어가 이번 가이드를 통해 무엇을 배울 수 있는지 구체적으로 확인해 보겠습니다.
빠른 개요
사전 요구 사항: 이 튜토리얼을 원활하게 진행하기 위해 Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치하세요. 첫 번째 튜토리얼을 확인하는 것도 잊지 마세요!
비디오 업로드: Twelve Labs 플랫폼에 비디오를 전송하면 시스템이 이를 알아서 인덱싱해 줍니다. 인덱싱이 완료되면 복잡한 결합 쿼리를 실행하여 원하는 장면을 정확히 찾아낼 수 있습니다!
결합 쿼리: 이제 진짜 흥미진진한 부분이 시작됩니다! 결합 쿼리는 간단히 말해 두 개 이상의 단순 검색 쿼리를 "or", "and", "not", "then" 등의 연산자를 사용하여 하나의 통합된 쿼리로 결합한 것입니다. 이 연산자들의 이론적인 측면을 가볍게 짚어본 뒤, 두 개 이상의 자연어 쿼리를 효과적으로 결합하는 실전 예제를 다뤄보겠습니다. 이를 통해 인덱싱된 비디오 중에서 결합 쿼리와 의미론적으로(semantically) 일치하는 최적의 장면들을 찾아낼 수 있습니다.
데모 앱 제작하기: 검색 API 결과값을 활용하고 컴퓨터 로컬 폴더에 저장된 비디오에 접근하는 깔끔한 Flask 기반 앱을 구축해 보겠습니다. 그런 다음 맞춤 디자인된 세련된 HTML 페이지를 렌더링하여 검색 결과를 멋지게 보여줍니다.
💡 혹시 개발자가 아니더라도 걱정하지 마세요! 활용하기 쉽게 준비된 Jupyter 노트북 링크를 첨부해 두었습니다. 쿼리와 연산자를 간편하게 수정하고 전체 프로세스를 실행하여 원하는 결과를 바로 얻을 수 있습니다 😄. 재밌게 즐겨보세요!
사전 요구 사항
이번 단계를 진행하기 위한 유일한 사전 요구 사항은 이전 튜토리얼을 숙지하는 것입니다. 이 글이나 이전 글을 읽다가 막히는 부분이 있다면 주저하지 말고 지원을 요청하세요! 저희 Discord 서버에서는 매우 신속하게 답변해 드리고 있습니다 🚅🏎️⚡️. 디스코드 이용이 번거로우시다면 편하게 이메일로 연락해 주셔도 좋습니다. Twelve Labs 계정을 생성한 후 API 대시보드에 접속하여 API 키를 발급받을 수 있습니다. 이번 데모에서는 제가 기존에 사용하던 계정을 활용하겠습니다:
<pre><code class="bash">%env API_KEY=<your_API_key> %env API_URL=https://api.twelvelabs.io/v1.1 </code></pre>
<pre><code class="python">!pip install requests !pip install flask import os import requests import glob from pprint import pprint # Retrieve the URL of the API and my API key API_URL = os.getenv("API_URL") assert API_URL API_KEY = os.getenv("API_KEY") assert API_KEY </code></pre>
비디오 업로드
첫 번째 단계로, 최신 최첨단 비디오 이해 엔진인 "Marengo 2.5"를 사용하여 두 개의 인덱스를 생성하되, 각각 서로 다른 인덱싱 옵션을 지정해 보겠습니다. 포뮬러 원 레이싱 관련 인덱스의 경우 시각 정보(visual)와 대화(conversation) 외에도 비디오 내 텍스트(text-in-video) 및 로고(logo) 옵션을 활성화하는 것이 유리합니다. 레이스 차량, 트랙, 펜스에는 로고가 가득하며 시상식 중 화면에 나타나는 텍스트 분량도 상당하기 때문입니다. 반면 도쿄 드리프트 영화 인덱스에는 텍스트(text-in-video) 옵션을 활성화해도 실질적인 효과가 없을 수 있습니다. 이처럼 다양한 옵션으로 인덱스를 유연하게 조합해 생성하는 기능은 매우 유용합니다. 목적에 맞게 인덱싱 옵션을 정교하게 맞춤 설정함으로써 컴퓨팅 리소스 사용을 최적화하고 최종 비용을 절감할 수 있습니다.
<pre><code class="python"># Construct the URL of the `/indexes` endpoint INDEXES_URL = f"{API_URL}/indexes" # Set the header of the request default_header = { "x-api-key": API_KEY } # Define a function to create an index with a given name def create_index(index_name, index_options, engine): # Declare a dictionary named data data = { "engine_id": engine, "index_options": index_options, "index_name": index_name, } # Create an index response = requests.post(INDEXES_URL, headers=default_header, json=data) # Store the unique identifier of your index INDEX_ID = response.json().get('_id') # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new index was created.") else: print(f"Status code: {response.status_code}") pprint(response.json()) return INDEX_ID # Specify the names of the indexes index_names = ["formula_one", "tokyo_drift"] # Create the indexes index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5") index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5") # Print the created index IDs print(f"Created index IDs: {index_id_formula_one}, {index_id_tokyo_drift}") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Status code: 201 - The request was successful and a new resource was created. {'_id': '##38fb695b65d57eaecaf8##'} Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8## </code></pre>
동영상 인덱싱 태스크 빌드
특정 폴더 안에 있는 모든 동영상을 자동으로 가져와 파일명과 동일한 이름을 부여하고, 플랫폼에 비동기 방식으로 업로드하도록 코드를 설계했습니다. 인덱스에 포함하고 싶은 모든 동영상을 하나의 폴더에 넣어두기만 하면 됩니다. 비동기 방식으로 'for' 루프를 실행해 개별 스레드 생성 없이 업로드하더라도 시스템 내부적으로는 동시에(동기식으로) 인덱싱 작업을 진행하므로, 총 처리 시간은 가장 긴 동영상의 재생 시간 대비 약 40% 수준에 불과합니다. 나중에 동일한 인덱스에 영상을 추가로 인덱싱하고 싶을 때도 새로운 폴더를 만들 필요가 없습니다. 기존 폴더에 추가하면, 코드가 자동으로 현재 인덱싱을 수행하기 전에 동일한 이름의 연동된 비디오 혹은 대기 중인 태스크가 있는지 먼저 검증합니다. 덕분에 중복 인덱싱 리소스를 아낄 수 있습니다. 꽤 유용하죠? 😄
<pre><code class="python">TASKS_URL = f"{API_URL}/tasks" TASK_ID_LIST = [] video_folder = 'static' # folder containing the video files INDEX_ID = index_id_tom # change this to the other index id while creating the index for lex fridman podcast videos # INDEX_ID = '##38d9c4e4225d1c0eb1e8##' # Iterate through all the video files in the folder for file_name in os.listdir(video_folder): # Validate if a video already exists in the index task_list_response = requests.get( TASKS_URL, headers=default_header, params={"index_id": INDEX_ID, "filename": file_name}, ) if "data" in task_list_response.json(): task_list = task_list_response.json()["data"] if len(task_list) > 0: if task_list[0]['status'] == 'ready': print(f"Video '{file_name}' already exists in index {INDEX_ID}") else: print("task pending or validating") continue #Proceed further to create a new task to index the current video if the video didn't exist in the index already print("Entering task creation code for the file: ", file_name) if file_name.endswith('.mp4'): # Make sure the file is an MP4 video file_path = os.path.join(video_folder, file_name) # Get the full path of the video file with open(file_path, "rb") as file_stream: data = { "index_id": INDEX_ID, "language": "en" } file_param = [ ("video_file", (file_name, file_stream, "application/octet-stream")),] # The video will be indexed on the platform using the same name as the video file itself. response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param) TASK_ID = response.json().get("_id") TASK_ID_LIST.append(TASK_ID) # Check if the status code is 201 and print success if response.status_code == 201: print(f"Status code: {response.status_code} - The request was successful and a new resource was created.") else: print(f"Status code: {response.status_code}") print(f"File name: {file_name}") pprint(response.json()) print("\n") </code></pre>
출력 결과:
<pre><code class="language-plaintext">Entering task creation code for the file: 20211113T190000Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T190000Z.mp4 {'_id': '6438fb6e5b65d57eaecaf8bc'} Entering task creation code for the file: 20211113T193300Z.mp4 Status code: 201 - The request was successful and a new resource was created. File name: 20211113T193300Z.mp4 {'_id': '##38fb755b65d57eaecaf8##'} </code></pre>
인덱싱 진행률 모니터링
현재 인덱싱 중인 비디오의 예상 남은 시간을 보여주도록 모니터링 수식을 설계했습니다. 하나의 인덱싱 작업이 완료되면 병렬 처리 방식에 의해 이미 백그라운드에서 실행하고 있던 다음 비디오 태스크의 모니터링 과정으로 넘어갑니다. 이 과정은 지정된 폴더 내의 모든 비디오 인덱싱이 끝날 때까지 반복됩니다. 마침내 동기 인덱싱에 소요된 최종 시간이 초 단정밀도로 화면에 제공됩니다.
<pre><code class="python">import time def monitor_upload_status(task_id): TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}" while True: response = requests.get(TASK_STATUS_URL, headers=default_header) STATUS = response.json().get("status") if STATUS == "ready": print(f"Task ID: {task_id}, Status code: {STATUS}") break remain_seconds = response.json().get('process', {}).get('remain_seconds', None) upload_percentage = response.json().get('process', {}).get('upload_percentage', None) if remain_seconds is not None: print(f"Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}") else: print('.', end='') time.sleep(10) # Define starting time start = time.time() print("Starting to monitor...") # Monitor the indexing process for all tasks for task_id in TASK_ID_LIST: print("Current Task being monitored: ", task_id) monitor_upload_status(task_id) # Define ending time end = time.time() print("Uploading finished") print("Time elapsed (in seconds): ", end - start) </code></pre>
<pre><code class="language-plaintext">Starting to monitor... Current Task being monitored: ##38fb6e5b65d57eaecaf8## ........Remaining seconds: 264.48919677734375, Upload Percentage: 0 Remaining seconds: 258.351806640625, Upload Percentage: 2 Remaining seconds: 253.1555633544922, Upload Percentage: 4 Remaining seconds: 247.93516540527344, Upload Percentage: 6 Remaining seconds: 242.26431274414062, Upload Percentage: 8 Remaining seconds: 237.22894287109375, Upload Percentage: 10 Remaining seconds: 231.01914978027344, Upload Percentage: 12 Remaining seconds: 224.7932891845703, Upload Percentage: 15 Remaining seconds: 218.599609375, Upload Percentage: 17 </code></pre>
인덱스 내의 모든 동영상 목록 조회
필요한 모든 비디오가 확실히 인덱싱되었는지 확인하기 위해, 인덱스 내에 있는 전체 동영상을 리스트 업하여 최종 검토를 해보겠습니다. 또한 비디오 클립에 할당할 고유 비디오 파일명을 추적할 목적의 전용 연동 매핑 리스트를 빌드합니다. 나중에 이 리스트를 사용하여 추출한 타임스탬프와 파일 매칭 정보를 바탕으로 시각 요소 결과를 표출할 예정입니다.
<pre><code class="python"># List all the videos in an index INDEX_ID='##38d9c4e4225d1c0eb1e8##' INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos" response = requests.get(INDEXES_VIDEOS_URL, headers=default_header) response_json = response.json() pprint(response_json) video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']] print(video_id_name_list) </code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##', 'created_at': '2023-04-17T18:56:51Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T190000Z.mp4', 'fps': 25, 'height': 396, 'size': 415876158, 'width': 704}, 'updated_at': '2023-04-17T19:01:32Z'}, {'_id': '##3d975786daab572f3481##', 'created_at': '2023-04-17T18:56:44Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211114T170000Z.mp4', 'fps': 25, 'height': 396, 'size': 387273943, 'width': 704}, 'updated_at': '2023-04-17T19:00:39Z'}, {'_id': '##3d972e86daab572f3481##', 'created_at': '2023-04-17T18:56:38Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T193000Z.mp4', 'fps': 25, 'height': 396, 'size': 386209689, 'width': 704}, 'updated_at': '2023-04-17T18:59:58Z'}, {'_id': '##3d96d386daab572f3481##', 'created_at': '2023-04-17T18:56:28Z', 'metadata': {'duration': 1800.52, 'engine_id': 'marengo2.5', 'filename': '20211121T133000Z.mp4', 'fps': 25, 'height': 396, 'size': 348611416, 'width': 704}, 'updated_at': '2023-04-17T18:58:27Z'}, {'_id': '##3d96af86daab572f3481##', 'created_at': '2023-04-17T18:56:08Z', 'metadata': {'duration': 1800.56, 'engine_id': 'marengo2.5', 'filename': '20211113T200000Z.mp4', 'fps': 25, 'height': 396, 'size': 327766175, 'width': 704}, 'updated_at': '2023-04-17T18:57:51Z'}], 'page_info': {'limit_per_page': 10, 'page': 1, 'total_duration': 9002.76, 'total_page': 1, 'total_results': 5}} [{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'}, {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'}, {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'}, {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'}, {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}] </code></pre>
결합 쿼리
시스템이 비디오 인덱싱과 비디오 임베딩(embedding) 생성을 완료하면, 이제 검색 API를 사용해 의미론적으로 가장 잘 일치하는 순간들을 찾을 준비가 된 것입니다. 이전 튜토리얼에서 단순 쿼리를 사용하는 방법을 살펴보았으니, 여기서는 실용적인 결합 쿼리를 구성하는 데 집중해 보겠습니다.
검색 API는 다음과 같은 연산자들을 주축으로 결합 쿼리 조립을 지원합니다:
AND: 두 단순 쿼리의 교집합을 의미합니다. 예를 들어 "빨간색 자동차"와 "파란색 자동차"라는 두 개의 단순 쿼리를 'and' 연산자로 결합하면, 빨간색 자동차와 파란색 자동차가 동시에 등장하는 모든 장면을 검색합니다.
OR: 두 단순 쿼리의 합집합을 의미합니다. 앞서 든 예시에서 "빨간색 자동차"와 "파란색 자동차"를 'or' 연산자로 결합하면, 빨간색 자동차 혹은 파란색 자동차 중 하나라도 등장하는 모든 장면을 반환합니다.
NOT: 이 연산자를 사용하려면 키가 $not 문자열이고, 값이 origin과 sub라는 두 개의 쿼리로 구성된 딕셔너리 객체를 생성해야 합니다. API는 origin 쿼리 조건에는 부합하나 sub 쿼리 조건에는 부합하지 않는 비디오 세그먼트를 추출합니다. 기존 예시를 적용해보면, origin에 "빨간색 자동차", sub에 "파란색 자동차"를 설정할 경우 빨간색 자동차는 등장하지만 파란색 자동차는 등장하지 않는 비디오 영역을 도출합니다. 여기서 origin과 sub 쿼리 내부에는 하위 쿼리를 원하는 수만큼 추가할 수 있습니다.
THEN: 키가 $then 문자열이고, 값이 객체 배열인 딕셔너리를 생성하여 사용하는 연산자입니다. 배열 내의 각 객체는 하위 쿼리를 나타냅니다. API는 매칭되는 비디오 파편들의 순서가 하위 쿼리들의 명시적 배치 순서와 일치할 때에만 결과를 반환합니다. 따라서 해당 연산을 빌드하면 빨간색 자동차가 먼저 노출되고 이어서 파란색 자동차가 명확한 시퀀스로 관측되는 비디오 장면들을 검출할 수 있습니다.
여기까지가 기본적인 이론이었습니다. 이제 결합 쿼리를 이용해 첫 번째 검색을 실행해 보면서 더 재미있는 응용 단계로 넘어가 보겠습니다. 이 결합 쿼리는 서로 다른 검색 옵션을 가진 두 개의 단순 쿼리를 “AND” 연산자로 결합한 형태입니다. 첫 번째 쿼리는 오디오와 비주얼 모두에서 "트로피를 차지하다(winning trophy)"라는 개념과 의미론적으로 유사한 장면을 찾는 것이고, 두 번째 쿼리는 "crypto.com"이라는 텍스트나 로고가 포함된 장면을 찾는 것입니다. 이 두 쿼리를 결합함으로써 두 기준을 동시에 만족하는 비디오 세그먼트를 찾을 수 있습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "winning trophy", "search_options": ["visual"] }, { "text": "crypto.com", "search_options": ["text_in_video"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 20, 'score': 92.28, 'start': 18, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 43, 'score': 92.28, 'start': 42, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 71, 'score': 92.28, 'start': 61, 'video_id': '##3d978c86daab572f3481##'}, {'confidence': 'high', 'end': 62, 'score': 92.28, 'start': 61, 'video_id': '##3d96af86daab572f3481##'}, {'confidence': 'high', 'end': 67, 'score': 92.28, 'start': 65, 'video_id': '##3d96af86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1', 'page_expired_at': '2023-04-17T23:56:00Z', 'total_results': 110}, 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##', 'total_count': 5, 'total_duration': 9003}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
이 부분은 비디오 이해 모델이 가진 고도화된 지능을 고스란히 체감할 수 있어 매우 흥미롭습니다. 모델은 사람이 비디오 콘텐츠를 이해하는 것과 매우 유사한 방식을 보여줍니다. 위의 스크린샷에서 볼 수 있듯이, 시스템은 제가 지목하고자 했던 정확한 장면을 완벽하게 짚어냈습니다.
이번에는 전체 도쿄 드리프트 영화를 담고 있는 두 번째 인덱스에 대해 간단한 쿼리들을 결합하여 정밀 검색을 시도해 보겠습니다.
<pre><code class="python"># Construct the URL of the `/search` endpoint SEARCH_URL = f"{API_URL}/search/" # Declare a dictionary named `data` data = { "index_id": INDEX_ID, "search_options": ["visual"], "query": { "$and": [ { "text": "drift", "search_options": ["visual"] }, { "text": "mitsubishi", "search_options": ["logo"] } ] } } # Make a search request response = requests.post(SEARCH_URL, headers=default_header, json=data) if response.status_code == 200: print(f"Status code: {response.status_code} - Success") else: print(f"Status code: {response.status_code}") pprint(response.json()) </code></pre>
출력 결과:
<pre><code class="bash">Status code: 200 - Success {'data': [{'confidence': 'high', 'end': 3710, 'score': 92.28, 'start': 3705, 'video_id': '##3e3ace86daab572f3481##'}], 'page_info': {'limit_per_page': 10, 'page_expired_at': '2023-04-18T09:09:59Z', 'total_results': 1}, 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##', 'total_count': 1, 'total_duration': 6246}} </code></pre>
해당 비디오 세그먼트:
<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
빙고! 이번에도 역시 시스템이 완벽한 시점을 정확하게 잡아냈습니다. 해당 장면은 주인공 숀(루카스 블랙 수 분)이 빨간색 미쓰비시 차량을 몰고 노련하게 드리프트를 구사하는 씬입니다.
각 비디오의 ID, 파일명, 검색된 시작/종료 타임스탬프를 포함하는 파이썬 리스트 구조를 도출해 보겠습니다. 이 리스트 정보를 다음 단계에서 Flask 앱으로 연동하여 웹페이지상에 검색 결과를 표출할 예정입니다.
<pre><code class="python">response_data = response.json() # Extract unique video IDs unique_video_ids = list(set([item['video_id'] for item in response_data['data']])) # Create empty start and end instances lists for each video ID video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids} def find_video_name(video_id, video_id_name_list): for video in video_id_name_list: if video['video_id'] == video_id: return video['video_name'] return None # Append start and end instances to their respective lists for item in response_data['data']: video_id = item['video_id'] video_start_end_lists[video_id]['starts'].append(item['start']) video_start_end_lists[video_id]['ends'].append(item['end']) for video_id, timestamps in video_start_end_lists.items(): video_name = find_video_name(video_id, video_id_name_list) if video_name: timestamps['video_name'] = video_name else: print(f"No video name found for ID '{video_id}'") # Print the result pprint(video_start_end_lists) </code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114], 'starts': [18, 61, 65, 111], 'video_name': '20211113T200000Z.mp4'}, '643d972e86daab572f34810d': {'ends': [84], 'starts': [68], 'video_name': '20211113T193000Z.mp4'}, '643d975786daab572f34810e': {'ends': [79], 'starts': [70], 'video_name': '20211114T170000Z.mp4'}, '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95], 'starts': [42, 61, 84, 91], 'video_name': '20211113T190000Z.mp4'}} </code></pre>
추후 Flask 앱에서 불러올 수 있도록 해당 파이썬 리스트를 로컬 파일에 직렬화(pickle 처리)하여 저장하겠습니다:
<pre><code class="python">import pickle with open("lists.pkl", "wb") as f: pickle.dump(video_start_end_lists, f) </code></pre>
데모 앱 구축하기
이제 마지막 단계입니다. 수신한 JSON 응답값을 활용해 시작 및 종료 지점을 수동으로 직접 재생할 필요 없이 비디오 세그먼트를 깔끔하게 불러와 표시해 보겠습니다. 로컬 드라이브에서 가져온 영상에 해당 타임스탬프를 적용하고, 이를 브라우저상에서 작동하게 할 웹페이지를 제작할 것입니다. 결과적으로 웹페이지 안에 당사 검색 쿼리에 완벽하게 부합하며 시각적으로 훌륭히 편집된 비디오 클립 컬렉션이 예쁘게 나타나게 됩니다.
프로젝트 작업 경로의 폴더 구조 구조는 아래와 같습니다:
<pre><code class="markdown">my_flask_app/ │ app.py │ sample_notebook.ipynb └───templates/ │ │ index.html └───static/ │ <your_video_1.mp4> │ <your_video_2.mp4> │ <your_video_3.mp4> . . . </code></pre>
Flask 애플리케이션 코드
"app.py" 파일에 구현할 코드 구조입니다:
<pre><code class="python"> from flask import Flask, render_template import pickle with open("lists.pkl", "rb") as f: video_start_end_lists = pickle.load(f) app = Flask(__name__) @app.route("/") def index(): return render_template("index_local.html", video_start_end_lists=video_start_end_lists) if __name__ == "__main__": app.run(debug=True) </code></pre>
HTML 템플릿
Jinja2를 지원하는 마크업 정의 파일의 예시입니다. 앞서 빌드해 둔 정렬 데이터를 파싱하고, 로컬 디렉토리에서 대상 원본 영상들을 매핑하며, 최종 브라우저 콘솔상에서 결합 검색 결과를 정확히 슬라이싱해 표출해 줍니다.
<pre><code class="language-html"><!DOCTYPE html> <html> <head> <link rel="shortcut icon" href="#" /> <style> body { background-color: #FFE0B2; /* Light Orange */ font-family: Arial, sans-serif; text-align: center; margin: 0; } h1 { font-size: 3em; color: #000000; /* Black */ background-color: #9ACD32; /* Light Green */ padding: 20px; margin: 0; } h2 { font-size: 2em; color: #000000; /* Black */ margin-bottom: 20px; text-align: left; padding-left: 20px; } .video-container { display: flex; flex-wrap: wrap; padding: 2px; justify-content: space-evenly; gap: 2px; } .video-item { display: flex; flex-direction: column; align-items: center; width: 45%; height: 450px; margin: 20px; text-align: center; background-color: #FFFFFF; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 10px; padding: 20px; } .video-item video { width: 100%; height: 380px; margin: 0; /* Remove bottom margin */ border-radius: 5px; } .video-item p { font-size: 16px; margin-top: 10px; font-weight: bold; color: #212121; /* Dark Grey */ } .video-item span { color: #9ACD32; /* Light Green */ } </style> </head> <body> <h1>My Favorite Scenes</h1> {% for video_id, segments in video_start_end_lists.items() %} <div class="video-section"> <h2>Scenes from {{segments['video_name']}}</h2> <div class="video-container"> {% for i in range(segments['starts']|length) %} <div class="video-item"> <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls> <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4"> Your browser does not support the video tag. </video> <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p> <script> document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() { this.currentTime = {{ segments['starts'][i] }}; }); document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() { if (this.currentTime >= {{ segments['ends'][i] }}) { this.pause(); } }); </script> </div> {% endfor %} </div> </div> {% endfor %} </body> </html> </code></pre>




