From ced0e7685cacb3d69f67d874c270c0f4c27a2f46 Mon Sep 17 00:00:00 2001 From: kangpearl Date: Fri, 29 May 2026 23:16:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=99=84=EC=84=B1,=20=EB=8D=94=20=EB=B3=B4=EA=B8=B0,=20?= =?UTF-8?q?=EC=9E=90=EB=A7=89=20DB=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색창 Google 자동완성 드롭다운 추가 (/api/suggest 엔드포인트) - 검색 결과 더 보기 버튼 및 pageToken 기반 페이지네이션 추가 - 원본 자막 전체를 transcripts 테이블에 저장 (save_transcript) - action 문자열 기준 중복 단계 제거 (먼저 나온 것만 유지) - 요리 단계 시간 표시를 start_time만 표시하도록 변경 - 광고 감지 로직 추가 (시간 역주행 감지로 폴링 오작동 방지) - 카메라 후면 전환 및 에러 표시 개선 - vite allowedHosts 설정 추가 (ngrok 등 터널 접속 지원) Co-Authored-By: Claude Sonnet 4.6 --- backend/api/db_service.py | 31 ++++++ backend/api/youtube_search.py | 19 ++-- backend/main.py | 36 ++++++- frontend/src/components/CameraFeed.tsx | 26 +++-- frontend/src/components/CookingSteps.tsx | 2 +- frontend/src/components/SearchBar.tsx | 118 ++++++++++++++++++++--- frontend/src/components/VideoPlayer.tsx | 9 ++ frontend/vite.config.ts | 1 + 8 files changed, 207 insertions(+), 35 deletions(-) diff --git a/backend/api/db_service.py b/backend/api/db_service.py index efb96a8..2aeeeb6 100644 --- a/backend/api/db_service.py +++ b/backend/api/db_service.py @@ -4,6 +4,7 @@ from app.models.recipe import Recipe, ProcessingStatus from app.models.cooking_step import CookingStep from app.models.action_label import ActionLabel +from app.models.transcript import Transcript def _mm_ss_to_seconds(t: str) -> float: @@ -45,6 +46,26 @@ async def _resolve_action_label_id(session: AsyncSession, gesture_ko: str) -> in return label.id if label else None +async def save_transcript( + session: AsyncSession, + recipe_id: int, + transcript: list[dict], +) -> int: + """원본 자막 전체를 transcripts 테이블에 저장한다. 재호출 시 기존 데이터를 덮어쓴다.""" + await session.execute( + delete(Transcript).where(Transcript.recipe_id == recipe_id) + ) + for entry in transcript: + session.add(Transcript( + recipe_id=recipe_id, + start_time=float(entry["start"]), + duration=float(entry["duration"]), + text=entry["text"], + )) + await session.commit() + return len(transcript) + + async def save_cooking_steps( session: AsyncSession, recipe_id: int, @@ -55,6 +76,16 @@ async def save_cooking_steps( delete(CookingStep).where(CookingStep.recipe_id == recipe_id) ) + # action 문자열 기준 중복 제거 — 먼저 나온 단계만 유지 + seen: set[str] = set() + deduped = [] + for step in steps: + action = step.get("action", "") + if action not in seen: + seen.add(action) + deduped.append(step) + steps = deduped + saved = 0 for order, step in enumerate(steps, start=1): gesture = step.get("gesture") diff --git a/backend/api/youtube_search.py b/backend/api/youtube_search.py index 19e70ff..d53db3a 100644 --- a/backend/api/youtube_search.py +++ b/backend/api/youtube_search.py @@ -4,27 +4,32 @@ YOUTUBE_API_KEY = os.environ.get("YOUTUBE_API_KEY") -def search_videos(query: str, max_results: int = 15) -> list[dict]: +def search_videos(query: str, max_results: int = 15, page_token: str | None = None) -> dict: try: youtube = build("youtube", "v3", developerKey=YOUTUBE_API_KEY) - request = youtube.search().list( + params = dict( q=query + " 요리", part="snippet", type="video", maxResults=max_results, relevanceLanguage="ko", ) - response = request.execute() - results = [] + if page_token: + params["pageToken"] = page_token + response = youtube.search().list(**params).execute() + items = [] for item in response.get("items", []): snippet = item["snippet"] - results.append({ + items.append({ "videoId": item["id"]["videoId"], "title": snippet["title"], "thumbnail": snippet["thumbnails"]["medium"]["url"], "channelTitle": snippet["channelTitle"], }) - return results + return { + "items": items, + "nextPageToken": response.get("nextPageToken"), + } except Exception as e: print(f"[youtube_search] 오류: {e}") - return [] + return {"items": [], "nextPageToken": None} diff --git a/backend/main.py b/backend/main.py index daa592e..ae80497 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ from dotenv import load_dotenv load_dotenv() +import httpx from fastapi import FastAPI, Query, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +13,7 @@ # 팀원 DB 연동 — app/ 디렉토리가 없으면 DB 저장 기능은 비활성화 try: from app.core.database import get_db - from api.db_service import get_or_create_recipe, save_cooking_steps + from api.db_service import get_or_create_recipe, save_cooking_steps, save_transcript DB_ENABLED = True except ImportError: DB_ENABLED = False @@ -28,10 +29,28 @@ ) +@app.get("/api/suggest") +async def suggest(q: str = Query(..., min_length=1)): + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://suggestqueries.google.com/complete/search", + params={"client": "firefox", "q": q, "hl": "ko"}, + timeout=3.0, + ) + data = resp.json() + return data[1] + except Exception: + return [] + + @app.get("/api/search") -async def search(q: str = Query(..., min_length=1)): +async def search( + q: str = Query(..., min_length=1), + pageToken: str = Query(default=None), +): try: - results = search_videos(q) + results = search_videos(q, page_token=pageToken) return results except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -57,6 +76,16 @@ async def get_steps( from api.gemini_parser import GESTURES steps = [s for s in steps if s.get("gesture") in set(GESTURES)] + # action 문자열 중복 제거 — 먼저 나온 단계만 유지 + seen: set[str] = set() + deduped = [] + for step in steps: + action = step.get("action", "") + if action not in seen: + seen.add(action) + deduped.append(step) + steps = deduped + # DB 저장 (DB가 활성화된 경우만) if DB_ENABLED and db is not None: try: @@ -65,6 +94,7 @@ async def get_steps( channel_name=channelTitle, thumbnail_url=thumbnail, ) + await save_transcript(db, recipe.id, transcript) saved = await save_cooking_steps(db, recipe.id, steps) print(f"[main] DB 저장 완료 — recipe_id={recipe.id}, steps={saved}개") except Exception as e: diff --git a/frontend/src/components/CameraFeed.tsx b/frontend/src/components/CameraFeed.tsx index 6ca2f6e..ee4f0e9 100644 --- a/frontend/src/components/CameraFeed.tsx +++ b/frontend/src/components/CameraFeed.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; import { useVideoStore } from "../store/useVideoStore"; // ───────────────────────────────────────────────────────────────────────────── @@ -27,6 +27,7 @@ async function detectAction( export default function CameraFeed() { const videoRef = useRef(null); + const [cameraError, setCameraError] = useState(null); const cookingSteps = useVideoStore((s) => s.cookingSteps); const currentStepIndex = useVideoStore((s) => s.currentStepIndex); @@ -38,11 +39,15 @@ export default function CameraFeed() { let stream: MediaStream | null = null; async function startCamera() { + if (!navigator.mediaDevices?.getUserMedia) { + setCameraError("이 브라우저는 카메라를 지원하지 않습니다."); + return; + } try { - stream = await navigator.mediaDevices.getUserMedia({ video: true }); + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }); if (videoRef.current) videoRef.current.srcObject = stream; - } catch { - console.error("카메라 접근 실패"); + } catch (e) { + setCameraError(`카메라 오류: ${e instanceof Error ? e.message : String(e)}`); } } @@ -78,11 +83,14 @@ export default function CameraFeed() { playsInline className="w-64 h-48 rounded border bg-black object-cover" /> -

- {playerStatus === "paused" && currentStep - ? `인식 대기 중: ${currentStep.action}` - : "카메라 대기 중"} -

+ {cameraError + ?

{cameraError}

+ :

+ {playerStatus === "paused" && currentStep + ? `인식 대기 중: ${currentStep.action}` + : "카메라 대기 중"} +

+ } ); } diff --git a/frontend/src/components/CookingSteps.tsx b/frontend/src/components/CookingSteps.tsx index cac397b..b1827c5 100644 --- a/frontend/src/components/CookingSteps.tsx +++ b/frontend/src/components/CookingSteps.tsx @@ -21,7 +21,7 @@ export default function CookingSteps() { className="p-3 rounded border text-sm border-gray-200" > - {step.start_time} ~ {step.end_time} + {step.start_time} {step.action} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index b66e307..e0aab49 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,38 +1,99 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import type { VideoSearchResult } from "../types"; import { useVideoStore } from "../store/useVideoStore"; export default function SearchBar() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); + const [nextPageToken, setNextPageToken] = useState(null); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const suggestTimer = useRef | null>(null); + const setSelectedVideo = useVideoStore((s) => s.setSelectedVideo); const setCookingSteps = useVideoStore((s) => s.setCookingSteps); + useEffect(() => { + if (suggestTimer.current) clearTimeout(suggestTimer.current); + if (!query.trim()) { + setSuggestions([]); + return; + } + suggestTimer.current = setTimeout(async () => { + try { + const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`); + if (res.ok) setSuggestions(await res.json()); + } catch {} + }, 300); + return () => { + if (suggestTimer.current) clearTimeout(suggestTimer.current); + }; + }, [query]); + + async function fetchSearch(q: string, token?: string | null) { + const url = token + ? `/api/search?q=${encodeURIComponent(q)}&pageToken=${token}` + : `/api/search?q=${encodeURIComponent(q)}`; + const res = await fetch(url); + if (!res.ok) throw new Error("검색 실패"); + return res.json() as Promise<{ items: VideoSearchResult[]; nextPageToken: string | null }>; + } + async function handleSearch(e: React.FormEvent) { e.preventDefault(); if (!query.trim()) return; + setShowSuggestions(false); setLoading(true); setError(null); try { - const res = await fetch( - `/api/search?q=${encodeURIComponent(query)}` - ); - if (!res.ok) throw new Error("검색 실패"); - const data: VideoSearchResult[] = await res.json(); - setResults(data); - } catch (err) { + const data = await fetchSearch(query); + setResults(data.items); + setNextPageToken(data.nextPageToken ?? null); + } catch { + setError("영상 검색 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + } + + async function handleSuggestionClick(s: string) { + setQuery(s); + setShowSuggestions(false); + setLoading(true); + setError(null); + try { + const data = await fetchSearch(s); + setResults(data.items); + setNextPageToken(data.nextPageToken ?? null); + } catch { setError("영상 검색 중 오류가 발생했습니다."); } finally { setLoading(false); } } + async function handleLoadMore() { + if (!nextPageToken) return; + setLoadingMore(true); + try { + const data = await fetchSearch(query, nextPageToken); + setResults((prev) => [...prev, ...data.items]); + setNextPageToken(data.nextPageToken ?? null); + } catch { + setError("추가 영상 로드 중 오류가 발생했습니다."); + } finally { + setLoadingMore(false); + } + } + async function handleSelect(video: VideoSearchResult) { setSelectedVideo(video); setCookingSteps([]); setError(null); + setShowSuggestions(false); try { const params = new URLSearchParams({ videoId: video.videoId, @@ -55,13 +116,30 @@ export default function SearchBar() { return (
- setQuery(e.target.value)} - placeholder="요리 이름 검색 (예: 감자볶음)" - className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" - /> +
+ { setQuery(e.target.value); setShowSuggestions(true); }} + onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + placeholder="요리 이름 검색 (예: 감자볶음)" + className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + {showSuggestions && suggestions.length > 0 && ( +
    + {suggestions.map((s, i) => ( +
  • handleSuggestionClick(s)} + className="px-3 py-2 text-sm cursor-pointer hover:bg-gray-100" + > + {s} +
  • + ))} +
+ )} +
+ )}
); } diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index cff31f5..91a185d 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -29,6 +29,7 @@ export default function VideoPlayer() { // 스텝 정보를 interval 내부에서 최신값으로 읽기 위한 ref const stepsRef = useRef(cookingSteps); const stepIndexRef = useRef(currentStepIndex); + const lastTimeRef = useRef(0); useEffect(() => { stepsRef.current = cookingSteps; }, [cookingSteps]); useEffect(() => { stepIndexRef.current = currentStepIndex; }, [currentStepIndex]); @@ -42,6 +43,14 @@ export default function VideoPlayer() { intervalRef.current = setInterval(() => { if (!playerRef.current) return; const currentTime: number = playerRef.current.getCurrentTime(); + + // 광고 감지: 재생 시간이 이전보다 3초 이상 뒤로 점프하면 광고로 간주하고 건너뜀 + if (currentTime < lastTimeRef.current - 3) { + lastTimeRef.current = currentTime; + return; + } + lastTimeRef.current = currentTime; + const step = stepsRef.current[stepIndexRef.current]; if (!step) return; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1e595ed..bb000e0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { + allowedHosts: true, proxy: { "/api": { target: "http://localhost:8000",