Tutorials

셰이드 파인더(Shade Finder) 앱 개발: 트웰브랩스(Twelve Labs) API를 활용한 영상 속 특정 색상 탐색 감지

김미란

이 튜토리얼에서는 텍스트 대신 이미지로 검색하여 비디오 콘텐츠에서 특정 색상과 제품을 찾아내는 Twelve Labs의 이미지-비디오 검색 API를 활용해, 인덱싱된 비디오를 브라우징하고 타임스탬프 결과까지 표시해 주는 JavaScript 및 Node 기반 프런트엔드의 Shade Finder 앱을 구축하는 과정을 단계별로 안내합니다.

이 튜토리얼에서는 텍스트 대신 이미지로 검색하여 비디오 콘텐츠에서 특정 색상과 제품을 찾아내는 Twelve Labs의 이미지-비디오 검색 API를 활용해, 인덱싱된 비디오를 브라우징하고 타임스탬프 결과까지 표시해 주는 JavaScript 및 Node 기반 프런트엔드의 Shade Finder 앱을 구축하는 과정을 단계별로 안내합니다.

In this article

No headings found on page

뉴스레터 구독하기

뉴스레터 구독하기

영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.

영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.

AI로 영상을 검색하고, 분석하고, 탐색하세요.

2024. 8. 21.

12분

링크 복사하기

비디오에서 특정 색상을 정확히 찾아내고 싶었던 적이 있으신가요? 예를 들어 좋아하는 색상이 포함된 제품이나 특정 순간을 찾고 싶을 때처럼 말이죠. 최근 저는 퍼스널 컬러 진단 서비스를 받고 저에게 베리 계열의 색상이 가장 잘 어울린다는 사실을 발견했습니다.

그동안 모아둔 유튜브 비디오 보관함을 살펴보면서, 바로 그 정확한 색상의 제품들을 쉽게 찾을 수 있는 방법이 있으면 좋겠다고 생각했습니다. 다행히 Twelve Labs의 이미지 투 비디오(Image-to-Video) 검색 기술을 활용하여 이를 정확히 수행하는 앱을 개발할 수 있었습니다.

이 튜토리얼에서는 Twelve Labs API를 사용하여 어떻게 "Shade Finder" 앱을 구축했는지 단계별로 안내해 드리겠습니다. 완벽한 베리 톤의 립스틱을 찾고 싶으시거나 비디오 내에서 특정 색상을 편리하게 감지하는 방법이 궁금하시다면, 이 가이드가 최첨단 AI를 활용해 이를 쉽게 해결하는 데 도움이 될 것입니다. 그럼 시작해 볼까요!

📌 데모를 확인해 보세요!

사전 준비 사항

  • Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요.

  • 다음으로 인덱스를 생성하고 해당 인덱스에 비디오를 업로드합니다. 이 작업이 완료되면 비디오 검색을 시작할 준비가 끝납니다! 

  • 이 앱은 JavaScript와 Node로 구축되었습니다. 

  • 이 앱의 모든 파일이 포함된 저장소는 Github에서 확인하실 수 있습니다.

목차

이 앱의 구조는 매우 직관적이고 이해하기 쉽습니다. 크게 보면 index.html, script.js, server.js의 세 가지 주요 구성 요소로 이루어져 있습니다. 

먼저 index.html의 개요를 빠르게 살펴본 다음, 서버 측과 클라이언트 측의 전체적인 흐름을 심층적으로 다루겠습니다. 여기에는 비디오 목록 가져오기, 단일 비디오 조회하기, 이미지 기반 검색 수행하기, 그리고 페이지 토큰을 활용한 검색 결과 페이징 처리가 포함됩니다.

HTML

index.html 파일은 앱의 뼈대 역할을 하여 기본적인 구조와 레이아웃을 제공합니다. server.js 파일은 SDK를 통해 Twelve Labs API로 향하는 모든 API 호출을 관리하며, 앱이 관련 데이터를 효율적으로 처리하고 반환할 수 있도록 보장합니다. script.js 파일은 클라이언트 측 로직으로 작동하여 사용자 상호작용을 처리하고, 서버에 요청을 보내며, 검색 작업을 실행합니다. 

아래는 앱의 핵심 구성 요소를 리스트업한 index.html의 body 영역입니다. 

  • 쿼리 이미지를 보여주기 위한 이미지 캐러셀

  • 검색을 시작하기 위한 검색 버튼

  • 지정된 인덱스의 비디오들을 보여주는 비디오 리스트

  • 검색 버튼을 클릭한 후 결과를 표시하는 검색 결과 섹션

Index.html

<body>
<h1 class="text-3xl text-center m-5 p-3"><i class="fa-solid fa-palette"><&sol;i> Shade Finder<&sol;h1>


 <div class="m-5 p-3">
   <p class="text-center m-5" id="color-label"><&sol;p>
   <div class="flex justify-center gap-5">
     <button id="prev"><i class="fa-solid fa-chevron-left"><&sol;i><&sol;button>
     <div class="size-40"><img id="carousel-image"><&sol;div>
     <button id="next"><i class="fa-solid fa-chevron-right"><&sol;i><&sol;button>
   <&sol;div>
   <div class="flex justify-center m-5 gap-2">
     <button id="search" class="bg-lime-400 py-2.5 px-3">Search<&sol;button>
   <&sol;div>
 <&sol;div>


 <div id ="video-list-container" class="container max-w-5xl mx-auto py-4">
 <div id ="video-list-loading" class="container max-w-5xl mx-auto py-4"> <&sol;div>
 <div id="video-list" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 justify-items-center"><&sol;div>
 <div id="video-list-pagination" class="flex justify-center m-5 gap-2"><&sol;div>
 <&sol;div>


 <div id="search-result-container" class="container w-5/6 mx-auto py-4 hidden">
 <div id="search-result-list" class="grid grid-cols-1 md:grid-cols-4 justify-center"><&sol;div>
 <&sol;div>


 <script src="./script.js"><&sol;script>
<&sol;body

서버

server.js는 Twelve Labs API에 대한 모든 API 호출을 관리하는 파일입니다. 여기에는 비디오 목록 조회 및 페이징, 단일 비디오 조회, 이미지 투 비디오 검색, 그리고 페이지 토큰 기반 검색 결과 조회의 4가지 라우트가 존재합니다. 

Twelve Labs API를 위한 4가지 요청

💡 Twelve Labs는 개발 중인 애플리케이션에 플랫폼을 손쉽게 연동하여 사용할 수 있도록 SDK를 제공합니다. 이 앱에서는 Javascript SDK(버전 0.2.5)를 사용했습니다. 

설정 단계

1 - Twelve Labs API Key와 Index Id를 .env에 저장하기

백엔드 폴더 내부에서 키 값이 주석 처리된 .env 파일을 찾을 수 있습니다. 주석을 해제하고 실제 값으로 변경해 주세요. 

.env

TWELVE_LABS_API_KEY=<YOUR API KEY>
TWELVE_LABS_INDEX_ID=<YOUR_INDEX_ID>

2 - Twelve Labs SDK 설치 및 임포트 

먼저, twelvelabs-js 패키지를 설치합니다.

yarn add twelvelabs-js # or npm i twelvelabs-js

그 다음, 필요한 패키지들을 애플리케이션으로 가져옵니다. Node.js로 작성된 server.js 파일에 다음과 같이 패키지를 임포트했습니다. 

server.js (영역 7 - 9)

const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");

마지막으로 발급받은 API 키를 전달하여 SDK 클라이언트 객체를 생성합니다.  

server.js (영역 23)

const client = new TwelveLabs({ apiKey: API_KEY });

전체적인 코드는 아래에서 확인하실 수 있습니다. 

server.js (영역 1 - 25)

"use strict";


const express = require("express");
const dotenv = require("dotenv");
const cors = require("cors");
const asyncHandler = require("express-async-handler");
const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");


dotenv.config();


const app = express();


app.use(express.json());
app.use(cors());
app.use(express.static(path.join(__dirname, "../frontend/public")));


const PORT = 5001;
const API_KEY = process.env.TWELVE_LABS_API_KEY;
const INDEX_ID = process.env.TWELVE_LABS_INDEX_ID;


const client = new TwelveLabs({ apiKey: API_KEY });


app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

요청 1. 비디오 목록 가져오기 및 페이징

페이지별로 비디오 목록을 조회하려면 client.index.video.listPagination 메서드를 사용하고 인덱스 ID와 조회할 페이지를 전달하면 됩니다. 필요한 경우 한 페이지에 반환할 비디오 수를 제어할 수 있는 pageLimit 파라미터를 함께 전달할 수 있습니다. 

‍💡 팁: API 레퍼런스 문서 문서에 기재된 모든 요청용 파라미터들은 Javascript SDK 환경에서 카멜 케이스(camelCase) 형식으로 변환하여 동일하게 사용할 수 있습니다.

비디오 목록 데이터를 수신한 후, 이후 사용할 수 있도록 각 비디오의 idmetadata를 추출하고 pageInfo와 함께 반환합니다.

‍💡 비디오 페이징 처리에 대한 자세한 내용은 가이드 문서 가이드를 참조하세요. 공식 예제 코드도 큰 도움이 될 것입니다!

server.js (영역 34 - 55)

/** Get videos */
app.get(
 "/videos",
 asyncHandler(async (req, res, next) => {
   const { page_limit, page } = req.query;


   const videosResponse = await client.index.video.listPagination(INDEX_ID, {
     pageLimit: page_limit,
     page: page,
   });


   const videos = videosResponse.data.map((video) => ({
     id: video.id,
     metadata: video.metadata,
   }));


   res.json({
     videos,
     page_info: videosResponse.pageInfo,
   });
 })
);

비디오 응답 객체 예시

videosResponse= VideoListWithPagination {  
  ...,
  data: [
    Video {
      _resource: [Video],
      _indexId: '...',
      id: '...',
      metadata: [Object],
      hls: undefined,
      source: undefined,
      indexedAt: '2024-06-27T05:11:29Z',
      createdAt: '2024-06-27T05:01:35Z',
      updatedAt: '2024-06-27T05:01:52Z'
    },
  	 ...
       ],
  pageInfo: {
    page: 1,
    limitPerPage: 12,
    totalPage: 3,
    totalResults: 29,
    totalDuration: 19122
  }
}

요청 2. 단일 비디오 조회하기

비디오 목록 조회와 마찬가지로, 인덱스 ID와 비디오 ID를 client.index.video.retrieve 메서드에 전달하여 단일 비디오의 상세 정보를 가져올 수 있습니다. 

비디오 정보를 수신한 후, 필요한 최소 정보인 metadata, hlssource 정보만 추출하여 반환합니다. 구체적으로 나중 단계에서 메타데이터의 비디오 제목, HLS의 썸네일 URL(thumbnailUrls), 소스의 URL 정보들을 요긴하게 사용하게 됩니다.

server.js (영역 57 - 71)

/** Get a video of an index */
app.get(
 "/videos/:videoId",
 asyncHandler(async (req, res, next) => {
   const { videoId } = req.params;


   const videoResponse = await client.index.video.retrieve(INDEX_ID, videoId);


   res.json({
     metadata: videoResponse.metadata,
     hls: videoResponse.hls,
     source: videoResponse.source,
   });
 })
);

비디오 조회 응답 객체 예시

videoResponse= Video {
  ...,
  id: '...',
  metadata: {
    duration: 54,
    engine_ids: [ 'marengo2.6', 'pegasus1.1' ],
    filename: 'tirtir korean cushion review',
    fps: 30,
    height: 1280,
    size: 9601300,
    video_title: 'tirtir korean cushion review',
    width: 720
  },
  hls: {
    videoUrl: '... .m3u8',
    thumbnailUrls: ['... .jpg'],
    status: 'COMPLETE',
    updatedAt: '2024-05-22T02:49:49.074Z'
  },
  source: {
    type: 'youtube',
    name: 'theoliviasaurusrex',
    url: 'https://www.youtube.com/watch?v=tOabvdtTa-U'
  },
  indexedAt: '2024-05-22T03:03:53Z',
  createdAt: '2024-05-22T02:49:28Z',
  updatedAt: '2024-05-22T02:49:36Z'
}

요청 3. 이미지 투 비디오 검색 (Image to Video Search)

이제 가장 재미있는 파트입니다! client.search.query 메서드를 사용하여 이미지 기반의 비디오 검색을 실행합니다. 이를 위해서는 필수 파라미터 네 가지인 indexId, queryMediaFile, queryMediaType, options를 전달해야 합니다. 

특히 queryMediaFile을 올바르게 전달하려면 몇 가지 작업 단계를 거쳐야 합니다.

  • 경로 생성(Path Construction): 우선, 이미지 파일이 저장되어 있는 전체 경로를 생성해야 합니다. 이 앱의 경우 이미 기기 안의 images 폴더 안에 이미지 파일들이 보관되어 있습니다. 따라서 현재 디렉터리 경로(__dirname), 해당 앱의 상대 경로 구조(../frontend/public/images), 그리고 실제 파일 이름(imageSrc)을 조합하여 경로를 완성합니다.

  • 존재 여부 검증(Existence Check): 경로 생성이 완료되면 실제 해당 위치에 이미지 파일이 존재하는지 검증합니다. 파일을 찾을 수 없다면 클라이언트로 404 에러 응답을 실어서 보냅니다.

  • 읽기 스트림 생성(Read Stream Creation): 파일이 정상적으로 존재할 경우 이미지 파일로부터 Readable Stream을 인스턴스화합니다. 이렇게 생성된 스트림을 Twelve Labs API로 안전하고 효율적으로 전송합니다.

이 앱에서는 추가적으로 threshold, pageLimit, adjustConfidenceLevel 같은 선택적 파라미터들도 함께 사용하고 있습니다. 사용 가능한 파라미터들의 세부 항목은 언제든지 API 레퍼런스 문서에서 한눈에 확인해 보실 수 있습니다. 

비디오 검색 결과를 돌려받은 뒤, 이후 화면 단 표시 및 후속 처리에 사용할 수 있도록 datapageInfo 정보를 추출하여 클라이언트에게 다시 반환합니다.‍

💡 이미지 검색 기능에 대해 더 알고 싶으시다면 가이드 문서 가이드를 확인해 보세요. 공식 예제 코드 자료도 아주 유용하게 쓰일 수 있습니다!‍

server.js (영역 73 - 105)

/** Search videos based on an image query */
app.get(
 "/search",
 asyncHandler(async (req, res, next) => {
   const { imageSrc, threshold, pageLimit, adjustConfidenceLevel } = req.query;


   const imagePath = path.join(
     __dirname,
     "../frontend/public/images",
     imageSrc
   );


   if (!fs.existsSync(imagePath)) {
     console.error("Image not found at path:", imagePath);
     return res.status(404).json({ error: "Image not found" });
   }


   const searchResponse = await client.search.query({
     indexId: INDEX_ID,
     queryMediaFile: fs.createReadStream(imagePath),
     queryMediaType: "image",
     options: ["visual"],
     threshold: threshold,
     pageLimit: pageLimit,
     adjustConfidenceLevel: adjustConfidenceLevel,
   });


   res.json({
     searchResults: searchResponse.data,
     pageInfo: searchResponse.pageInfo,
   });
 })
);

검색 결과 응답 객체 예시

searchResponse= SearchResult {
  ...,
  pool: {
    totalCount: 29,
    totalDuration: 19122,
    indexId: '...'
  },
  data: [
    {
      score: 84.45,
      start: 379.13333333341933,
      end: 381,
      metadata: [Array],
      videoId: '...',
      confidence: 'high',
      thumbnailUrl: '...'
    },
      ...
      ],
  pageInfo: {
    limitPerPage: 12,
    totalResults: 20,
    pageExpiredAt: '2024-08-15T04:09:46Z'
    nextPageToken: '...' //This might not exist 
  }
}

요청 4. 페이지 토큰 지정을 통한 검색

페이지 토큰 지정을 활용하여 결과를 검색하는 것은 매우 단순합니다. 이전의 검색 API 요청(요청 3단계) 결과에서 획득한 pageToken 값을 담아서 다음과 같이 client.search.byPageToken 메서드를 실행하기만 하면 됩니다. 반환되는 응답 본문은 기존 이미지 기반 검색 결과(요청 3단계)의 형태와 완전히 동일합니다. 

server.js (영역 107 - 120)

/** Get search results of a specific page */
app.get(
 "/search/:pageToken",
 asyncHandler(async (req, res, next) => {
   const { pageToken } = req.params;


   let searchByPageResponse = await client.search.byPageToken(`${pageToken}`);


   res.json({
     searchResults: searchByPageResponse.data,
     pageInfo: searchByPageResponse.pageInfo,
   });
 })
);

클라이언트

필수 API 엔드포인트들을 포함하는 서버 세팅이 완벽히 해결되었으므로, 이제 클라이언트 코드로 포커스를 맞출 수 있습니다. 이 부분은 서버에 직접 API 요청을 보낸 다음 수신한 실제 데이터들을 연동 처리하는 중책을 맡게 됩니다.

서버에서 확립했던 설계 흐름대로, 인덱스로부터 비디오를 초기에 어떻게 노출하는지 먼저 점검해 보겠습니다. 그런 다음 비디오를 검색하고 검색 결과 리스트에 무한 페이징 처리하는 실제 구현 단계로 가보겠습니다.

1 - 인덱스의 비디오 목록 화면에 뿌려주기

showVideos 함수

어플리케이션 화면이 브라우저 상에서 리렌더링 될 때 구동되는 핵심 로직 중 하나가 바로 showVideos입니다. 

script.js (영역 434 - 460)

async function showVideos(page = 1) {
 videoList.innerHTML = "";


 ...


 try {
   const { videosDetail, pageInfo } = await getVideoOfVideos(page);


   videoListLoading.removeChild(loadingSpinnerContainer);


   if (videosDetail) {
     videosDetail.forEach((video) => {
       const videoContainer = createVideoContainer(video);
       videoList.appendChild(videoContainer);
     });


     videoListLoading.classList.remove("min-h-[300px]");


     createPaginationButtons(pageInfo, page);
   }
 } catch (error) {
   console.error("Error fetching videos:", error);
 }
}



  • DOM상의 현재 비디오 항목 리스트 영역을 말끔히 비웁니다.

  • getVideoOfVideos를 실행하여 주어지는 특정 페이지 번호 안의 모든 비디오 개별 상세 데이터를 리트리브합니다.

  • 조회된 세부 데이터를 토대로 순회 루프(loop)를 돌리며 비디오 컴포넌트 껍데기를 만들어 리스트 내부에 주입(append)합니다.

  • 마지막 단계로, 리트리브한 pageInfo 스펙을 정밀 체크하여 페이지 이동 버튼 그룹을 올바르게 세팅합니다.

getVideoOfVideos 함수

getVideoOfVideos 함수는 명시된 특정 단일 페이지 내의 모든 비디오 데이터 셋을 조회한 뒤, 이어서 비디오 건별 상세 프로퍼티에 접근하는 두 단계 행동을 취합니다.

script.js (영역 523 - 533)

async function getVideoOfVideos(page = 1) {
 const videosResponse = await getVideos(page);


 if (videosResponse.videos.length > 0) {
   const videosDetail = await Promise.all(
     videosResponse.videos.map((video) => getVideo(video.id))
   );


   return { videosDetail, pageInfo: videosResponse.page_info };
 }
}
  • getVideos 함수는 인자로 주어지는 페이지 기준의 비디오 셋을 가져와 달라고 서버의 API 엔드포인트(서버 파트 요청 1단계 명세 사항)로 리퀘스트를 날립니다.

  • 해당 요청이 정상적으로 완수되어 비디오 셋이 실존하는 것으로 진단되면 비디오 항목별 순환 루프를 시작하고, 서버측 요청 2단계로 주어지는 getVideo 리퀘스트 로직으로 상세 스펙을 온전히 조회합니다.

  • 조회 성료 후의 후속 사용 시 퍼포먼스를 극대화하기 위하여, 리트리브한 이 디테일 정보 조각들은 내부 캐싱 시스템 레이어로 안전하게 세이브됩니다.

비디오 컨테이너 동적 생성

비디오 상세 조회가 정상 완료되어 데이터 배치가 준비되는 즉시, showVideos 함수는 비디오 URL, 썸네일 경로, 인덱스 타겟 비디오 타이틀을 연동 처리하여 비디오 목록 UI 블록을 동적 구축합니다.

페이지네이션 컴포넌트

마지막 순서로, getVideos 로직 수행으로 도출된 최종 전체 페이지 총 개수 데이터 스펙에 기반하여 페이지네이션 컴포넌트가 UI에 자동 구성됩니다. 각각의 페이지 숫자 버튼마다 사용자의 마우스 클릭 이벤트를 구독하는 리스너(Listener) 장치가 부착되며 이에 연동된 해당 숫자 기준의 showVideos가 원활히 재실행됩니다.

script.js (영역 498 - 521)

function createPageButton(pageNumber, currentPage) {
 const pageButton = document.createElement("button");
 pageButton.textContent = pageNumber;
  
  ...


 if (pageNumber === currentPage) {
   pageButton.classList.add("bg-slate-200", "font-medium");
   pageButton.disabled = true;
 } else {
   pageButton.classList.add("bg-transparent");
   pageButton.addEventListener("click", () => showVideos(pageNumber));
 }


 return pageButton;
}

2 - 전달받은 이미지를 이용하여 인덱스 내 비디오 데이터 검색하기

엔드 유저가 Search 인터랙션 컴포넌트를 마우스 클릭하면 화면 단의 handleSearchButtonClick 핵심 비즈니스 로직 함수가 기동하며 백엔드 단으로 실질적인 검색 처리를 구하는 신호를 전달합니다. 이 구동 스텝들을 순서대로 찬찬히 추적해 보겠습니다.

script.js (영역 62 - 89)

async function handleSearchButtonClick() {
 toggleSearchButton(false);


 nextPageToken = null;
 searchResultContainer.innerHTML = "";
 videoListContainer.classList.add("hidden");
 searchResultContainer.classList.remove("hidden");
 searchResultList.innerHTML = "";


 const loadingSpinnerContainer = createLoadingSpinner();
 searchResultContainer.appendChild(loadingSpinnerContainer);


 try {
   const { searchResults } = await searchByImage();


   searchResultContainer.removeChild(loadingSpinnerContainer);


   if (searchResults.length > 0) {
     showSearchResults(searchResults);
   } else {
     displayNoResultsMessage();
   }
 } catch (error) {
   console.error("Error fetching search results:", error);
 } finally {
   toggleSearchButton(true);
 }
}

제일 먼저, 과도한 연속 클릭 방지를 통한 최적 데이터 연동 보호 목적 상 프로세스의 전반부가 진행되는 동안 검색 UI 컴포넌트를 즉시 일시 비활성화 처리해 놓습니다.

  • 다음으로, 메모리상의 다음 검색 페이지 트래킹 목적 지시자인 nextPageToken 정보를 널 가비지 데이터(null)로 안전하게 플러시(flush) 시키고, DOM 트리상의 searchResultContainer 하위 엘리먼트 껍데기와 searchResultList 내부 리스트 항목들을 완전히 클렌징시킵니다. 아울러 기존 메인 화면을 메우고 있던 기존 비디오 목록 뷰(videoListContainer)는 CSS 디스플레이 히든 레이어로 마스킹시키고 그 대신 searchResultContainer는 활력 있게 보여줍니다.

  • 사용자로 하여금 비디오 분석 시스템 작동 처리가 순조롭게 반응 작동 중임을 육안으로 감지할 수 있도록 로딩 스피너 리소스 객체를 생성하여 화면 한가운데에 즉시 띄워 배치합니다.

  • 그리고 검색 연동 전용 헬퍼 함수인 searchByImage를 실행하여 서버 측의 분석 리퀘스트 포인트로 연결 신호를 보냅니다.

  • 서버 연산 작업 성료 시점이 감지되면 올려져 있던 로딩 스피너 UI 노드를 신속 삭제한 후, 실질적 매칭 데이터 내용이 있다면 showSearchResults UI 블록을 호출하여 정밀 그리드를 생성하고, 매칭에 유효한 정보 블록이 일절 식별되지 않았다면 결과 없음 전용 가이드 텍스트 장치를 스크린 상에 렌더링하도록 흐름을 우회 분기시킵니다.

  • 최종 단계로, 프로세스의 진행 상태 플래그를 정지 모드로 원복시켜 검색 버튼을 활성 상태로 다시 전환하므로 유저가 희망 시 추가 신규 검색 사이클을 부담 없이 시작할 수 있도록 조치합니다.

3 - 페이지 토큰을 활용한 후속 추가 검색 목록 연속 갱신 처리

리트리브해 가져올 대상 비디오 검색 리스트 볼륨이 지정 규격 크기를 상회해 다음 후속 연속 페이지를 필히 가리켜야만 하는 구조라면 (nextPageToken 값이 정상 수신되어 실존한다면), 화면 어플리케이션은 createShowMoreButton 컴포넌트 헬पर 함수를 즉각 시동하여 스크린 상단에 "Show More" 컴포넌트를 깔끔하게 배치합니다. 엔드 유저가 이 가독성 높은 인터랙션 컴포넌트를 실행하면 다음 회차 분량의 분석 검색 데이터 리스트 팩이 끊김 없이 매끄럽게 가져와집니다. 세부 구동 방식들을 확인해 보겠습니다.

script.js (영역 296 - 319)

function createShowMoreButton() {
 removeExistingButton();


 const showMoreButtonContainer = document.createElement("div");
 showMoreButtonContainer.id = "show-more-button";
 showMoreButtonContainer.classList.add("flex", "justify-center", "my-4");


 const showMoreButton = document.createElement("button");
 showMoreButton.innerHTML = '<i class="fa-solid fa-chevron-up"></i> Show More';


 showMoreButton.addEventListener("click", async () => {
   const loadingSpinnerContainer = createLoadingSpinner();
   searchResultContainer.appendChild(loadingSpinnerContainer);


   const nextPageResults = await getNextSearchResults(nextPageToken);


   searchResultContainer.removeChild(loadingSpinnerContainer);


   showSearchResults(nextPageResults.searchResults);
   nextPageToken = nextPageResults.pageInfo.nextPageToken || null;
 });
 showMoreButtonContainer.appendChild(showMoreButton);
 searchResultContainer.appendChild(showMoreButtonContainer);
}
  • 우선순위로, 기존 레이아웃 플로우에 이미 렌더링 되어 혹시 자리 잡고 있을지 모를 노후 잔여 "Show More" 컴포넌트 장치들을 일제히 완벽 회수 제거 처리하여 화면 중복 마운트 버그를 선제적 방지합니다.

  • 다음으로, 깨끗한 UI 연동에 맞는 감각적인 "Show More" 타겟 버튼 엘리먼트와 공간 컨테이너 블록을 세심하게 동적 생성해 놓습니다.

  • 이렇게 동적으로 빌드해 낸 해당 타겟 버튼에 마우스 단일 클릭 제스처를 모니터할 전담 이벤트 리스너를 결속(bind)시킵니다. 이에 반응하여 화면에는 작업 로딩 표시용 스피너가 구동되고, 서버로 다음 차례 검색 결과 페이지 번들을 비동기 리트리브해 가져올 getNextSearchResults 통신 로직이 속속 실행되며, 통신 완수 감지 시 스피너를 무대 뒤로 퇴장시키고, 스크린 상단에 기존 결과 리스트에 다음 리브 항목들을 부드럽게 병합 표시해 준 뒤, 마지막으로 다음 타겟 이동 목적 지표인 nextPageToken 포인터 버퍼 정보를 후속 업데이트합니다.

  • 전체 처리 구동 사이클이 안정화되어 가동되면 UI 조립 단계의 마무리로 완성해 낸 컨테이너와 "Show More" 실행 버튼 모듈을 메인 searchResultContainer 하부 레이아웃 위치에 완벽히 추가 부착하여 실 사용자가 볼 수 있도록 물리 배치합니다.

마치며

이번 포스팅을 통해 Twelve Labs의 최신 이미지 투 비디오(Image-to-Video) 검색 API를 깊이 있게 들여다보고, 실전 앱에 비즈니스 인텔리전스로 녹여 적용해 내는 아이디어에 대해 흥미로운 통찰력을 얻으셨기를 바랍니다. 읽어주셔서 진심으로 감사드리며, 차세대 비디오 분석 검색 혁신 솔루션 기능들을 여러분들의 혁신적인 프로젝트에도 세련되게 적용하여 놀라운 가치를 만들어 가시기를 즐거운 마음으로 기대해 보겠습니다!

비디오에서 특정 색상을 정확히 찾아내고 싶었던 적이 있으신가요? 예를 들어 좋아하는 색상이 포함된 제품이나 특정 순간을 찾고 싶을 때처럼 말이죠. 최근 저는 퍼스널 컬러 진단 서비스를 받고 저에게 베리 계열의 색상이 가장 잘 어울린다는 사실을 발견했습니다.

그동안 모아둔 유튜브 비디오 보관함을 살펴보면서, 바로 그 정확한 색상의 제품들을 쉽게 찾을 수 있는 방법이 있으면 좋겠다고 생각했습니다. 다행히 Twelve Labs의 이미지 투 비디오(Image-to-Video) 검색 기술을 활용하여 이를 정확히 수행하는 앱을 개발할 수 있었습니다.

이 튜토리얼에서는 Twelve Labs API를 사용하여 어떻게 "Shade Finder" 앱을 구축했는지 단계별로 안내해 드리겠습니다. 완벽한 베리 톤의 립스틱을 찾고 싶으시거나 비디오 내에서 특정 색상을 편리하게 감지하는 방법이 궁금하시다면, 이 가이드가 최첨단 AI를 활용해 이를 쉽게 해결하는 데 도움이 될 것입니다. 그럼 시작해 볼까요!

📌 데모를 확인해 보세요!

사전 준비 사항

  • Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요.

  • 다음으로 인덱스를 생성하고 해당 인덱스에 비디오를 업로드합니다. 이 작업이 완료되면 비디오 검색을 시작할 준비가 끝납니다! 

  • 이 앱은 JavaScript와 Node로 구축되었습니다. 

  • 이 앱의 모든 파일이 포함된 저장소는 Github에서 확인하실 수 있습니다.

목차

이 앱의 구조는 매우 직관적이고 이해하기 쉽습니다. 크게 보면 index.html, script.js, server.js의 세 가지 주요 구성 요소로 이루어져 있습니다. 

먼저 index.html의 개요를 빠르게 살펴본 다음, 서버 측과 클라이언트 측의 전체적인 흐름을 심층적으로 다루겠습니다. 여기에는 비디오 목록 가져오기, 단일 비디오 조회하기, 이미지 기반 검색 수행하기, 그리고 페이지 토큰을 활용한 검색 결과 페이징 처리가 포함됩니다.

HTML

index.html 파일은 앱의 뼈대 역할을 하여 기본적인 구조와 레이아웃을 제공합니다. server.js 파일은 SDK를 통해 Twelve Labs API로 향하는 모든 API 호출을 관리하며, 앱이 관련 데이터를 효율적으로 처리하고 반환할 수 있도록 보장합니다. script.js 파일은 클라이언트 측 로직으로 작동하여 사용자 상호작용을 처리하고, 서버에 요청을 보내며, 검색 작업을 실행합니다. 

아래는 앱의 핵심 구성 요소를 리스트업한 index.html의 body 영역입니다. 

  • 쿼리 이미지를 보여주기 위한 이미지 캐러셀

  • 검색을 시작하기 위한 검색 버튼

  • 지정된 인덱스의 비디오들을 보여주는 비디오 리스트

  • 검색 버튼을 클릭한 후 결과를 표시하는 검색 결과 섹션

Index.html

<body>
<h1 class="text-3xl text-center m-5 p-3"><i class="fa-solid fa-palette"><&sol;i> Shade Finder<&sol;h1>


 <div class="m-5 p-3">
   <p class="text-center m-5" id="color-label"><&sol;p>
   <div class="flex justify-center gap-5">
     <button id="prev"><i class="fa-solid fa-chevron-left"><&sol;i><&sol;button>
     <div class="size-40"><img id="carousel-image"><&sol;div>
     <button id="next"><i class="fa-solid fa-chevron-right"><&sol;i><&sol;button>
   <&sol;div>
   <div class="flex justify-center m-5 gap-2">
     <button id="search" class="bg-lime-400 py-2.5 px-3">Search<&sol;button>
   <&sol;div>
 <&sol;div>


 <div id ="video-list-container" class="container max-w-5xl mx-auto py-4">
 <div id ="video-list-loading" class="container max-w-5xl mx-auto py-4"> <&sol;div>
 <div id="video-list" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 justify-items-center"><&sol;div>
 <div id="video-list-pagination" class="flex justify-center m-5 gap-2"><&sol;div>
 <&sol;div>


 <div id="search-result-container" class="container w-5/6 mx-auto py-4 hidden">
 <div id="search-result-list" class="grid grid-cols-1 md:grid-cols-4 justify-center"><&sol;div>
 <&sol;div>


 <script src="./script.js"><&sol;script>
<&sol;body

서버

server.js는 Twelve Labs API에 대한 모든 API 호출을 관리하는 파일입니다. 여기에는 비디오 목록 조회 및 페이징, 단일 비디오 조회, 이미지 투 비디오 검색, 그리고 페이지 토큰 기반 검색 결과 조회의 4가지 라우트가 존재합니다. 

Twelve Labs API를 위한 4가지 요청

💡 Twelve Labs는 개발 중인 애플리케이션에 플랫폼을 손쉽게 연동하여 사용할 수 있도록 SDK를 제공합니다. 이 앱에서는 Javascript SDK(버전 0.2.5)를 사용했습니다. 

설정 단계

1 - Twelve Labs API Key와 Index Id를 .env에 저장하기

백엔드 폴더 내부에서 키 값이 주석 처리된 .env 파일을 찾을 수 있습니다. 주석을 해제하고 실제 값으로 변경해 주세요. 

.env

TWELVE_LABS_API_KEY=<YOUR API KEY>
TWELVE_LABS_INDEX_ID=<YOUR_INDEX_ID>

2 - Twelve Labs SDK 설치 및 임포트 

먼저, twelvelabs-js 패키지를 설치합니다.

yarn add twelvelabs-js # or npm i twelvelabs-js

그 다음, 필요한 패키지들을 애플리케이션으로 가져옵니다. Node.js로 작성된 server.js 파일에 다음과 같이 패키지를 임포트했습니다. 

server.js (영역 7 - 9)

const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");

마지막으로 발급받은 API 키를 전달하여 SDK 클라이언트 객체를 생성합니다.  

server.js (영역 23)

const client = new TwelveLabs({ apiKey: API_KEY });

전체적인 코드는 아래에서 확인하실 수 있습니다. 

server.js (영역 1 - 25)

"use strict";


const express = require("express");
const dotenv = require("dotenv");
const cors = require("cors");
const asyncHandler = require("express-async-handler");
const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");


dotenv.config();


const app = express();


app.use(express.json());
app.use(cors());
app.use(express.static(path.join(__dirname, "../frontend/public")));


const PORT = 5001;
const API_KEY = process.env.TWELVE_LABS_API_KEY;
const INDEX_ID = process.env.TWELVE_LABS_INDEX_ID;


const client = new TwelveLabs({ apiKey: API_KEY });


app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

요청 1. 비디오 목록 가져오기 및 페이징

페이지별로 비디오 목록을 조회하려면 client.index.video.listPagination 메서드를 사용하고 인덱스 ID와 조회할 페이지를 전달하면 됩니다. 필요한 경우 한 페이지에 반환할 비디오 수를 제어할 수 있는 pageLimit 파라미터를 함께 전달할 수 있습니다. 

‍💡 팁: API 레퍼런스 문서 문서에 기재된 모든 요청용 파라미터들은 Javascript SDK 환경에서 카멜 케이스(camelCase) 형식으로 변환하여 동일하게 사용할 수 있습니다.

비디오 목록 데이터를 수신한 후, 이후 사용할 수 있도록 각 비디오의 idmetadata를 추출하고 pageInfo와 함께 반환합니다.

‍💡 비디오 페이징 처리에 대한 자세한 내용은 가이드 문서 가이드를 참조하세요. 공식 예제 코드도 큰 도움이 될 것입니다!

server.js (영역 34 - 55)

/** Get videos */
app.get(
 "/videos",
 asyncHandler(async (req, res, next) => {
   const { page_limit, page } = req.query;


   const videosResponse = await client.index.video.listPagination(INDEX_ID, {
     pageLimit: page_limit,
     page: page,
   });


   const videos = videosResponse.data.map((video) => ({
     id: video.id,
     metadata: video.metadata,
   }));


   res.json({
     videos,
     page_info: videosResponse.pageInfo,
   });
 })
);

비디오 응답 객체 예시

videosResponse= VideoListWithPagination {  
  ...,
  data: [
    Video {
      _resource: [Video],
      _indexId: '...',
      id: '...',
      metadata: [Object],
      hls: undefined,
      source: undefined,
      indexedAt: '2024-06-27T05:11:29Z',
      createdAt: '2024-06-27T05:01:35Z',
      updatedAt: '2024-06-27T05:01:52Z'
    },
  	 ...
       ],
  pageInfo: {
    page: 1,
    limitPerPage: 12,
    totalPage: 3,
    totalResults: 29,
    totalDuration: 19122
  }
}

요청 2. 단일 비디오 조회하기

비디오 목록 조회와 마찬가지로, 인덱스 ID와 비디오 ID를 client.index.video.retrieve 메서드에 전달하여 단일 비디오의 상세 정보를 가져올 수 있습니다. 

비디오 정보를 수신한 후, 필요한 최소 정보인 metadata, hlssource 정보만 추출하여 반환합니다. 구체적으로 나중 단계에서 메타데이터의 비디오 제목, HLS의 썸네일 URL(thumbnailUrls), 소스의 URL 정보들을 요긴하게 사용하게 됩니다.

server.js (영역 57 - 71)

/** Get a video of an index */
app.get(
 "/videos/:videoId",
 asyncHandler(async (req, res, next) => {
   const { videoId } = req.params;


   const videoResponse = await client.index.video.retrieve(INDEX_ID, videoId);


   res.json({
     metadata: videoResponse.metadata,
     hls: videoResponse.hls,
     source: videoResponse.source,
   });
 })
);

비디오 조회 응답 객체 예시

videoResponse= Video {
  ...,
  id: '...',
  metadata: {
    duration: 54,
    engine_ids: [ 'marengo2.6', 'pegasus1.1' ],
    filename: 'tirtir korean cushion review',
    fps: 30,
    height: 1280,
    size: 9601300,
    video_title: 'tirtir korean cushion review',
    width: 720
  },
  hls: {
    videoUrl: '... .m3u8',
    thumbnailUrls: ['... .jpg'],
    status: 'COMPLETE',
    updatedAt: '2024-05-22T02:49:49.074Z'
  },
  source: {
    type: 'youtube',
    name: 'theoliviasaurusrex',
    url: 'https://www.youtube.com/watch?v=tOabvdtTa-U'
  },
  indexedAt: '2024-05-22T03:03:53Z',
  createdAt: '2024-05-22T02:49:28Z',
  updatedAt: '2024-05-22T02:49:36Z'
}

요청 3. 이미지 투 비디오 검색 (Image to Video Search)

이제 가장 재미있는 파트입니다! client.search.query 메서드를 사용하여 이미지 기반의 비디오 검색을 실행합니다. 이를 위해서는 필수 파라미터 네 가지인 indexId, queryMediaFile, queryMediaType, options를 전달해야 합니다. 

특히 queryMediaFile을 올바르게 전달하려면 몇 가지 작업 단계를 거쳐야 합니다.

  • 경로 생성(Path Construction): 우선, 이미지 파일이 저장되어 있는 전체 경로를 생성해야 합니다. 이 앱의 경우 이미 기기 안의 images 폴더 안에 이미지 파일들이 보관되어 있습니다. 따라서 현재 디렉터리 경로(__dirname), 해당 앱의 상대 경로 구조(../frontend/public/images), 그리고 실제 파일 이름(imageSrc)을 조합하여 경로를 완성합니다.

  • 존재 여부 검증(Existence Check): 경로 생성이 완료되면 실제 해당 위치에 이미지 파일이 존재하는지 검증합니다. 파일을 찾을 수 없다면 클라이언트로 404 에러 응답을 실어서 보냅니다.

  • 읽기 스트림 생성(Read Stream Creation): 파일이 정상적으로 존재할 경우 이미지 파일로부터 Readable Stream을 인스턴스화합니다. 이렇게 생성된 스트림을 Twelve Labs API로 안전하고 효율적으로 전송합니다.

이 앱에서는 추가적으로 threshold, pageLimit, adjustConfidenceLevel 같은 선택적 파라미터들도 함께 사용하고 있습니다. 사용 가능한 파라미터들의 세부 항목은 언제든지 API 레퍼런스 문서에서 한눈에 확인해 보실 수 있습니다. 

비디오 검색 결과를 돌려받은 뒤, 이후 화면 단 표시 및 후속 처리에 사용할 수 있도록 datapageInfo 정보를 추출하여 클라이언트에게 다시 반환합니다.‍

💡 이미지 검색 기능에 대해 더 알고 싶으시다면 가이드 문서 가이드를 확인해 보세요. 공식 예제 코드 자료도 아주 유용하게 쓰일 수 있습니다!‍

server.js (영역 73 - 105)

/** Search videos based on an image query */
app.get(
 "/search",
 asyncHandler(async (req, res, next) => {
   const { imageSrc, threshold, pageLimit, adjustConfidenceLevel } = req.query;


   const imagePath = path.join(
     __dirname,
     "../frontend/public/images",
     imageSrc
   );


   if (!fs.existsSync(imagePath)) {
     console.error("Image not found at path:", imagePath);
     return res.status(404).json({ error: "Image not found" });
   }


   const searchResponse = await client.search.query({
     indexId: INDEX_ID,
     queryMediaFile: fs.createReadStream(imagePath),
     queryMediaType: "image",
     options: ["visual"],
     threshold: threshold,
     pageLimit: pageLimit,
     adjustConfidenceLevel: adjustConfidenceLevel,
   });


   res.json({
     searchResults: searchResponse.data,
     pageInfo: searchResponse.pageInfo,
   });
 })
);

검색 결과 응답 객체 예시

searchResponse= SearchResult {
  ...,
  pool: {
    totalCount: 29,
    totalDuration: 19122,
    indexId: '...'
  },
  data: [
    {
      score: 84.45,
      start: 379.13333333341933,
      end: 381,
      metadata: [Array],
      videoId: '...',
      confidence: 'high',
      thumbnailUrl: '...'
    },
      ...
      ],
  pageInfo: {
    limitPerPage: 12,
    totalResults: 20,
    pageExpiredAt: '2024-08-15T04:09:46Z'
    nextPageToken: '...' //This might not exist 
  }
}

요청 4. 페이지 토큰 지정을 통한 검색

페이지 토큰 지정을 활용하여 결과를 검색하는 것은 매우 단순합니다. 이전의 검색 API 요청(요청 3단계) 결과에서 획득한 pageToken 값을 담아서 다음과 같이 client.search.byPageToken 메서드를 실행하기만 하면 됩니다. 반환되는 응답 본문은 기존 이미지 기반 검색 결과(요청 3단계)의 형태와 완전히 동일합니다. 

server.js (영역 107 - 120)

/** Get search results of a specific page */
app.get(
 "/search/:pageToken",
 asyncHandler(async (req, res, next) => {
   const { pageToken } = req.params;


   let searchByPageResponse = await client.search.byPageToken(`${pageToken}`);


   res.json({
     searchResults: searchByPageResponse.data,
     pageInfo: searchByPageResponse.pageInfo,
   });
 })
);

클라이언트

필수 API 엔드포인트들을 포함하는 서버 세팅이 완벽히 해결되었으므로, 이제 클라이언트 코드로 포커스를 맞출 수 있습니다. 이 부분은 서버에 직접 API 요청을 보낸 다음 수신한 실제 데이터들을 연동 처리하는 중책을 맡게 됩니다.

서버에서 확립했던 설계 흐름대로, 인덱스로부터 비디오를 초기에 어떻게 노출하는지 먼저 점검해 보겠습니다. 그런 다음 비디오를 검색하고 검색 결과 리스트에 무한 페이징 처리하는 실제 구현 단계로 가보겠습니다.

1 - 인덱스의 비디오 목록 화면에 뿌려주기

showVideos 함수

어플리케이션 화면이 브라우저 상에서 리렌더링 될 때 구동되는 핵심 로직 중 하나가 바로 showVideos입니다. 

script.js (영역 434 - 460)

async function showVideos(page = 1) {
 videoList.innerHTML = "";


 ...


 try {
   const { videosDetail, pageInfo } = await getVideoOfVideos(page);


   videoListLoading.removeChild(loadingSpinnerContainer);


   if (videosDetail) {
     videosDetail.forEach((video) => {
       const videoContainer = createVideoContainer(video);
       videoList.appendChild(videoContainer);
     });


     videoListLoading.classList.remove("min-h-[300px]");


     createPaginationButtons(pageInfo, page);
   }
 } catch (error) {
   console.error("Error fetching videos:", error);
 }
}



  • DOM상의 현재 비디오 항목 리스트 영역을 말끔히 비웁니다.

  • getVideoOfVideos를 실행하여 주어지는 특정 페이지 번호 안의 모든 비디오 개별 상세 데이터를 리트리브합니다.

  • 조회된 세부 데이터를 토대로 순회 루프(loop)를 돌리며 비디오 컴포넌트 껍데기를 만들어 리스트 내부에 주입(append)합니다.

  • 마지막 단계로, 리트리브한 pageInfo 스펙을 정밀 체크하여 페이지 이동 버튼 그룹을 올바르게 세팅합니다.

getVideoOfVideos 함수

getVideoOfVideos 함수는 명시된 특정 단일 페이지 내의 모든 비디오 데이터 셋을 조회한 뒤, 이어서 비디오 건별 상세 프로퍼티에 접근하는 두 단계 행동을 취합니다.

script.js (영역 523 - 533)

async function getVideoOfVideos(page = 1) {
 const videosResponse = await getVideos(page);


 if (videosResponse.videos.length > 0) {
   const videosDetail = await Promise.all(
     videosResponse.videos.map((video) => getVideo(video.id))
   );


   return { videosDetail, pageInfo: videosResponse.page_info };
 }
}
  • getVideos 함수는 인자로 주어지는 페이지 기준의 비디오 셋을 가져와 달라고 서버의 API 엔드포인트(서버 파트 요청 1단계 명세 사항)로 리퀘스트를 날립니다.

  • 해당 요청이 정상적으로 완수되어 비디오 셋이 실존하는 것으로 진단되면 비디오 항목별 순환 루프를 시작하고, 서버측 요청 2단계로 주어지는 getVideo 리퀘스트 로직으로 상세 스펙을 온전히 조회합니다.

  • 조회 성료 후의 후속 사용 시 퍼포먼스를 극대화하기 위하여, 리트리브한 이 디테일 정보 조각들은 내부 캐싱 시스템 레이어로 안전하게 세이브됩니다.

비디오 컨테이너 동적 생성

비디오 상세 조회가 정상 완료되어 데이터 배치가 준비되는 즉시, showVideos 함수는 비디오 URL, 썸네일 경로, 인덱스 타겟 비디오 타이틀을 연동 처리하여 비디오 목록 UI 블록을 동적 구축합니다.

페이지네이션 컴포넌트

마지막 순서로, getVideos 로직 수행으로 도출된 최종 전체 페이지 총 개수 데이터 스펙에 기반하여 페이지네이션 컴포넌트가 UI에 자동 구성됩니다. 각각의 페이지 숫자 버튼마다 사용자의 마우스 클릭 이벤트를 구독하는 리스너(Listener) 장치가 부착되며 이에 연동된 해당 숫자 기준의 showVideos가 원활히 재실행됩니다.

script.js (영역 498 - 521)

function createPageButton(pageNumber, currentPage) {
 const pageButton = document.createElement("button");
 pageButton.textContent = pageNumber;
  
  ...


 if (pageNumber === currentPage) {
   pageButton.classList.add("bg-slate-200", "font-medium");
   pageButton.disabled = true;
 } else {
   pageButton.classList.add("bg-transparent");
   pageButton.addEventListener("click", () => showVideos(pageNumber));
 }


 return pageButton;
}

2 - 전달받은 이미지를 이용하여 인덱스 내 비디오 데이터 검색하기

엔드 유저가 Search 인터랙션 컴포넌트를 마우스 클릭하면 화면 단의 handleSearchButtonClick 핵심 비즈니스 로직 함수가 기동하며 백엔드 단으로 실질적인 검색 처리를 구하는 신호를 전달합니다. 이 구동 스텝들을 순서대로 찬찬히 추적해 보겠습니다.

script.js (영역 62 - 89)

async function handleSearchButtonClick() {
 toggleSearchButton(false);


 nextPageToken = null;
 searchResultContainer.innerHTML = "";
 videoListContainer.classList.add("hidden");
 searchResultContainer.classList.remove("hidden");
 searchResultList.innerHTML = "";


 const loadingSpinnerContainer = createLoadingSpinner();
 searchResultContainer.appendChild(loadingSpinnerContainer);


 try {
   const { searchResults } = await searchByImage();


   searchResultContainer.removeChild(loadingSpinnerContainer);


   if (searchResults.length > 0) {
     showSearchResults(searchResults);
   } else {
     displayNoResultsMessage();
   }
 } catch (error) {
   console.error("Error fetching search results:", error);
 } finally {
   toggleSearchButton(true);
 }
}

제일 먼저, 과도한 연속 클릭 방지를 통한 최적 데이터 연동 보호 목적 상 프로세스의 전반부가 진행되는 동안 검색 UI 컴포넌트를 즉시 일시 비활성화 처리해 놓습니다.

  • 다음으로, 메모리상의 다음 검색 페이지 트래킹 목적 지시자인 nextPageToken 정보를 널 가비지 데이터(null)로 안전하게 플러시(flush) 시키고, DOM 트리상의 searchResultContainer 하위 엘리먼트 껍데기와 searchResultList 내부 리스트 항목들을 완전히 클렌징시킵니다. 아울러 기존 메인 화면을 메우고 있던 기존 비디오 목록 뷰(videoListContainer)는 CSS 디스플레이 히든 레이어로 마스킹시키고 그 대신 searchResultContainer는 활력 있게 보여줍니다.

  • 사용자로 하여금 비디오 분석 시스템 작동 처리가 순조롭게 반응 작동 중임을 육안으로 감지할 수 있도록 로딩 스피너 리소스 객체를 생성하여 화면 한가운데에 즉시 띄워 배치합니다.

  • 그리고 검색 연동 전용 헬퍼 함수인 searchByImage를 실행하여 서버 측의 분석 리퀘스트 포인트로 연결 신호를 보냅니다.

  • 서버 연산 작업 성료 시점이 감지되면 올려져 있던 로딩 스피너 UI 노드를 신속 삭제한 후, 실질적 매칭 데이터 내용이 있다면 showSearchResults UI 블록을 호출하여 정밀 그리드를 생성하고, 매칭에 유효한 정보 블록이 일절 식별되지 않았다면 결과 없음 전용 가이드 텍스트 장치를 스크린 상에 렌더링하도록 흐름을 우회 분기시킵니다.

  • 최종 단계로, 프로세스의 진행 상태 플래그를 정지 모드로 원복시켜 검색 버튼을 활성 상태로 다시 전환하므로 유저가 희망 시 추가 신규 검색 사이클을 부담 없이 시작할 수 있도록 조치합니다.

3 - 페이지 토큰을 활용한 후속 추가 검색 목록 연속 갱신 처리

리트리브해 가져올 대상 비디오 검색 리스트 볼륨이 지정 규격 크기를 상회해 다음 후속 연속 페이지를 필히 가리켜야만 하는 구조라면 (nextPageToken 값이 정상 수신되어 실존한다면), 화면 어플리케이션은 createShowMoreButton 컴포넌트 헬पर 함수를 즉각 시동하여 스크린 상단에 "Show More" 컴포넌트를 깔끔하게 배치합니다. 엔드 유저가 이 가독성 높은 인터랙션 컴포넌트를 실행하면 다음 회차 분량의 분석 검색 데이터 리스트 팩이 끊김 없이 매끄럽게 가져와집니다. 세부 구동 방식들을 확인해 보겠습니다.

script.js (영역 296 - 319)

function createShowMoreButton() {
 removeExistingButton();


 const showMoreButtonContainer = document.createElement("div");
 showMoreButtonContainer.id = "show-more-button";
 showMoreButtonContainer.classList.add("flex", "justify-center", "my-4");


 const showMoreButton = document.createElement("button");
 showMoreButton.innerHTML = '<i class="fa-solid fa-chevron-up"></i> Show More';


 showMoreButton.addEventListener("click", async () => {
   const loadingSpinnerContainer = createLoadingSpinner();
   searchResultContainer.appendChild(loadingSpinnerContainer);


   const nextPageResults = await getNextSearchResults(nextPageToken);


   searchResultContainer.removeChild(loadingSpinnerContainer);


   showSearchResults(nextPageResults.searchResults);
   nextPageToken = nextPageResults.pageInfo.nextPageToken || null;
 });
 showMoreButtonContainer.appendChild(showMoreButton);
 searchResultContainer.appendChild(showMoreButtonContainer);
}
  • 우선순위로, 기존 레이아웃 플로우에 이미 렌더링 되어 혹시 자리 잡고 있을지 모를 노후 잔여 "Show More" 컴포넌트 장치들을 일제히 완벽 회수 제거 처리하여 화면 중복 마운트 버그를 선제적 방지합니다.

  • 다음으로, 깨끗한 UI 연동에 맞는 감각적인 "Show More" 타겟 버튼 엘리먼트와 공간 컨테이너 블록을 세심하게 동적 생성해 놓습니다.

  • 이렇게 동적으로 빌드해 낸 해당 타겟 버튼에 마우스 단일 클릭 제스처를 모니터할 전담 이벤트 리스너를 결속(bind)시킵니다. 이에 반응하여 화면에는 작업 로딩 표시용 스피너가 구동되고, 서버로 다음 차례 검색 결과 페이지 번들을 비동기 리트리브해 가져올 getNextSearchResults 통신 로직이 속속 실행되며, 통신 완수 감지 시 스피너를 무대 뒤로 퇴장시키고, 스크린 상단에 기존 결과 리스트에 다음 리브 항목들을 부드럽게 병합 표시해 준 뒤, 마지막으로 다음 타겟 이동 목적 지표인 nextPageToken 포인터 버퍼 정보를 후속 업데이트합니다.

  • 전체 처리 구동 사이클이 안정화되어 가동되면 UI 조립 단계의 마무리로 완성해 낸 컨테이너와 "Show More" 실행 버튼 모듈을 메인 searchResultContainer 하부 레이아웃 위치에 완벽히 추가 부착하여 실 사용자가 볼 수 있도록 물리 배치합니다.

마치며

이번 포스팅을 통해 Twelve Labs의 최신 이미지 투 비디오(Image-to-Video) 검색 API를 깊이 있게 들여다보고, 실전 앱에 비즈니스 인텔리전스로 녹여 적용해 내는 아이디어에 대해 흥미로운 통찰력을 얻으셨기를 바랍니다. 읽어주셔서 진심으로 감사드리며, 차세대 비디오 분석 검색 혁신 솔루션 기능들을 여러분들의 혁신적인 프로젝트에도 세련되게 적용하여 놀라운 가치를 만들어 가시기를 즐거운 마음으로 기대해 보겠습니다!

비디오에서 특정 색상을 정확히 찾아내고 싶었던 적이 있으신가요? 예를 들어 좋아하는 색상이 포함된 제품이나 특정 순간을 찾고 싶을 때처럼 말이죠. 최근 저는 퍼스널 컬러 진단 서비스를 받고 저에게 베리 계열의 색상이 가장 잘 어울린다는 사실을 발견했습니다.

그동안 모아둔 유튜브 비디오 보관함을 살펴보면서, 바로 그 정확한 색상의 제품들을 쉽게 찾을 수 있는 방법이 있으면 좋겠다고 생각했습니다. 다행히 Twelve Labs의 이미지 투 비디오(Image-to-Video) 검색 기술을 활용하여 이를 정확히 수행하는 앱을 개발할 수 있었습니다.

이 튜토리얼에서는 Twelve Labs API를 사용하여 어떻게 "Shade Finder" 앱을 구축했는지 단계별로 안내해 드리겠습니다. 완벽한 베리 톤의 립스틱을 찾고 싶으시거나 비디오 내에서 특정 색상을 편리하게 감지하는 방법이 궁금하시다면, 이 가이드가 최첨단 AI를 활용해 이를 쉽게 해결하는 데 도움이 될 것입니다. 그럼 시작해 볼까요!

📌 데모를 확인해 보세요!

사전 준비 사항

  • Twelve Labs Playground를 방문하여 가입하고 API 키를 생성하세요.

  • 다음으로 인덱스를 생성하고 해당 인덱스에 비디오를 업로드합니다. 이 작업이 완료되면 비디오 검색을 시작할 준비가 끝납니다! 

  • 이 앱은 JavaScript와 Node로 구축되었습니다. 

  • 이 앱의 모든 파일이 포함된 저장소는 Github에서 확인하실 수 있습니다.

목차

이 앱의 구조는 매우 직관적이고 이해하기 쉽습니다. 크게 보면 index.html, script.js, server.js의 세 가지 주요 구성 요소로 이루어져 있습니다. 

먼저 index.html의 개요를 빠르게 살펴본 다음, 서버 측과 클라이언트 측의 전체적인 흐름을 심층적으로 다루겠습니다. 여기에는 비디오 목록 가져오기, 단일 비디오 조회하기, 이미지 기반 검색 수행하기, 그리고 페이지 토큰을 활용한 검색 결과 페이징 처리가 포함됩니다.

HTML

index.html 파일은 앱의 뼈대 역할을 하여 기본적인 구조와 레이아웃을 제공합니다. server.js 파일은 SDK를 통해 Twelve Labs API로 향하는 모든 API 호출을 관리하며, 앱이 관련 데이터를 효율적으로 처리하고 반환할 수 있도록 보장합니다. script.js 파일은 클라이언트 측 로직으로 작동하여 사용자 상호작용을 처리하고, 서버에 요청을 보내며, 검색 작업을 실행합니다. 

아래는 앱의 핵심 구성 요소를 리스트업한 index.html의 body 영역입니다. 

  • 쿼리 이미지를 보여주기 위한 이미지 캐러셀

  • 검색을 시작하기 위한 검색 버튼

  • 지정된 인덱스의 비디오들을 보여주는 비디오 리스트

  • 검색 버튼을 클릭한 후 결과를 표시하는 검색 결과 섹션

Index.html

<body>
<h1 class="text-3xl text-center m-5 p-3"><i class="fa-solid fa-palette"><&sol;i> Shade Finder<&sol;h1>


 <div class="m-5 p-3">
   <p class="text-center m-5" id="color-label"><&sol;p>
   <div class="flex justify-center gap-5">
     <button id="prev"><i class="fa-solid fa-chevron-left"><&sol;i><&sol;button>
     <div class="size-40"><img id="carousel-image"><&sol;div>
     <button id="next"><i class="fa-solid fa-chevron-right"><&sol;i><&sol;button>
   <&sol;div>
   <div class="flex justify-center m-5 gap-2">
     <button id="search" class="bg-lime-400 py-2.5 px-3">Search<&sol;button>
   <&sol;div>
 <&sol;div>


 <div id ="video-list-container" class="container max-w-5xl mx-auto py-4">
 <div id ="video-list-loading" class="container max-w-5xl mx-auto py-4"> <&sol;div>
 <div id="video-list" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 justify-items-center"><&sol;div>
 <div id="video-list-pagination" class="flex justify-center m-5 gap-2"><&sol;div>
 <&sol;div>


 <div id="search-result-container" class="container w-5/6 mx-auto py-4 hidden">
 <div id="search-result-list" class="grid grid-cols-1 md:grid-cols-4 justify-center"><&sol;div>
 <&sol;div>


 <script src="./script.js"><&sol;script>
<&sol;body

서버

server.js는 Twelve Labs API에 대한 모든 API 호출을 관리하는 파일입니다. 여기에는 비디오 목록 조회 및 페이징, 단일 비디오 조회, 이미지 투 비디오 검색, 그리고 페이지 토큰 기반 검색 결과 조회의 4가지 라우트가 존재합니다. 

Twelve Labs API를 위한 4가지 요청

💡 Twelve Labs는 개발 중인 애플리케이션에 플랫폼을 손쉽게 연동하여 사용할 수 있도록 SDK를 제공합니다. 이 앱에서는 Javascript SDK(버전 0.2.5)를 사용했습니다. 

설정 단계

1 - Twelve Labs API Key와 Index Id를 .env에 저장하기

백엔드 폴더 내부에서 키 값이 주석 처리된 .env 파일을 찾을 수 있습니다. 주석을 해제하고 실제 값으로 변경해 주세요. 

.env

TWELVE_LABS_API_KEY=<YOUR API KEY>
TWELVE_LABS_INDEX_ID=<YOUR_INDEX_ID>

2 - Twelve Labs SDK 설치 및 임포트 

먼저, twelvelabs-js 패키지를 설치합니다.

yarn add twelvelabs-js # or npm i twelvelabs-js

그 다음, 필요한 패키지들을 애플리케이션으로 가져옵니다. Node.js로 작성된 server.js 파일에 다음과 같이 패키지를 임포트했습니다. 

server.js (영역 7 - 9)

const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");

마지막으로 발급받은 API 키를 전달하여 SDK 클라이언트 객체를 생성합니다.  

server.js (영역 23)

const client = new TwelveLabs({ apiKey: API_KEY });

전체적인 코드는 아래에서 확인하실 수 있습니다. 

server.js (영역 1 - 25)

"use strict";


const express = require("express");
const dotenv = require("dotenv");
const cors = require("cors");
const asyncHandler = require("express-async-handler");
const fs = require("fs");
const path = require("path");
const { TwelveLabs } = require("twelvelabs-js");


dotenv.config();


const app = express();


app.use(express.json());
app.use(cors());
app.use(express.static(path.join(__dirname, "../frontend/public")));


const PORT = 5001;
const API_KEY = process.env.TWELVE_LABS_API_KEY;
const INDEX_ID = process.env.TWELVE_LABS_INDEX_ID;


const client = new TwelveLabs({ apiKey: API_KEY });


app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

요청 1. 비디오 목록 가져오기 및 페이징

페이지별로 비디오 목록을 조회하려면 client.index.video.listPagination 메서드를 사용하고 인덱스 ID와 조회할 페이지를 전달하면 됩니다. 필요한 경우 한 페이지에 반환할 비디오 수를 제어할 수 있는 pageLimit 파라미터를 함께 전달할 수 있습니다. 

‍💡 팁: API 레퍼런스 문서 문서에 기재된 모든 요청용 파라미터들은 Javascript SDK 환경에서 카멜 케이스(camelCase) 형식으로 변환하여 동일하게 사용할 수 있습니다.

비디오 목록 데이터를 수신한 후, 이후 사용할 수 있도록 각 비디오의 idmetadata를 추출하고 pageInfo와 함께 반환합니다.

‍💡 비디오 페이징 처리에 대한 자세한 내용은 가이드 문서 가이드를 참조하세요. 공식 예제 코드도 큰 도움이 될 것입니다!

server.js (영역 34 - 55)

/** Get videos */
app.get(
 "/videos",
 asyncHandler(async (req, res, next) => {
   const { page_limit, page } = req.query;


   const videosResponse = await client.index.video.listPagination(INDEX_ID, {
     pageLimit: page_limit,
     page: page,
   });


   const videos = videosResponse.data.map((video) => ({
     id: video.id,
     metadata: video.metadata,
   }));


   res.json({
     videos,
     page_info: videosResponse.pageInfo,
   });
 })
);

비디오 응답 객체 예시

videosResponse= VideoListWithPagination {  
  ...,
  data: [
    Video {
      _resource: [Video],
      _indexId: '...',
      id: '...',
      metadata: [Object],
      hls: undefined,
      source: undefined,
      indexedAt: '2024-06-27T05:11:29Z',
      createdAt: '2024-06-27T05:01:35Z',
      updatedAt: '2024-06-27T05:01:52Z'
    },
  	 ...
       ],
  pageInfo: {
    page: 1,
    limitPerPage: 12,
    totalPage: 3,
    totalResults: 29,
    totalDuration: 19122
  }
}

요청 2. 단일 비디오 조회하기

비디오 목록 조회와 마찬가지로, 인덱스 ID와 비디오 ID를 client.index.video.retrieve 메서드에 전달하여 단일 비디오의 상세 정보를 가져올 수 있습니다. 

비디오 정보를 수신한 후, 필요한 최소 정보인 metadata, hlssource 정보만 추출하여 반환합니다. 구체적으로 나중 단계에서 메타데이터의 비디오 제목, HLS의 썸네일 URL(thumbnailUrls), 소스의 URL 정보들을 요긴하게 사용하게 됩니다.

server.js (영역 57 - 71)

/** Get a video of an index */
app.get(
 "/videos/:videoId",
 asyncHandler(async (req, res, next) => {
   const { videoId } = req.params;


   const videoResponse = await client.index.video.retrieve(INDEX_ID, videoId);


   res.json({
     metadata: videoResponse.metadata,
     hls: videoResponse.hls,
     source: videoResponse.source,
   });
 })
);

비디오 조회 응답 객체 예시

videoResponse= Video {
  ...,
  id: '...',
  metadata: {
    duration: 54,
    engine_ids: [ 'marengo2.6', 'pegasus1.1' ],
    filename: 'tirtir korean cushion review',
    fps: 30,
    height: 1280,
    size: 9601300,
    video_title: 'tirtir korean cushion review',
    width: 720
  },
  hls: {
    videoUrl: '... .m3u8',
    thumbnailUrls: ['... .jpg'],
    status: 'COMPLETE',
    updatedAt: '2024-05-22T02:49:49.074Z'
  },
  source: {
    type: 'youtube',
    name: 'theoliviasaurusrex',
    url: 'https://www.youtube.com/watch?v=tOabvdtTa-U'
  },
  indexedAt: '2024-05-22T03:03:53Z',
  createdAt: '2024-05-22T02:49:28Z',
  updatedAt: '2024-05-22T02:49:36Z'
}

요청 3. 이미지 투 비디오 검색 (Image to Video Search)

이제 가장 재미있는 파트입니다! client.search.query 메서드를 사용하여 이미지 기반의 비디오 검색을 실행합니다. 이를 위해서는 필수 파라미터 네 가지인 indexId, queryMediaFile, queryMediaType, options를 전달해야 합니다. 

특히 queryMediaFile을 올바르게 전달하려면 몇 가지 작업 단계를 거쳐야 합니다.

  • 경로 생성(Path Construction): 우선, 이미지 파일이 저장되어 있는 전체 경로를 생성해야 합니다. 이 앱의 경우 이미 기기 안의 images 폴더 안에 이미지 파일들이 보관되어 있습니다. 따라서 현재 디렉터리 경로(__dirname), 해당 앱의 상대 경로 구조(../frontend/public/images), 그리고 실제 파일 이름(imageSrc)을 조합하여 경로를 완성합니다.

  • 존재 여부 검증(Existence Check): 경로 생성이 완료되면 실제 해당 위치에 이미지 파일이 존재하는지 검증합니다. 파일을 찾을 수 없다면 클라이언트로 404 에러 응답을 실어서 보냅니다.

  • 읽기 스트림 생성(Read Stream Creation): 파일이 정상적으로 존재할 경우 이미지 파일로부터 Readable Stream을 인스턴스화합니다. 이렇게 생성된 스트림을 Twelve Labs API로 안전하고 효율적으로 전송합니다.

이 앱에서는 추가적으로 threshold, pageLimit, adjustConfidenceLevel 같은 선택적 파라미터들도 함께 사용하고 있습니다. 사용 가능한 파라미터들의 세부 항목은 언제든지 API 레퍼런스 문서에서 한눈에 확인해 보실 수 있습니다. 

비디오 검색 결과를 돌려받은 뒤, 이후 화면 단 표시 및 후속 처리에 사용할 수 있도록 datapageInfo 정보를 추출하여 클라이언트에게 다시 반환합니다.‍

💡 이미지 검색 기능에 대해 더 알고 싶으시다면 가이드 문서 가이드를 확인해 보세요. 공식 예제 코드 자료도 아주 유용하게 쓰일 수 있습니다!‍

server.js (영역 73 - 105)

/** Search videos based on an image query */
app.get(
 "/search",
 asyncHandler(async (req, res, next) => {
   const { imageSrc, threshold, pageLimit, adjustConfidenceLevel } = req.query;


   const imagePath = path.join(
     __dirname,
     "../frontend/public/images",
     imageSrc
   );


   if (!fs.existsSync(imagePath)) {
     console.error("Image not found at path:", imagePath);
     return res.status(404).json({ error: "Image not found" });
   }


   const searchResponse = await client.search.query({
     indexId: INDEX_ID,
     queryMediaFile: fs.createReadStream(imagePath),
     queryMediaType: "image",
     options: ["visual"],
     threshold: threshold,
     pageLimit: pageLimit,
     adjustConfidenceLevel: adjustConfidenceLevel,
   });


   res.json({
     searchResults: searchResponse.data,
     pageInfo: searchResponse.pageInfo,
   });
 })
);

검색 결과 응답 객체 예시

searchResponse= SearchResult {
  ...,
  pool: {
    totalCount: 29,
    totalDuration: 19122,
    indexId: '...'
  },
  data: [
    {
      score: 84.45,
      start: 379.13333333341933,
      end: 381,
      metadata: [Array],
      videoId: '...',
      confidence: 'high',
      thumbnailUrl: '...'
    },
      ...
      ],
  pageInfo: {
    limitPerPage: 12,
    totalResults: 20,
    pageExpiredAt: '2024-08-15T04:09:46Z'
    nextPageToken: '...' //This might not exist 
  }
}

요청 4. 페이지 토큰 지정을 통한 검색

페이지 토큰 지정을 활용하여 결과를 검색하는 것은 매우 단순합니다. 이전의 검색 API 요청(요청 3단계) 결과에서 획득한 pageToken 값을 담아서 다음과 같이 client.search.byPageToken 메서드를 실행하기만 하면 됩니다. 반환되는 응답 본문은 기존 이미지 기반 검색 결과(요청 3단계)의 형태와 완전히 동일합니다. 

server.js (영역 107 - 120)

/** Get search results of a specific page */
app.get(
 "/search/:pageToken",
 asyncHandler(async (req, res, next) => {
   const { pageToken } = req.params;


   let searchByPageResponse = await client.search.byPageToken(`${pageToken}`);


   res.json({
     searchResults: searchByPageResponse.data,
     pageInfo: searchByPageResponse.pageInfo,
   });
 })
);

클라이언트

필수 API 엔드포인트들을 포함하는 서버 세팅이 완벽히 해결되었으므로, 이제 클라이언트 코드로 포커스를 맞출 수 있습니다. 이 부분은 서버에 직접 API 요청을 보낸 다음 수신한 실제 데이터들을 연동 처리하는 중책을 맡게 됩니다.

서버에서 확립했던 설계 흐름대로, 인덱스로부터 비디오를 초기에 어떻게 노출하는지 먼저 점검해 보겠습니다. 그런 다음 비디오를 검색하고 검색 결과 리스트에 무한 페이징 처리하는 실제 구현 단계로 가보겠습니다.

1 - 인덱스의 비디오 목록 화면에 뿌려주기

showVideos 함수

어플리케이션 화면이 브라우저 상에서 리렌더링 될 때 구동되는 핵심 로직 중 하나가 바로 showVideos입니다. 

script.js (영역 434 - 460)

async function showVideos(page = 1) {
 videoList.innerHTML = "";


 ...


 try {
   const { videosDetail, pageInfo } = await getVideoOfVideos(page);


   videoListLoading.removeChild(loadingSpinnerContainer);


   if (videosDetail) {
     videosDetail.forEach((video) => {
       const videoContainer = createVideoContainer(video);
       videoList.appendChild(videoContainer);
     });


     videoListLoading.classList.remove("min-h-[300px]");


     createPaginationButtons(pageInfo, page);
   }
 } catch (error) {
   console.error("Error fetching videos:", error);
 }
}



  • DOM상의 현재 비디오 항목 리스트 영역을 말끔히 비웁니다.

  • getVideoOfVideos를 실행하여 주어지는 특정 페이지 번호 안의 모든 비디오 개별 상세 데이터를 리트리브합니다.

  • 조회된 세부 데이터를 토대로 순회 루프(loop)를 돌리며 비디오 컴포넌트 껍데기를 만들어 리스트 내부에 주입(append)합니다.

  • 마지막 단계로, 리트리브한 pageInfo 스펙을 정밀 체크하여 페이지 이동 버튼 그룹을 올바르게 세팅합니다.

getVideoOfVideos 함수

getVideoOfVideos 함수는 명시된 특정 단일 페이지 내의 모든 비디오 데이터 셋을 조회한 뒤, 이어서 비디오 건별 상세 프로퍼티에 접근하는 두 단계 행동을 취합니다.

script.js (영역 523 - 533)

async function getVideoOfVideos(page = 1) {
 const videosResponse = await getVideos(page);


 if (videosResponse.videos.length > 0) {
   const videosDetail = await Promise.all(
     videosResponse.videos.map((video) => getVideo(video.id))
   );


   return { videosDetail, pageInfo: videosResponse.page_info };
 }
}
  • getVideos 함수는 인자로 주어지는 페이지 기준의 비디오 셋을 가져와 달라고 서버의 API 엔드포인트(서버 파트 요청 1단계 명세 사항)로 리퀘스트를 날립니다.

  • 해당 요청이 정상적으로 완수되어 비디오 셋이 실존하는 것으로 진단되면 비디오 항목별 순환 루프를 시작하고, 서버측 요청 2단계로 주어지는 getVideo 리퀘스트 로직으로 상세 스펙을 온전히 조회합니다.

  • 조회 성료 후의 후속 사용 시 퍼포먼스를 극대화하기 위하여, 리트리브한 이 디테일 정보 조각들은 내부 캐싱 시스템 레이어로 안전하게 세이브됩니다.

비디오 컨테이너 동적 생성

비디오 상세 조회가 정상 완료되어 데이터 배치가 준비되는 즉시, showVideos 함수는 비디오 URL, 썸네일 경로, 인덱스 타겟 비디오 타이틀을 연동 처리하여 비디오 목록 UI 블록을 동적 구축합니다.

페이지네이션 컴포넌트

마지막 순서로, getVideos 로직 수행으로 도출된 최종 전체 페이지 총 개수 데이터 스펙에 기반하여 페이지네이션 컴포넌트가 UI에 자동 구성됩니다. 각각의 페이지 숫자 버튼마다 사용자의 마우스 클릭 이벤트를 구독하는 리스너(Listener) 장치가 부착되며 이에 연동된 해당 숫자 기준의 showVideos가 원활히 재실행됩니다.

script.js (영역 498 - 521)

function createPageButton(pageNumber, currentPage) {
 const pageButton = document.createElement("button");
 pageButton.textContent = pageNumber;
  
  ...


 if (pageNumber === currentPage) {
   pageButton.classList.add("bg-slate-200", "font-medium");
   pageButton.disabled = true;
 } else {
   pageButton.classList.add("bg-transparent");
   pageButton.addEventListener("click", () => showVideos(pageNumber));
 }


 return pageButton;
}

2 - 전달받은 이미지를 이용하여 인덱스 내 비디오 데이터 검색하기

엔드 유저가 Search 인터랙션 컴포넌트를 마우스 클릭하면 화면 단의 handleSearchButtonClick 핵심 비즈니스 로직 함수가 기동하며 백엔드 단으로 실질적인 검색 처리를 구하는 신호를 전달합니다. 이 구동 스텝들을 순서대로 찬찬히 추적해 보겠습니다.

script.js (영역 62 - 89)

async function handleSearchButtonClick() {
 toggleSearchButton(false);


 nextPageToken = null;
 searchResultContainer.innerHTML = "";
 videoListContainer.classList.add("hidden");
 searchResultContainer.classList.remove("hidden");
 searchResultList.innerHTML = "";


 const loadingSpinnerContainer = createLoadingSpinner();
 searchResultContainer.appendChild(loadingSpinnerContainer);


 try {
   const { searchResults } = await searchByImage();


   searchResultContainer.removeChild(loadingSpinnerContainer);


   if (searchResults.length > 0) {
     showSearchResults(searchResults);
   } else {
     displayNoResultsMessage();
   }
 } catch (error) {
   console.error("Error fetching search results:", error);
 } finally {
   toggleSearchButton(true);
 }
}

제일 먼저, 과도한 연속 클릭 방지를 통한 최적 데이터 연동 보호 목적 상 프로세스의 전반부가 진행되는 동안 검색 UI 컴포넌트를 즉시 일시 비활성화 처리해 놓습니다.

  • 다음으로, 메모리상의 다음 검색 페이지 트래킹 목적 지시자인 nextPageToken 정보를 널 가비지 데이터(null)로 안전하게 플러시(flush) 시키고, DOM 트리상의 searchResultContainer 하위 엘리먼트 껍데기와 searchResultList 내부 리스트 항목들을 완전히 클렌징시킵니다. 아울러 기존 메인 화면을 메우고 있던 기존 비디오 목록 뷰(videoListContainer)는 CSS 디스플레이 히든 레이어로 마스킹시키고 그 대신 searchResultContainer는 활력 있게 보여줍니다.

  • 사용자로 하여금 비디오 분석 시스템 작동 처리가 순조롭게 반응 작동 중임을 육안으로 감지할 수 있도록 로딩 스피너 리소스 객체를 생성하여 화면 한가운데에 즉시 띄워 배치합니다.

  • 그리고 검색 연동 전용 헬퍼 함수인 searchByImage를 실행하여 서버 측의 분석 리퀘스트 포인트로 연결 신호를 보냅니다.

  • 서버 연산 작업 성료 시점이 감지되면 올려져 있던 로딩 스피너 UI 노드를 신속 삭제한 후, 실질적 매칭 데이터 내용이 있다면 showSearchResults UI 블록을 호출하여 정밀 그리드를 생성하고, 매칭에 유효한 정보 블록이 일절 식별되지 않았다면 결과 없음 전용 가이드 텍스트 장치를 스크린 상에 렌더링하도록 흐름을 우회 분기시킵니다.

  • 최종 단계로, 프로세스의 진행 상태 플래그를 정지 모드로 원복시켜 검색 버튼을 활성 상태로 다시 전환하므로 유저가 희망 시 추가 신규 검색 사이클을 부담 없이 시작할 수 있도록 조치합니다.

3 - 페이지 토큰을 활용한 후속 추가 검색 목록 연속 갱신 처리

리트리브해 가져올 대상 비디오 검색 리스트 볼륨이 지정 규격 크기를 상회해 다음 후속 연속 페이지를 필히 가리켜야만 하는 구조라면 (nextPageToken 값이 정상 수신되어 실존한다면), 화면 어플리케이션은 createShowMoreButton 컴포넌트 헬पर 함수를 즉각 시동하여 스크린 상단에 "Show More" 컴포넌트를 깔끔하게 배치합니다. 엔드 유저가 이 가독성 높은 인터랙션 컴포넌트를 실행하면 다음 회차 분량의 분석 검색 데이터 리스트 팩이 끊김 없이 매끄럽게 가져와집니다. 세부 구동 방식들을 확인해 보겠습니다.

script.js (영역 296 - 319)

function createShowMoreButton() {
 removeExistingButton();


 const showMoreButtonContainer = document.createElement("div");
 showMoreButtonContainer.id = "show-more-button";
 showMoreButtonContainer.classList.add("flex", "justify-center", "my-4");


 const showMoreButton = document.createElement("button");
 showMoreButton.innerHTML = '<i class="fa-solid fa-chevron-up"></i> Show More';


 showMoreButton.addEventListener("click", async () => {
   const loadingSpinnerContainer = createLoadingSpinner();
   searchResultContainer.appendChild(loadingSpinnerContainer);


   const nextPageResults = await getNextSearchResults(nextPageToken);


   searchResultContainer.removeChild(loadingSpinnerContainer);


   showSearchResults(nextPageResults.searchResults);
   nextPageToken = nextPageResults.pageInfo.nextPageToken || null;
 });
 showMoreButtonContainer.appendChild(showMoreButton);
 searchResultContainer.appendChild(showMoreButtonContainer);
}
  • 우선순위로, 기존 레이아웃 플로우에 이미 렌더링 되어 혹시 자리 잡고 있을지 모를 노후 잔여 "Show More" 컴포넌트 장치들을 일제히 완벽 회수 제거 처리하여 화면 중복 마운트 버그를 선제적 방지합니다.

  • 다음으로, 깨끗한 UI 연동에 맞는 감각적인 "Show More" 타겟 버튼 엘리먼트와 공간 컨테이너 블록을 세심하게 동적 생성해 놓습니다.

  • 이렇게 동적으로 빌드해 낸 해당 타겟 버튼에 마우스 단일 클릭 제스처를 모니터할 전담 이벤트 리스너를 결속(bind)시킵니다. 이에 반응하여 화면에는 작업 로딩 표시용 스피너가 구동되고, 서버로 다음 차례 검색 결과 페이지 번들을 비동기 리트리브해 가져올 getNextSearchResults 통신 로직이 속속 실행되며, 통신 완수 감지 시 스피너를 무대 뒤로 퇴장시키고, 스크린 상단에 기존 결과 리스트에 다음 리브 항목들을 부드럽게 병합 표시해 준 뒤, 마지막으로 다음 타겟 이동 목적 지표인 nextPageToken 포인터 버퍼 정보를 후속 업데이트합니다.

  • 전체 처리 구동 사이클이 안정화되어 가동되면 UI 조립 단계의 마무리로 완성해 낸 컨테이너와 "Show More" 실행 버튼 모듈을 메인 searchResultContainer 하부 레이아웃 위치에 완벽히 추가 부착하여 실 사용자가 볼 수 있도록 물리 배치합니다.

마치며

이번 포스팅을 통해 Twelve Labs의 최신 이미지 투 비디오(Image-to-Video) 검색 API를 깊이 있게 들여다보고, 실전 앱에 비즈니스 인텔리전스로 녹여 적용해 내는 아이디어에 대해 흥미로운 통찰력을 얻으셨기를 바랍니다. 읽어주셔서 진심으로 감사드리며, 차세대 비디오 분석 검색 혁신 솔루션 기능들을 여러분들의 혁신적인 프로젝트에도 세련되게 적용하여 놀라운 가치를 만들어 가시기를 즐거운 마음으로 기대해 보겠습니다!