Spaces:
Build error
Build error
LLM추가 및 템플릿 개선, (프록시는 미구현)
Browse files- Dockerfile +72 -48
- backend/app/main.py +63 -8
- backend/app/proxy_manager.py +1 -1
- backend/app/rag_core.py +24 -40
- backend/app/youtube_parser.py +15 -5
- backend/requirements.txt +2 -1
- frontend/.env.development +2 -0
- frontend/src/App.vue +219 -73
Dockerfile
CHANGED
@@ -1,50 +1,74 @@
|
|
1 |
# --- Stage 1: Frontend Build ---
|
2 |
-
FROM node:20-slim as frontend-builder
|
3 |
|
4 |
-
WORKDIR /app/frontend
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
#
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
git
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
#
|
45 |
-
|
46 |
-
|
47 |
-
#
|
48 |
-
#
|
49 |
-
#
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# --- Stage 1: Frontend Build ---
|
2 |
+
FROM node:20-slim as frontend-builder
|
3 |
|
4 |
+
WORKDIR /app/frontend
|
5 |
+
|
6 |
+
# package.json 및 package-lock.json 복사
|
7 |
+
COPY frontend/package*.json ./
|
8 |
+
|
9 |
+
# 의존성 설치
|
10 |
+
RUN npm install
|
11 |
+
|
12 |
+
# 나머지 프론트엔드 코드 복사
|
13 |
+
COPY frontend/ .
|
14 |
+
|
15 |
+
# 프론트엔드 빌드 (Vue CLI 프로젝트의 경우 'npm run build'가 적합)
|
16 |
+
RUN npm run build
|
17 |
+
|
18 |
+
|
19 |
+
# --- Stage 2: Backend and Combined Serving via FastAPI (Uvicorn) ---
|
20 |
+
FROM python:3.11-slim-bookworm
|
21 |
+
|
22 |
+
WORKDIR /app
|
23 |
+
|
24 |
+
# 시스템 패키지 업데이트 및 build-essential (Python 패키지 빌드용), curl, git 설치
|
25 |
+
# Ollama 설치 스크립트가 필요 없으므로 bash는 제거 가능하지만, 다른 용도로 필요할 수 있어 유지합니다.
|
26 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
27 |
+
build-essential \
|
28 |
+
curl \
|
29 |
+
git \
|
30 |
+
bash \
|
31 |
+
&& rm -rf /var/lib/apt/lists/*
|
32 |
+
|
33 |
+
# Python 종속성 설치
|
34 |
+
COPY backend/requirements.txt ./backend/requirements.txt
|
35 |
+
RUN pip install --no-cache-dir -r backend/requirements.txt
|
36 |
+
|
37 |
+
# 백엔드 소스 코드 복사
|
38 |
+
COPY backend/ ./backend/
|
39 |
+
|
40 |
+
# Hugging Face 캐시 디렉토리 설정 (모델이 직접 다운로드되지 않으므로 필요 없을 수 있지만, 안전을 위해 유지)
|
41 |
+
ENV TRANSFORMERS_CACHE="/tmp/hf_cache"
|
42 |
+
ENV HF_HOME="/tmp/hf_cache"
|
43 |
+
|
44 |
+
# ✨ Ollama 설치 및 모델 다운로드 단계 제거 ✨
|
45 |
+
# 이 부분은 Hugging Face Spaces 환경에서 관리됩니다.
|
46 |
+
# RUN curl -fsSL https://ollama.com/install.sh | sh
|
47 |
+
# RUN sh -c "ollama serve & \
|
48 |
+
# ATTEMPTS=0; \
|
49 |
+
# while ! curl -s http://localhost:11434 > /dev/null && ATTEMPTS < 30; do \
|
50 |
+
# ATTEMPTS=$((ATTEMPTS+1)); \
|
51 |
+
# echo 'Waiting for Ollama server to start... (Attempt '$ATTEMPTS'/30)'; \
|
52 |
+
# sleep 2; \
|
53 |
+
# done; \
|
54 |
+
# if [ $ATTEMPTS -eq 30 ]; then \
|
55 |
+
# echo 'Ollama server did not start in time. Exiting.'; \
|
56 |
+
# exit 1; \
|
57 |
+
# fi; \
|
58 |
+
# echo 'Ollama server started. Pulling model...'; \
|
59 |
+
# ollama pull hf.co/DevQuasar/naver-hyperclovax.HyperCLOVAX-SEED-Text-Instruct-0.5B-GGUF:F16"
|
60 |
+
|
61 |
+
# 프론트엔드 빌드 결과물을 백엔드 앱이 접근할 수 있는 경로로 복사합니다.
|
62 |
+
# /app/static 폴더를 만들고 그 안에 Vue.js 빌드 결과물을 넣습니다.
|
63 |
+
RUN mkdir -p /app/static
|
64 |
+
COPY --from=frontend-builder /app/frontend/dist /app/static
|
65 |
+
|
66 |
+
# 최종 실행 명령어
|
67 |
+
# 애플리케이션 포트 설정 (허깅페이스 스페이스는 7860번 포트를 사용)
|
68 |
+
EXPOSE 7860
|
69 |
+
|
70 |
+
# CMD를 수정하여 Uvicorn만 실행합니다.
|
71 |
+
# Ollama 서버는 Hugging Face Spaces 환경에서 별도로 관리됩니다.
|
72 |
+
# 백엔드 main.py의 `OLLAMA_API_BASE_URL` 환경 변수가 `http://127.0.0.1:11434`로 설정되어 있는지 확인하세요.
|
73 |
+
# (Hugging Face Spaces에서 Ollama SDK를 사용하면 보통 이 주소로 접근 가능합니다.)
|
74 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "${PORT:-7860}", "--app-dir", "backend/app"]
|
backend/app/main.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1 |
-
# backend/app/main.py
|
2 |
|
3 |
from fastapi import FastAPI, HTTPException
|
4 |
from fastapi.middleware.cors import CORSMiddleware
|
5 |
from fastapi.staticfiles import StaticFiles # StaticFiles 임포트
|
6 |
import os
|
7 |
from pydantic import BaseModel
|
|
|
8 |
|
9 |
from youtube_parser import process_youtube_video_data
|
10 |
from rag_core import perform_rag_query
|
@@ -31,15 +32,48 @@ current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
|
31 |
project_root_dir = os.path.join(current_file_dir, "..", "..")
|
32 |
static_files_dir = os.path.join(project_root_dir, "static")
|
33 |
|
34 |
-
#
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
class VideoProcessRequest(BaseModel):
|
40 |
video_url: str
|
41 |
query: str
|
|
|
|
|
42 |
|
|
|
43 |
@app.post("/api/process_youtube_video")
|
44 |
async def process_youtube_video(request: VideoProcessRequest):
|
45 |
try:
|
@@ -48,10 +82,30 @@ async def process_youtube_video(request: VideoProcessRequest):
|
|
48 |
if not processed_chunks_with_timestamps:
|
49 |
return {"message": "자막 또는 내용을 추출할 수 없습니다.", "results": []}
|
50 |
|
|
|
51 |
rag_results = await perform_rag_query(
|
52 |
chunks_with_timestamps=processed_chunks_with_timestamps,
|
53 |
query=request.query,
|
54 |
-
top_k=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
55 |
)
|
56 |
|
57 |
return {
|
@@ -59,9 +113,10 @@ async def process_youtube_video(request: VideoProcessRequest):
|
|
59 |
"message": "성공적으로 영상을 처리하고 RAG 검색을 수행했습니다.",
|
60 |
"video_url": request.video_url,
|
61 |
"query": request.query,
|
62 |
-
"
|
|
|
|
|
63 |
}
|
64 |
-
|
65 |
except Exception as e:
|
66 |
print(f"ERROR: 서버 처리 중 오류 발생: {str(e)}")
|
67 |
raise HTTPException(status_code=500, detail="서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
|
|
|
1 |
+
# ./backend/app/main.py
|
2 |
|
3 |
from fastapi import FastAPI, HTTPException
|
4 |
from fastapi.middleware.cors import CORSMiddleware
|
5 |
from fastapi.staticfiles import StaticFiles # StaticFiles 임포트
|
6 |
import os
|
7 |
from pydantic import BaseModel
|
8 |
+
import httpx
|
9 |
|
10 |
from youtube_parser import process_youtube_video_data
|
11 |
from rag_core import perform_rag_query
|
|
|
32 |
project_root_dir = os.path.join(current_file_dir, "..", "..")
|
33 |
static_files_dir = os.path.join(project_root_dir, "static")
|
34 |
|
35 |
+
# OLLAMA_API_BASE_URL 환경 변수 설정
|
36 |
+
OLLAMA_API_BASE_URL = os.getenv("OLLAMA_API_BASE_URL", "http://127.0.0.1:11434")
|
37 |
+
|
38 |
+
async def generate_answer_with_ollama(model_name: str, prompt: str) -> str:
|
39 |
+
"""
|
40 |
+
Ollama 서버에 질의하여 답변을 생성합니다.
|
41 |
+
"""
|
42 |
+
url = f"{OLLAMA_API_BASE_URL}/api/generate"
|
43 |
+
headers = {"Content-Type": "application/json"}
|
44 |
+
data = {
|
45 |
+
"model": model_name,
|
46 |
+
"prompt": prompt,
|
47 |
+
"stream": False # 스트리밍을 사용하지 않고 한 번에 답변을 받습니다.
|
48 |
+
}
|
49 |
+
print(f"INFO: Ollama API 호출 시작. 모델: {model_name}")
|
50 |
+
print(f"INFO: 프롬프트 미리보기: {prompt[:200]}...")
|
51 |
+
|
52 |
+
try:
|
53 |
+
async with httpx.AsyncClient(timeout=600.0) as client:
|
54 |
+
response = await client.post(url, headers=headers, json=data)
|
55 |
+
response.raise_for_status()
|
56 |
+
|
57 |
+
response_data = response.json()
|
58 |
+
full_response = response_data.get("response", "").strip()
|
59 |
+
return full_response
|
60 |
+
except httpx.HTTPStatusError as e:
|
61 |
+
print(f"ERROR: Ollama API 호출 실패: {e}")
|
62 |
+
raise HTTPException(status_code=500, detail="Ollama API 호출 실패")
|
63 |
+
except httpx.RequestError as e:
|
64 |
+
print(f"ERROR: 네트워크 오류: {e}")
|
65 |
+
raise HTTPException(status_code=500, detail="네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
|
66 |
+
except Exception as e:
|
67 |
+
print(f"ERROR: 알 수 없는 오류: {e}")
|
68 |
+
raise HTTPException(status_code=500, detail="알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
|
69 |
|
70 |
class VideoProcessRequest(BaseModel):
|
71 |
video_url: str
|
72 |
query: str
|
73 |
+
# 사용자가 사용할 Ollama 모델 이름을 지정할 수 있도록 추가
|
74 |
+
ollama_model_name: str = "hf.co/DevQuasar/naver-hyperclovax.HyperCLOVAX-SEED-Text-Instruct-0.5B-GGUF:F16"
|
75 |
|
76 |
+
# ✅ 유튜브 영상 처리 API
|
77 |
@app.post("/api/process_youtube_video")
|
78 |
async def process_youtube_video(request: VideoProcessRequest):
|
79 |
try:
|
|
|
82 |
if not processed_chunks_with_timestamps:
|
83 |
return {"message": "자막 또는 내용을 추출할 수 없습니다.", "results": []}
|
84 |
|
85 |
+
# 1. RAG 검색 수행
|
86 |
rag_results = await perform_rag_query(
|
87 |
chunks_with_timestamps=processed_chunks_with_timestamps,
|
88 |
query=request.query,
|
89 |
+
top_k=50
|
90 |
+
)
|
91 |
+
|
92 |
+
if not rag_results:
|
93 |
+
return {
|
94 |
+
"status": "error",
|
95 |
+
"message": "검색 결과가 없습니다.",
|
96 |
+
"video_url": request.video_url,
|
97 |
+
"query": request.query,
|
98 |
+
"results": []
|
99 |
+
}
|
100 |
+
|
101 |
+
# 2. 검색 결과를 프롬프트에 추가
|
102 |
+
context = "\\n\\n".join([chunk["text"] for chunk in rag_results])
|
103 |
+
prompt = f"다음 정보와 대화 내용을 참고하여 사용자의 질문에 답변하세요. 제공된 정보에서 답을 찾을 수 없다면 '정보 부족'이라고 명시하세요.\\n\\n참고 정보:\\n{context}\\n\\n사용자 질문: {request.query}\\n\\n답변:"
|
104 |
+
|
105 |
+
# 3. Ollama 모델에 질의하여 답변 생성
|
106 |
+
generated_answer = await generate_answer_with_ollama(
|
107 |
+
model_name=request.ollama_model_name,
|
108 |
+
prompt=prompt
|
109 |
)
|
110 |
|
111 |
return {
|
|
|
113 |
"message": "성공적으로 영상을 처리하고 RAG 검색을 수행했습니다.",
|
114 |
"video_url": request.video_url,
|
115 |
"query": request.query,
|
116 |
+
"ollama_model_used": request.ollama_model_name,
|
117 |
+
"retrieved_chunks": rag_results,
|
118 |
+
"generated_answer": generated_answer
|
119 |
}
|
|
|
120 |
except Exception as e:
|
121 |
print(f"ERROR: 서버 처리 중 오류 발생: {str(e)}")
|
122 |
raise HTTPException(status_code=500, detail="서버 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")
|
backend/app/proxy_manager.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
# backend/app/proxy_manager.py
|
2 |
|
3 |
import os
|
4 |
import requests # requests는 FreeProxy 테스트나 ScrapingBee API 직접 호출 시 유용
|
|
|
1 |
+
# ./backend/app/proxy_manager.py
|
2 |
|
3 |
import os
|
4 |
import requests # requests는 FreeProxy 테스트나 ScrapingBee API 직접 호출 시 유용
|
backend/app/rag_core.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
# backend/app/rag_core.py
|
2 |
|
3 |
from sentence_transformers import SentenceTransformer
|
4 |
import faiss
|
@@ -9,13 +9,13 @@ from typing import List, Dict, Tuple
|
|
9 |
# 네이버 클로바의 한국어 SentenceBERT 모델을 로드합니다.
|
10 |
try:
|
11 |
# 네이버 클로바 HyperCLOVAX-SEED-Text-Instruct-0.5B 모델 로드
|
12 |
-
model = SentenceTransformer('jhgan/ko-sroberta-multitask')
|
13 |
print("INFO: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 완료.")
|
14 |
except Exception as e:
|
15 |
print(f"ERROR: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 실패: {e}. 다국어 모델로 시도합니다.")
|
16 |
# 대체 모델 로직 (필요하다면 유지하거나 제거할 수 있습니다)
|
17 |
try:
|
18 |
-
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
|
19 |
print("INFO: 임베딩 모델 'sentence-transformers/paraphrase-multilingual-L12-v2' 로드 완료.")
|
20 |
except Exception as e:
|
21 |
print(f"ERROR: 대체 임베딩 모델 로드 실패: {e}. RAG 기능을 사용할 수 없습니다.")
|
@@ -68,48 +68,32 @@ async def perform_rag_query(chunks_with_timestamps: List[Dict], query: str, top_
|
|
68 |
print(f"INFO: FAISS 유사도 검색 완료. 상위 {top_k}개 결과.")
|
69 |
|
70 |
retrieved_chunks = []
|
71 |
-
|
72 |
-
|
73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
retrieved_chunks.append({
|
75 |
"text": original_chunk["text"],
|
76 |
"timestamp": original_chunk["timestamp"],
|
77 |
-
"score":
|
78 |
})
|
|
|
|
|
|
|
79 |
|
80 |
# 거리가 작은 순서(유사도가 높은 순서)로 정렬하여 반환
|
81 |
-
retrieved_chunks.sort(key=lambda x: x['
|
82 |
|
83 |
print(f"DEBUG: 최종 검색된 청크 수: {len(retrieved_chunks)}")
|
84 |
-
return retrieved_chunks
|
85 |
-
|
86 |
-
# ... (나머지 perform_rag_query 함수 코드는 동일합니다) ...
|
87 |
-
|
88 |
-
# 테스트용 코드 (직접 실행 시)
|
89 |
-
if __name__ == "__main__":
|
90 |
-
import asyncio
|
91 |
-
|
92 |
-
sample_chunks = [
|
93 |
-
{"text": "안녕하세요, 오늘 우리는 AI와 머신러닝의 미래에 대해 이야기할 것입니다.", "timestamp": "00:00:05", "start_seconds": 5},
|
94 |
-
{"text": "특히 딥러닝과 신경망이 어떻게 혁신을 이끄는지 살펴보겠습니다.", "timestamp": "00:00:15", "start_seconds": 15},
|
95 |
-
{"text": "이번 영상에서는 유튜브 영상 데이트 서비스 개발 과정을 보여드립니다.", "timestamp": "00:00:25", "start_seconds": 25},
|
96 |
-
{"text": "파이썬과 FastAPI를 사용하여 백엔드를 구축하고 있습니다.", "timestamp": "00:00:35", "start_seconds": 35},
|
97 |
-
{"text": "데이트 앱 개발은 쉬운 일이 아니지만, 재미있습니다.", "timestamp": "00:00:45", "start_seconds": 45},
|
98 |
-
{"text": "이 모델은 자연어 처리(NLP) 작업에 매우 유용합니다.", "timestamp": "00:00:55", "start_seconds": 55},
|
99 |
-
{"text": "다음주에는 새로운 데이트 장소를 탐험할 계획입니다.", "timestamp": "01:00:05", "start_seconds": 65},
|
100 |
-
]
|
101 |
-
|
102 |
-
async def main():
|
103 |
-
print("\n[테스트 1] 쿼리: 데이트 앱")
|
104 |
-
query1 = "데이트 앱"
|
105 |
-
results1 = await perform_rag_query(sample_chunks, query1, top_k=3)
|
106 |
-
for r in results1:
|
107 |
-
print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
|
108 |
-
|
109 |
-
print("\n[테스트 2] 쿼리: 인공지능")
|
110 |
-
query2 = "인공지능"
|
111 |
-
results2 = await perform_rag_query(sample_chunks, query2, top_k=2)
|
112 |
-
for r in results2:
|
113 |
-
print(f" [{r['score']:.4f}] {r['timestamp']}: {r['text']}")
|
114 |
-
|
115 |
-
asyncio.run(main())
|
|
|
1 |
+
# ./backend/app/rag_core.py
|
2 |
|
3 |
from sentence_transformers import SentenceTransformer
|
4 |
import faiss
|
|
|
9 |
# 네이버 클로바의 한국어 SentenceBERT 모델을 로드합니다.
|
10 |
try:
|
11 |
# 네이버 클로바 HyperCLOVAX-SEED-Text-Instruct-0.5B 모델 로드
|
12 |
+
model = SentenceTransformer('jhgan/ko-sroberta-multitask', device='cpu')
|
13 |
print("INFO: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 완료.")
|
14 |
except Exception as e:
|
15 |
print(f"ERROR: 임베딩 모델 'jhgan/ko-sroberta-multitask' 로드 실패: {e}. 다국어 모델로 시도합니다.")
|
16 |
# 대체 모델 로직 (필요하다면 유지하거나 제거할 수 있습니다)
|
17 |
try:
|
18 |
+
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2', device='cpu')
|
19 |
print("INFO: 임베딩 모델 'sentence-transformers/paraphrase-multilingual-L12-v2' 로드 완료.")
|
20 |
except Exception as e:
|
21 |
print(f"ERROR: 대체 임베딩 모델 로드 실패: {e}. RAG 기능을 사용할 수 없습니다.")
|
|
|
68 |
print(f"INFO: FAISS 유사도 검색 완료. 상위 {top_k}개 결과.")
|
69 |
|
70 |
retrieved_chunks = []
|
71 |
+
|
72 |
+
# ✨✨✨ 유사도 임계값 설정 및 필터링 추가 ✨✨✨
|
73 |
+
# 이 값은 실험을 통해 최적의 값을 찾아야 합니다.
|
74 |
+
# 거리가 낮을수록 유사하므로, 이 값보다 '거리(score)'가 낮아야만 결과를 포함합니다.
|
75 |
+
# 예를 들어, 0.5는 '거리가 0.5 미만인 경우에만 결과에 포함하라'는 의미입니다.
|
76 |
+
# 거리가 0이면 완벽히 일치합니다.
|
77 |
+
MIN_DISTANCE_THRESHOLD = 150 # 예시 값: 이 값보다 거리가 작아야 합니다 (더 유사해야 함)
|
78 |
+
|
79 |
+
for i in range(len(indices[0])):
|
80 |
+
idx = indices[0][i]
|
81 |
+
original_chunk = chunks_with_timestamps[idx]
|
82 |
+
score = float(distances[0][i]) # FAISS에서 반환된 거리 값 (낮을수록 유사)
|
83 |
+
|
84 |
+
# 설정된 임계값보다 거리가 작을 때만 (즉, 유사도가 높을 때만) 결과에 포함
|
85 |
+
if score < MIN_DISTANCE_THRESHOLD:
|
86 |
retrieved_chunks.append({
|
87 |
"text": original_chunk["text"],
|
88 |
"timestamp": original_chunk["timestamp"],
|
89 |
+
"score": score
|
90 |
})
|
91 |
+
else:
|
92 |
+
# 디버깅용: 임계값 때문에 제외된 청크를 로그로 확인
|
93 |
+
print(f"DEBUG: 유사도 임계값({MIN_DISTANCE_THRESHOLD:.4f}) 초과로 제외된 청크 (거리: {score:.4f}): {original_chunk['text'][:50]}...")
|
94 |
|
95 |
# 거리가 작은 순서(유사도가 높은 순서)로 정렬하여 반환
|
96 |
+
retrieved_chunks.sort(key=lambda x: x['timestamp'])
|
97 |
|
98 |
print(f"DEBUG: 최종 검색된 청크 수: {len(retrieved_chunks)}")
|
99 |
+
return retrieved_chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/youtube_parser.py
CHANGED
@@ -1,4 +1,6 @@
|
|
|
|
1 |
import os
|
|
|
2 |
import contextlib
|
3 |
import asyncio
|
4 |
import json # json 모듈 추가
|
@@ -58,6 +60,13 @@ async def get_youtube_video_id(url: str) -> str | None:
|
|
58 |
logger.warning(f"알 수 없는 형식의 YouTube URL: {url}")
|
59 |
return None
|
60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
62 |
logger.info(f"비디오 ID '{video_id}'에 대한 자막 가져오기 시도.")
|
63 |
|
@@ -145,7 +154,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
145 |
|
146 |
# 3. 자막 파일이 찾아졌으면 파싱 시작
|
147 |
if caption_file_path and os.path.exists(caption_file_path):
|
148 |
-
# VTT, SRT, JSON 등 다양한 자막 파일 형식에 대한 파싱 로직 분기
|
149 |
if caption_file_path.endswith('.vtt'):
|
150 |
with open(caption_file_path, 'r', encoding='utf-8') as f:
|
151 |
vtt_content = f.read()
|
@@ -153,10 +161,11 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
153 |
# WEBVTT 파싱
|
154 |
segments = vtt_content.split('\n\n')
|
155 |
for segment in segments:
|
156 |
-
if '-->' in segment:
|
157 |
lines = segment.split('\n')
|
158 |
time_str = lines[0].strip()
|
159 |
text_content = ' '.join(lines[1:]).strip()
|
|
|
160 |
|
161 |
try:
|
162 |
# VTT 시간은 HH:MM:SS.ms 형태로 제공되므로, 밀리초를 float로 처리 후 정수로 변환
|
@@ -184,7 +193,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
184 |
})
|
185 |
except Exception as e:
|
186 |
logger.warning(f"VTT 시간 파싱 오류: {time_str} - {e}")
|
187 |
-
|
188 |
logger.info(f"yt-dlp로 VTT 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
189 |
|
190 |
elif caption_file_path.endswith('.json'):
|
@@ -199,6 +207,8 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
199 |
hours = total_seconds // 3600
|
200 |
minutes = (total_seconds % 3600) // 60
|
201 |
seconds = total_seconds % 60
|
|
|
|
|
202 |
|
203 |
if hours > 0:
|
204 |
timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
@@ -206,7 +216,7 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
206 |
timestamp_str = f"{minutes:02d}:{seconds:02d}"
|
207 |
|
208 |
processed_chunks.append({
|
209 |
-
"text":
|
210 |
"timestamp": timestamp_str
|
211 |
})
|
212 |
logger.info(f"yt-dlp로 JSON 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
@@ -224,6 +234,7 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
224 |
if len(lines) >= 3 and '-->' in lines[1]:
|
225 |
time_str = lines[1].strip()
|
226 |
text_content = ' '.join(lines[2:]).strip()
|
|
|
227 |
|
228 |
try:
|
229 |
# SRT 시간은 HH:MM:SS,ms 형태로 제공
|
@@ -248,7 +259,6 @@ async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
|
248 |
|
249 |
else:
|
250 |
logger.warning(f"지원하지 않는 자막 파일 형식입니다: {caption_file_path}")
|
251 |
-
|
252 |
else:
|
253 |
logger.warning(f"비디오 ID '{video_id}'에 대한 yt-dlp 자막 파일을 찾을 수 없습니다. 최종 시도 경로: {caption_file_path}")
|
254 |
|
|
|
1 |
+
# ./backend/app/youtube_parser.py
|
2 |
import os
|
3 |
+
import re
|
4 |
import contextlib
|
5 |
import asyncio
|
6 |
import json # json 모듈 추가
|
|
|
60 |
logger.warning(f"알 수 없는 형식의 YouTube URL: {url}")
|
61 |
return None
|
62 |
|
63 |
+
def clean_caption_text(text: str) -> str:
|
64 |
+
# <00:00:00.000><c>...</c> 또는 </c> 같은 태그 제거
|
65 |
+
cleaned_text = re.sub(r'<[^>]+>', '', text)
|
66 |
+
# 여러 공백을 하나의 공백으로 줄이고 양쪽 끝 공백 제거
|
67 |
+
cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip()
|
68 |
+
return cleaned_text
|
69 |
+
|
70 |
async def get_transcript_with_timestamps(video_id: str) -> list[dict] | None:
|
71 |
logger.info(f"비디오 ID '{video_id}'에 대한 자막 가져오기 시도.")
|
72 |
|
|
|
154 |
|
155 |
# 3. 자막 파일이 찾아졌으면 파싱 시작
|
156 |
if caption_file_path and os.path.exists(caption_file_path):
|
|
|
157 |
if caption_file_path.endswith('.vtt'):
|
158 |
with open(caption_file_path, 'r', encoding='utf-8') as f:
|
159 |
vtt_content = f.read()
|
|
|
161 |
# WEBVTT 파싱
|
162 |
segments = vtt_content.split('\n\n')
|
163 |
for segment in segments:
|
164 |
+
if '-->' in segment:
|
165 |
lines = segment.split('\n')
|
166 |
time_str = lines[0].strip()
|
167 |
text_content = ' '.join(lines[1:]).strip()
|
168 |
+
text_content = clean_caption_text(text_content)
|
169 |
|
170 |
try:
|
171 |
# VTT 시간은 HH:MM:SS.ms 형태로 제공되므로, 밀리초를 float로 처리 후 정수로 변환
|
|
|
193 |
})
|
194 |
except Exception as e:
|
195 |
logger.warning(f"VTT 시간 파싱 오류: {time_str} - {e}")
|
|
|
196 |
logger.info(f"yt-dlp로 VTT 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
197 |
|
198 |
elif caption_file_path.endswith('.json'):
|
|
|
207 |
hours = total_seconds // 3600
|
208 |
minutes = (total_seconds % 3600) // 60
|
209 |
seconds = total_seconds % 60
|
210 |
+
text = entry['text']
|
211 |
+
text = clean_caption_text(text)
|
212 |
|
213 |
if hours > 0:
|
214 |
timestamp_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
|
216 |
timestamp_str = f"{minutes:02d}:{seconds:02d}"
|
217 |
|
218 |
processed_chunks.append({
|
219 |
+
"text": text,
|
220 |
"timestamp": timestamp_str
|
221 |
})
|
222 |
logger.info(f"yt-dlp로 JSON 자막 {len(processed_chunks)}개 청크 처리 완료.")
|
|
|
234 |
if len(lines) >= 3 and '-->' in lines[1]:
|
235 |
time_str = lines[1].strip()
|
236 |
text_content = ' '.join(lines[2:]).strip()
|
237 |
+
text_content = clean_caption_text(text_content)
|
238 |
|
239 |
try:
|
240 |
# SRT 시간은 HH:MM:SS,ms 형태로 제공
|
|
|
259 |
|
260 |
else:
|
261 |
logger.warning(f"지원하지 않는 자막 파일 형식입니다: {caption_file_path}")
|
|
|
262 |
else:
|
263 |
logger.warning(f"비디오 ID '{video_id}'에 대한 yt-dlp 자막 파일을 찾을 수 없습니다. 최종 시도 경로: {caption_file_path}")
|
264 |
|
backend/requirements.txt
CHANGED
@@ -8,4 +8,5 @@ sentence-transformers==3.2.1
|
|
8 |
faiss-cpu==1.9.0
|
9 |
numpy==1.26.4
|
10 |
pydantic==2.9.2
|
11 |
-
torch==2.6.0
|
|
|
|
8 |
faiss-cpu==1.9.0
|
9 |
numpy==1.26.4
|
10 |
pydantic==2.9.2
|
11 |
+
torch==2.6.0
|
12 |
+
httpx
|
frontend/.env.development
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
# frontend/.env.development 파일
|
2 |
+
VUE_APP_BACKEND_URL=http://localhost:7860
|
frontend/src/App.vue
CHANGED
@@ -1,54 +1,71 @@
|
|
1 |
<template>
|
2 |
-
<div id="app">
|
3 |
-
<
|
4 |
-
|
|
|
5 |
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
</div>
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
|
|
|
|
41 |
</div>
|
42 |
</div>
|
43 |
-
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
</div>
|
46 |
</div>
|
47 |
</template>
|
48 |
|
49 |
<script>
|
50 |
-
import apiClient from './api'; // axios 인스턴스 import
|
51 |
-
|
52 |
export default {
|
53 |
name: 'App',
|
54 |
data() {
|
@@ -59,23 +76,23 @@ export default {
|
|
59 |
results: [],
|
60 |
errorMessage: '',
|
61 |
responseMessage: '',
|
62 |
-
videoEmbedUrl: ''
|
|
|
63 |
};
|
64 |
},
|
65 |
methods: {
|
66 |
-
// 유튜브 비디오 ID 추출 및 임베딩 URL 저장
|
67 |
updateVideoEmbed() {
|
68 |
this.videoEmbedUrl = '';
|
69 |
this.errorMessage = '';
|
70 |
if (!this.videoUrl) return;
|
71 |
|
72 |
-
// 유튜브 URL에서 비디오 ID 추출
|
73 |
const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
74 |
const match = this.videoUrl.match(regex);
|
75 |
if (match && match[1]) {
|
|
|
76 |
this.videoEmbedUrl = `https://www.youtube.com/embed/${match[1]}`;
|
77 |
} else{
|
78 |
-
this.errorMessage = '
|
79 |
}
|
80 |
},
|
81 |
async processVideo() {
|
@@ -83,51 +100,136 @@ export default {
|
|
83 |
this.results = [];
|
84 |
this.responseMessage = '';
|
85 |
this.loading = true;
|
|
|
86 |
|
87 |
try {
|
88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
video_url: this.videoUrl,
|
90 |
-
query: this.query
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
|
93 |
-
|
94 |
-
|
95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
} else {
|
97 |
-
this.
|
|
|
|
|
98 |
}
|
99 |
} catch (error) {
|
100 |
-
console.error(
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
this.errorMessage = '서버 응답이 없습니다. 백엔드가 실행 중인지 확인하세요.';
|
105 |
-
} else {
|
106 |
-
this.errorMessage = '요청 설정 중 오류가 발생했습니다.';
|
107 |
-
}
|
108 |
} finally {
|
109 |
this.loading = false;
|
110 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
}
|
112 |
}
|
113 |
}
|
114 |
</script>
|
115 |
|
116 |
<style>
|
117 |
-
#app
|
|
|
118 |
font-family: Avenir, Helvetica, Arial, sans-serif;
|
119 |
-webkit-font-smoothing: antialiased;
|
120 |
-moz-osx-font-smoothing: grayscale;
|
121 |
text-align: center;
|
122 |
color: #2c3e50;
|
123 |
margin-top: 60px;
|
124 |
-
max-width:
|
125 |
margin-left: auto;
|
126 |
margin-right: auto;
|
127 |
padding: 20px;
|
128 |
border: 1px solid #eee;
|
129 |
border-radius: 8px;
|
130 |
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
}
|
132 |
|
133 |
.input-section {
|
@@ -157,6 +259,7 @@ button {
|
|
157 |
cursor: pointer;
|
158 |
font-size: 16px;
|
159 |
transition: background-color 0.3s ease;
|
|
|
160 |
}
|
161 |
|
162 |
button:hover:not(:disabled) {
|
@@ -172,36 +275,78 @@ button:disabled {
|
|
172 |
color: red;
|
173 |
margin-top: 20px;
|
174 |
font-weight: bold;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
175 |
}
|
176 |
|
177 |
.info-message {
|
178 |
color: #3498db;
|
179 |
margin-top: 20px;
|
180 |
font-style: italic;
|
|
|
|
|
|
|
|
|
181 |
}
|
182 |
|
183 |
-
.
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
padding-top: 20px;
|
188 |
}
|
189 |
|
190 |
-
.
|
191 |
-
|
192 |
-
|
|
|
|
|
|
|
|
|
|
|
193 |
}
|
194 |
|
195 |
.result-item {
|
196 |
background-color: #f9f9f9;
|
197 |
-
border: 1px solid #
|
198 |
-
border-radius:
|
199 |
padding: 15px;
|
200 |
-
margin-bottom:
|
|
|
201 |
}
|
202 |
|
203 |
.result-item p {
|
204 |
-
margin: 5px 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
}
|
206 |
|
207 |
.result-item a {
|
@@ -213,11 +358,12 @@ button:disabled {
|
|
213 |
text-decoration: underline;
|
214 |
}
|
215 |
|
216 |
-
.video-embed{
|
217 |
-
margin: 20px
|
|
|
|
|
218 |
border-radius: 8px;
|
219 |
overflow: hidden;
|
220 |
-
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
221 |
position: relative;
|
222 |
padding-bottom: 56.25%;
|
223 |
height: 0;
|
|
|
1 |
<template>
|
2 |
+
<div id="app" class="main-container">
|
3 |
+
<div class="left-panel">
|
4 |
+
<h1>YouTube Transcript Extraction</h1>
|
5 |
+
<p>영상 URL과 찾고 싶은 내용을 입력해주세요.</p>
|
6 |
|
7 |
+
<div class="input-section">
|
8 |
+
<label for="videoUrl">YouTube 영상 URL:</label>
|
9 |
+
<input type="text" id="videoUrl" v-model="videoUrl" placeholder="예: https://www.youtube.com/watch?v=xxxxxxxxxxx" @input="updateVideoEmbed" />
|
10 |
+
</div>
|
11 |
|
12 |
+
<div v-if="videoEmbedUrl" class="video-embed">
|
13 |
+
<iframe
|
14 |
+
:src="videoEmbedUrl"
|
15 |
+
width="100%"
|
16 |
+
height="400"
|
17 |
+
frameborder="0"
|
18 |
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
19 |
+
allowfullscreen
|
20 |
+
id="youtube-iframe" ></iframe>
|
21 |
+
</div>
|
|
|
22 |
|
23 |
+
<div class="input-section">
|
24 |
+
<label for="query">찾을 내용 (쿼리):</label>
|
25 |
+
<input type="text" id="query" v-model="query" placeholder="예: RAG 기술의 장점은?" />
|
26 |
+
</div>
|
27 |
|
28 |
+
<button @click="processVideo" :disabled="loading" class="center-button">
|
29 |
+
{{ loading ? '처리 중...' : '영상 탐색 시작' }}
|
30 |
+
</button>
|
31 |
|
32 |
+
<div v-if="errorMessage" class="error-message">
|
33 |
+
{{ errorMessage }}
|
34 |
+
</div>
|
35 |
|
36 |
+
<div v-if="generatedAnswer" class="generated-answer-section">
|
37 |
+
<h2>✨ 생성된 답변:</h2>
|
38 |
+
<p>{{ generatedAnswer }}</p>
|
39 |
+
</div>
|
40 |
+
|
41 |
+
<div v-if="responseMessage && !generatedAnswer && !errorMessage" class="info-message">
|
42 |
+
{{ responseMessage }}
|
43 |
</div>
|
44 |
</div>
|
45 |
+
|
46 |
+
<div class="right-sidebar">
|
47 |
+
<h2>검색 결과 (타임 라인):</h2>
|
48 |
+
<div v-if="loading && results.length === 0 && !errorMessage">
|
49 |
+
<p>검색 결과를 불러오는 중...</p>
|
50 |
+
</div>
|
51 |
+
<div v-else-if="results.length > 0">
|
52 |
+
<div v-for="(result, index) in results" :key="index" class="result-item">
|
53 |
+
<p>
|
54 |
+
<strong class="timestamp-link" @click="seekVideo(result.timestamp)">
|
55 |
+
시간: {{ result.timestamp }}
|
56 |
+
</strong>
|
57 |
+
</p>
|
58 |
+
<p><strong>내용:</strong> {{ result.text }}</p>
|
59 |
+
</div>
|
60 |
+
</div>
|
61 |
+
<div v-else>
|
62 |
+
<p>아직 검색 결과가 없습니다.</p>
|
63 |
+
</div>
|
64 |
</div>
|
65 |
</div>
|
66 |
</template>
|
67 |
|
68 |
<script>
|
|
|
|
|
69 |
export default {
|
70 |
name: 'App',
|
71 |
data() {
|
|
|
76 |
results: [],
|
77 |
errorMessage: '',
|
78 |
responseMessage: '',
|
79 |
+
videoEmbedUrl: '',
|
80 |
+
generatedAnswer: ''
|
81 |
};
|
82 |
},
|
83 |
methods: {
|
|
|
84 |
updateVideoEmbed() {
|
85 |
this.videoEmbedUrl = '';
|
86 |
this.errorMessage = '';
|
87 |
if (!this.videoUrl) return;
|
88 |
|
|
|
89 |
const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
90 |
const match = this.videoUrl.match(regex);
|
91 |
if (match && match[1]) {
|
92 |
+
// 유튜브 임베드 URL 수정 (http -> https, googleusercontent.com 제거)
|
93 |
this.videoEmbedUrl = `https://www.youtube.com/embed/${match[1]}`;
|
94 |
} else{
|
95 |
+
this.errorMessage = '유효한 유튜브 URL을 입력해주세요.';
|
96 |
}
|
97 |
},
|
98 |
async processVideo() {
|
|
|
100 |
this.results = [];
|
101 |
this.responseMessage = '';
|
102 |
this.loading = true;
|
103 |
+
this.generatedAnswer = '';
|
104 |
|
105 |
try {
|
106 |
+
const backendUrl = process.env.VUE_APP_BACKEND_URL || 'http://localhost:7860';
|
107 |
+
const response = await fetch(`${backendUrl}/api/process_youtube_video`, {
|
108 |
+
method: 'POST',
|
109 |
+
headers: {
|
110 |
+
'Content-Type': 'application/json',
|
111 |
+
},
|
112 |
+
body: JSON.stringify({
|
113 |
video_url: this.videoUrl,
|
114 |
+
query: this.query,
|
115 |
+
}),
|
116 |
+
})
|
117 |
+
|
118 |
+
const data = await response.json();
|
119 |
+
|
120 |
+
if (response.ok) {
|
121 |
+
if (data.status === 'success') {
|
122 |
+
this.results = data.retrieved_chunks || [];
|
123 |
+
this.generatedAnswer = data.generated_answer || '답변을 생성하지 못했습니다.';
|
124 |
+
this.responseMessage = data.message;
|
125 |
|
126 |
+
if (this.results.length === 0 && !this.generatedAnswer) {
|
127 |
+
this.errorMessage = '관련된 정보나 답변을 찾을 수 없습니다.';
|
128 |
+
} else if (this.results.length > 0 && !this.generatedAnswer) {
|
129 |
+
this.errorMessage = '검색된 정보를 바탕으로 답변을 생성하지 못했습니다. Ollama 서버를 확인해주세요.';
|
130 |
+
}
|
131 |
+
} else {
|
132 |
+
this.errorMessage = data.message || '영상을 처리하는 데 실패했습니다.';
|
133 |
+
this.results = [];
|
134 |
+
this.generatedAnswer = '';
|
135 |
+
}
|
136 |
} else {
|
137 |
+
this.errorMessage = data.detail || '서버 오류가 발생했습니다.'
|
138 |
+
this.results = [];
|
139 |
+
this.generatedAnswer = '';
|
140 |
}
|
141 |
} catch (error) {
|
142 |
+
console.error('Error processing video:', error);
|
143 |
+
this.errorMessage = '네트워크 오류 또는 서버에 연결할 수 없습니다.';
|
144 |
+
this.results = [];
|
145 |
+
this.generatedAnswer = '';
|
|
|
|
|
|
|
|
|
146 |
} finally {
|
147 |
this.loading = false;
|
148 |
}
|
149 |
+
},
|
150 |
+
// hh:mm:ss 형식을 초 단위로 변환하는 헬퍼 함수
|
151 |
+
timeToSeconds(timeString) {
|
152 |
+
const parts = timeString.split(':').map(Number);
|
153 |
+
if (parts.length === 3) { // HH:MM:SS
|
154 |
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
155 |
+
} else if (parts.length === 2) { // MM:SS (일부 자막은 이 형식일 수 있음)
|
156 |
+
return parts[0] * 60 + parts[1];
|
157 |
+
}
|
158 |
+
return 0; // 유효하지 않은 형식은 0으로 처리
|
159 |
+
},
|
160 |
+
// 비디오를 특정 시간으로 이동시키는 함수
|
161 |
+
seekVideo(timestamp) {
|
162 |
+
const seconds = this.timeToSeconds(timestamp);
|
163 |
+
const iframe = document.getElementById('youtube-iframe');
|
164 |
+
if (iframe && iframe.src) {
|
165 |
+
const currentSrc = iframe.src;
|
166 |
+
// 기존 쿼리 파라미터 유지하고 &start=xx 추가 또는 업데이트
|
167 |
+
let newSrc;
|
168 |
+
const urlObj = new URL(currentSrc);
|
169 |
+
urlObj.searchParams.set('start', seconds);
|
170 |
+
// autoplay도 추가하여 클릭 시 재생되도록 할 수 있음
|
171 |
+
urlObj.searchParams.set('autoplay', '1');
|
172 |
+
newSrc = urlObj.toString();
|
173 |
+
|
174 |
+
iframe.src = newSrc;
|
175 |
+
} else {
|
176 |
+
this.errorMessage = '비디오 플레이어를 찾을 수 없습니다. URL을 먼저 입력해주세요.';
|
177 |
+
}
|
178 |
}
|
179 |
}
|
180 |
}
|
181 |
</script>
|
182 |
|
183 |
<style>
|
184 |
+
/* 기존 #app 스타일을 main-container에 적용 및 수정 */
|
185 |
+
.main-container {
|
186 |
font-family: Avenir, Helvetica, Arial, sans-serif;
|
187 |
-webkit-font-smoothing: antialiased;
|
188 |
-moz-osx-font-smoothing: grayscale;
|
189 |
text-align: center;
|
190 |
color: #2c3e50;
|
191 |
margin-top: 60px;
|
192 |
+
max-width: 1200px; /* 전체 컨테이너 너비 증가 */
|
193 |
margin-left: auto;
|
194 |
margin-right: auto;
|
195 |
padding: 20px;
|
196 |
border: 1px solid #eee;
|
197 |
border-radius: 8px;
|
198 |
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
199 |
+
|
200 |
+
/* Flexbox 레이아웃 설정 */
|
201 |
+
display: flex;
|
202 |
+
flex-direction: row; /* 자식 요소들을 가로로 배치 */
|
203 |
+
gap: 30px; /* 왼쪽 패널과 사이드바 사이의 간격 */
|
204 |
+
align-items: flex-start; /* 상단 정렬 */
|
205 |
+
}
|
206 |
+
|
207 |
+
/* 왼쪽 패널 ��타일 */
|
208 |
+
.left-panel {
|
209 |
+
flex: 2; /* 왼쪽 패널이 더 많은 공간 차지 (예: 66%) */
|
210 |
+
text-align: center; /* 왼쪽 패널 내 텍스트 왼쪽 정렬 */
|
211 |
+
padding-right: 15px; /* 사이드바와의 시각적 구분 */
|
212 |
+
}
|
213 |
+
|
214 |
+
/* 오른쪽 사이드바 스타일 */
|
215 |
+
.right-sidebar {
|
216 |
+
flex: 1; /* 오른쪽 사이드바가 남은 공간 차지 (예: 33%) */
|
217 |
+
text-align: left; /* 사이드바 내 텍스트 왼쪽 정렬 */
|
218 |
+
padding-left: 15px; /* 왼쪽 패널과의 시각적 구분 */
|
219 |
+
border-left: 1px solid #eee; /* 구분선 */
|
220 |
+
max-height: 80vh; /* 화면 높이의 80%를 넘지 않도록 */
|
221 |
+
overflow-y: auto; /* 내용이 넘치면 세로 스크롤바 생성 */
|
222 |
+
padding-bottom: 20px; /* 스크롤 시 하단 여백 */
|
223 |
+
}
|
224 |
+
|
225 |
+
/* 기존 스타일 유지 (수정된 .main-container, .left-panel, .right-sidebar 제외) */
|
226 |
+
h1 {
|
227 |
+
color: #42b983;
|
228 |
+
text-align: center; /* 제목 중앙 정렬 유지 */
|
229 |
+
}
|
230 |
+
|
231 |
+
p {
|
232 |
+
text-align: center; /* 설명 문구 중앙 정렬 유지 */
|
233 |
}
|
234 |
|
235 |
.input-section {
|
|
|
259 |
cursor: pointer;
|
260 |
font-size: 16px;
|
261 |
transition: background-color 0.3s ease;
|
262 |
+
margin-top: 10px; /* 버튼 위쪽 여백 추가 */
|
263 |
}
|
264 |
|
265 |
button:hover:not(:disabled) {
|
|
|
275 |
color: red;
|
276 |
margin-top: 20px;
|
277 |
font-weight: bold;
|
278 |
+
background-color: #ffebee;
|
279 |
+
border: 1px solid #ef9a9a;
|
280 |
+
padding: 10px;
|
281 |
+
border-radius: 4px;
|
282 |
+
}
|
283 |
+
|
284 |
+
.generated-answer-section {
|
285 |
+
margin-top: 30px;
|
286 |
+
padding: 20px;
|
287 |
+
background-color: #e8f5e9; /* 부드러운 녹색 배경 */
|
288 |
+
border-left: 5px solid #4CAF50; /* 왼쪽 테두리 */
|
289 |
+
border-radius: 5px;
|
290 |
+
text-align: left;
|
291 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
292 |
+
}
|
293 |
+
|
294 |
+
.generated-answer-section h2 {
|
295 |
+
color: #2e7d32; /* 진한 녹색 제목 */
|
296 |
+
margin-top: 0;
|
297 |
+
margin-bottom: 15px;
|
298 |
+
font-size: 1.5em;
|
299 |
+
}
|
300 |
+
|
301 |
+
.generated-answer-section p {
|
302 |
+
line-height: 1.6;
|
303 |
+
color: #333;
|
304 |
+
white-space: pre-wrap; /* 줄바꿈 및 공백 유지 */
|
305 |
}
|
306 |
|
307 |
.info-message {
|
308 |
color: #3498db;
|
309 |
margin-top: 20px;
|
310 |
font-style: italic;
|
311 |
+
background-color: #e3f2fd;
|
312 |
+
border: 1px solid #90caf9;
|
313 |
+
padding: 10px;
|
314 |
+
border-radius: 4px;
|
315 |
}
|
316 |
|
317 |
+
.timestamp-link {
|
318 |
+
color: #007bff; /* 파란색 링크 스타일 */
|
319 |
+
cursor: pointer; /* 클릭 가능하다는 표시 */
|
320 |
+
text-decoration: none; /* 밑줄 */
|
|
|
321 |
}
|
322 |
|
323 |
+
.timestamp-link:hover {
|
324 |
+
color: #0056b3; /* 호버 시 색상 변경 */
|
325 |
+
}
|
326 |
+
|
327 |
+
.results-section h2 { /* 이 스타일은 이제 .right-sidebar h2로 대체될 수 있음 */
|
328 |
+
color: #2c3e50;
|
329 |
+
margin-bottom: 15px;
|
330 |
+
font-size: 1.3em;
|
331 |
}
|
332 |
|
333 |
.result-item {
|
334 |
background-color: #f9f9f9;
|
335 |
+
border: 1px solid #eee;
|
336 |
+
border-radius: 4px;
|
337 |
padding: 15px;
|
338 |
+
margin-bottom: 10px;
|
339 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
340 |
}
|
341 |
|
342 |
.result-item p {
|
343 |
+
margin: 0 0 5px 0;
|
344 |
+
color: #555;
|
345 |
+
text-align: left;
|
346 |
+
}
|
347 |
+
|
348 |
+
.result-item p strong {
|
349 |
+
color: #000;
|
350 |
}
|
351 |
|
352 |
.result-item a {
|
|
|
358 |
text-decoration: underline;
|
359 |
}
|
360 |
|
361 |
+
.video-embed {
|
362 |
+
margin-top: 20px;
|
363 |
+
margin-bottom: 30px;
|
364 |
+
background-color: #000;
|
365 |
border-radius: 8px;
|
366 |
overflow: hidden;
|
|
|
367 |
position: relative;
|
368 |
padding-bottom: 56.25%;
|
369 |
height: 0;
|