Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/api/db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down
19 changes: 12 additions & 7 deletions backend/api/youtube_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
36 changes: 33 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -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:
Expand Down
26 changes: 17 additions & 9 deletions frontend/src/components/CameraFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import { useVideoStore } from "../store/useVideoStore";

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -27,6 +27,7 @@ async function detectAction(

export default function CameraFeed() {
const videoRef = useRef<HTMLVideoElement>(null);
const [cameraError, setCameraError] = useState<string | null>(null);

const cookingSteps = useVideoStore((s) => s.cookingSteps);
const currentStepIndex = useVideoStore((s) => s.currentStepIndex);
Expand All @@ -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)}`);
}
}

Expand Down Expand Up @@ -78,11 +83,14 @@ export default function CameraFeed() {
playsInline
className="w-64 h-48 rounded border bg-black object-cover"
/>
<p className="text-xs text-gray-500">
{playerStatus === "paused" && currentStep
? `인식 대기 중: ${currentStep.action}`
: "카메라 대기 중"}
</p>
{cameraError
? <p className="text-xs text-red-500">{cameraError}</p>
: <p className="text-xs text-gray-500">
{playerStatus === "paused" && currentStep
? `인식 대기 중: ${currentStep.action}`
: "카메라 대기 중"}
</p>
}
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/src/components/CookingSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function CookingSteps() {
className="p-3 rounded border text-sm border-gray-200"
>
<span className="text-gray-400 text-xs mr-2">
{step.start_time} ~ {step.end_time}
{step.start_time}
</span>
{step.action}
</li>
Expand Down
118 changes: 103 additions & 15 deletions frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<VideoSearchResult[]>([]);
const [nextPageToken, setNextPageToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const suggestTimer = useRef<ReturnType<typeof setTimeout> | 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,
Expand All @@ -55,13 +116,30 @@ export default function SearchBar() {
return (
<div className="w-full max-w-2xl mx-auto p-4">
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => 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"
/>
<div className="flex-1 relative">
<input
type="text"
value={query}
onChange={(e) => { 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 && (
<ul className="absolute left-0 right-0 top-full mt-1 bg-white border rounded shadow-lg z-10">
{suggestions.map((s, i) => (
<li
key={i}
onMouseDown={() => handleSuggestionClick(s)}
className="px-3 py-2 text-sm cursor-pointer hover:bg-gray-100"
>
{s}
</li>
))}
</ul>
)}
</div>
<button
type="submit"
disabled={loading}
Expand All @@ -88,6 +166,16 @@ export default function SearchBar() {
</li>
))}
</ul>

{nextPageToken && (
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="mt-3 w-full py-2 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
>
{loadingMore ? "불러오는 중..." : "더 보기"}
</button>
)}
</div>
);
}
Loading