├── backend ├── app │ ├── db │ │ ├── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── video_task.py │ │ ├── engine.py │ │ ├── init_db.py │ │ ├── migrate.py │ │ └── video_task_dao.py │ ├── gpt │ │ ├── __init__.py │ │ ├── base.py │ │ └── openai_gpt.py │ ├── routers │ │ ├── __init__.py │ │ ├── model.py │ │ └── download.py │ ├── services │ │ └── __init__.py │ ├── exceptions │ │ ├── __init__.py │ │ └── exception_handlers.py │ ├── transcriber │ │ ├── __init__.py │ │ ├── base.py │ │ ├── transcriber_provider.py │ │ └── fast_whisper.py │ ├── .DS_Store │ ├── models │ │ ├── __init__.py │ │ ├── notes_model.py │ │ └── transcriber_model.py │ ├── __init__.py │ └── utils │ │ ├── logger.py │ │ ├── response.py │ │ ├── video_helper.py │ │ └── ffmpeg_helper.py ├── .DS_Store ├── icon.icns ├── requirements.txt ├── .gitignore ├── check_db.py ├── fix_markdown_paths.py ├── start.sh ├── start.bat ├── main.py ├── ENV_SETUP.md ├── video_note_ai.spec └── app_entry.py ├── .DS_Store ├── frontend ├── postcss.config.js ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── components │ │ ├── ui │ │ │ └── ScrollArea.tsx │ │ ├── MarkdownContent.tsx │ │ ├── ProviderIcon.tsx │ │ ├── UploadPage.tsx │ │ ├── MarkdownViewer.tsx │ │ ├── ContentPreviewModal.tsx │ │ ├── Sidebar.tsx │ │ ├── TranscriptViewer.tsx │ │ ├── MainContent.tsx │ │ ├── FileConfirmDialog.tsx │ │ ├── CurrentModelDisplay.tsx │ │ ├── StepProgress.tsx │ │ ├── TaskList.tsx │ │ ├── UploadForm.tsx │ │ ├── VideoDownloader.tsx │ │ ├── ModelSelector.tsx │ │ ├── ModelSelectorPanel.tsx │ │ ├── TaskDetailPanel.tsx │ │ └── EnhancedMarkdownViewer.tsx │ ├── index.css │ ├── store │ │ └── taskStore.ts │ ├── App.tsx │ └── services │ │ └── api.ts ├── tailwind.config.js ├── tsconfig.node.json ├── .gitignore ├── index.html ├── vite.config.ts ├── icon │ ├── siliconcloud-color.svg │ ├── claude-color.svg │ ├── openai-svgrepo-com.svg │ ├── deepseek-color.svg │ ├── chatglm-color.svg │ ├── gemini-color.svg │ └── ollama.svg ├── tsconfig.json └── package.json ├── .gitignore ├── README.md └── 原理博客.md /backend/app/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/gpt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/transcriber/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jehuge/video-Ai-note/HEAD/.DS_Store -------------------------------------------------------------------------------- /backend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jehuge/video-Ai-note/HEAD/backend/.DS_Store -------------------------------------------------------------------------------- /backend/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jehuge/video-Ai-note/HEAD/backend/icon.icns -------------------------------------------------------------------------------- /backend/app/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jehuge/video-Ai-note/HEAD/backend/app/.DS_Store -------------------------------------------------------------------------------- /backend/app/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .video_task import VideoTask 2 | 3 | __all__ = ["VideoTask"] 4 | 5 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | 8 | -------------------------------------------------------------------------------- /backend/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .notes_model import NoteResult 2 | from .transcriber_model import TranscriptResult, TranscriptSegment 3 | 4 | __all__ = ["NoteResult", "TranscriptResult", "TranscriptSegment"] 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | 11 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | 12 | -------------------------------------------------------------------------------- /backend/app/models/notes_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from app.models.transcriber_model import TranscriptResult 3 | 4 | 5 | @dataclass 6 | class NoteResult: 7 | """笔记结果""" 8 | markdown: str # GPT 总结的 Markdown 内容 9 | transcript: TranscriptResult # 转录结果 10 | filename: str # 原始文件名 11 | 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .DS_Store 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.115.12 2 | uvicorn==0.34.0 3 | python-dotenv==1.1.0 4 | python-multipart==0.0.20 5 | SQLAlchemy==2.0.41 6 | openai==1.70.0 7 | google-generativeai==0.8.3 8 | faster-whisper==1.1.1 9 | ffmpeg-python==0.2.0 10 | imageio-ffmpeg>=0.5.0 11 | pydantic==2.11.2 12 | orjson==3.10.16 13 | requests==2.32.3 14 | markdown>=3.0 15 | reportlab>=4.0.0 16 | playwright>=1.40.0 17 | yt-dlp 18 | -------------------------------------------------------------------------------- /frontend/src/components/ui/ScrollArea.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | interface ScrollAreaProps { 4 | children: ReactNode 5 | className?: string 6 | } 7 | 8 | export function ScrollArea({ children, className = '' }: ScrollAreaProps) { 9 | return ( 10 |
11 | {children} 12 |
13 | ) 14 | } 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 强大的AI视频笔记神器-支持截图生产 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /backend/app/transcriber/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from app.models.transcriber_model import TranscriptResult 3 | 4 | 5 | class Transcriber(ABC): 6 | """转录器基类""" 7 | 8 | @abstractmethod 9 | def transcript(self, file_path: str) -> TranscriptResult: 10 | """ 11 | 转录音频文件 12 | 13 | :param file_path: 音频文件路径 14 | :return: TranscriptResult 对象 15 | """ 16 | pass 17 | 18 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | ENV/ 10 | .venv 11 | 12 | # Database 13 | *.db 14 | *.sqlite 15 | *.sqlite3 16 | 17 | # Environment 18 | .env 19 | 20 | # IDE 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | 26 | # Uploads and outputs 27 | uploads/ 28 | note_results/ 29 | static/ 30 | 31 | # FFmpeg binaries (auto-downloaded) 32 | ffmpeg_bin/ 33 | 34 | # Logs 35 | *.log 36 | 37 | -------------------------------------------------------------------------------- /backend/app/gpt/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from app.models.transcriber_model import TranscriptResult 3 | 4 | 5 | class GPT(ABC): 6 | """GPT 基类""" 7 | 8 | @abstractmethod 9 | def summarize(self, transcript: TranscriptResult, filename: str = "") -> str: 10 | """ 11 | 根据转录内容生成笔记 12 | 13 | :param transcript: 转录结果 14 | :param filename: 文件名(可选) 15 | :return: Markdown 格式的笔记 16 | """ 17 | pass 18 | 19 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, './src'), 10 | }, 11 | }, 12 | server: { 13 | port: 5173, 14 | proxy: { 15 | '/api': { 16 | target: 'http://localhost:8483', 17 | changeOrigin: true, 18 | }, 19 | }, 20 | }, 21 | }) 22 | 23 | -------------------------------------------------------------------------------- /backend/app/models/transcriber_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional 3 | 4 | 5 | @dataclass 6 | class TranscriptSegment: 7 | """转录片段""" 8 | start: float # 开始时间(秒) 9 | end: float # 结束时间(秒) 10 | text: str # 该段文字 11 | 12 | 13 | @dataclass 14 | class TranscriptResult: 15 | """转录结果""" 16 | language: Optional[str] # 检测语言(如 "zh"、"en") 17 | full_text: str # 完整合并后的文本 18 | segments: List[TranscriptSegment] # 分段结构 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | 19 | -------------------------------------------------------------------------------- /backend/app/db/engine.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | import os 5 | 6 | # SQLite 数据库路径 7 | DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./video_note.db") 8 | 9 | engine = create_engine( 10 | DATABASE_URL, 11 | connect_args={"check_same_thread": False} # SQLite 需要这个参数 12 | ) 13 | 14 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 15 | 16 | Base = declarative_base() 17 | 18 | -------------------------------------------------------------------------------- /frontend/icon/siliconcloud-color.svg: -------------------------------------------------------------------------------- 1 | SiliconCloud -------------------------------------------------------------------------------- /frontend/src/components/MarkdownContent.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown' 2 | import remarkGfm from 'remark-gfm' 3 | import 'github-markdown-css/github-markdown.css' 4 | 5 | interface MarkdownContentProps { 6 | markdown: string 7 | } 8 | 9 | export default function MarkdownContent({ markdown }: MarkdownContentProps) { 10 | return ( 11 |
12 | {markdown} 13 |
14 | ) 15 | } 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .routers import note, model, download 4 | 5 | 6 | def create_app(lifespan) -> FastAPI: 7 | """创建 FastAPI 应用""" 8 | app = FastAPI( 9 | title="Video AI Note", 10 | description="简化版视频笔记生成工具", 11 | version="1.0.0", 12 | lifespan=lifespan 13 | ) 14 | 15 | app.include_router(note.router, prefix="/api") 16 | app.include_router(model.router, prefix="/api") 17 | app.include_router(download.router, prefix="/api") 18 | 19 | return app 20 | 21 | -------------------------------------------------------------------------------- /backend/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from app.db.engine import Base, engine 2 | from app.db.migrate import migrate_add_screenshot_column 3 | from app.utils.logger import get_logger 4 | 5 | logger = get_logger(__name__) 6 | 7 | 8 | def init_db(): 9 | """初始化数据库,创建所有表""" 10 | try: 11 | Base.metadata.create_all(bind=engine) 12 | logger.info("数据库表创建完成") 13 | 14 | # 执行迁移 15 | migrate_add_screenshot_column() 16 | 17 | logger.info("数据库初始化完成") 18 | except Exception as e: 19 | logger.error(f"数据库初始化失败: {e}") 20 | raise 21 | 22 | -------------------------------------------------------------------------------- /backend/app/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | def get_logger(name: str) -> logging.Logger: 5 | """获取日志记录器""" 6 | logger = logging.getLogger(name) 7 | logger.setLevel(logging.INFO) 8 | 9 | if not logger.handlers: 10 | handler = logging.StreamHandler(sys.stdout) 11 | handler.setLevel(logging.INFO) 12 | formatter = logging.Formatter( 13 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 14 | ) 15 | handler.setFormatter(formatter) 16 | logger.addHandler(handler) 17 | 18 | return logger 19 | 20 | -------------------------------------------------------------------------------- /backend/app/utils/response.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class ResponseWrapper: 6 | """统一响应包装器""" 7 | 8 | @staticmethod 9 | def success(data: Any = None, msg: str = "success", code: int = 200): 10 | return { 11 | "code": code, 12 | "msg": msg, 13 | "data": data 14 | } 15 | 16 | @staticmethod 17 | def error(msg: str = "error", code: int = 500, data: Any = None): 18 | return { 19 | "code": code, 20 | "msg": msg, 21 | "data": data 22 | } 23 | 24 | -------------------------------------------------------------------------------- /backend/app/exceptions/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.responses import JSONResponse 3 | from app.utils.logger import get_logger 4 | 5 | logger = get_logger(__name__) 6 | 7 | 8 | def register_exception_handlers(app: FastAPI): 9 | """注册全局异常处理器""" 10 | 11 | @app.exception_handler(Exception) 12 | async def global_exception_handler(request: Request, exc: Exception): 13 | logger.error(f"未处理的异常: {exc}", exc_info=True) 14 | return JSONResponse( 15 | status_code=500, 16 | content={ 17 | "code": 500, 18 | "msg": str(exc), 19 | "data": None 20 | } 21 | ) 22 | 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | 27 | -------------------------------------------------------------------------------- /backend/app/db/models/video_task.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, DateTime, func 2 | from app.db.engine import Base 3 | 4 | 5 | class VideoTask(Base): 6 | """视频任务表""" 7 | __tablename__ = "video_tasks" 8 | 9 | id = Column(Integer, primary_key=True, autoincrement=True) 10 | task_id = Column(String, unique=True, nullable=False, index=True) 11 | filename = Column(String, nullable=False) 12 | status = Column(String, nullable=False, default="pending") 13 | markdown = Column(String, nullable=True) 14 | screenshot = Column(Integer, default=0) # 0=False, 1=True 15 | created_at = Column(DateTime, server_default=func.now()) 16 | updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 环境变量和敏感信息 2 | .env 3 | .env.local 4 | .env.*.local 5 | *.key 6 | *.pem 7 | *.p12 8 | 9 | # 数据库文件 10 | *.db 11 | *.sqlite 12 | *.sqlite3 13 | 14 | # 上传文件和输出结果 15 | uploads/ 16 | note_results/ 17 | static/ 18 | 19 | # Python 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | *.so 24 | .Python 25 | env/ 26 | venv/ 27 | ENV/ 28 | .venv 29 | 30 | # Node 31 | node_modules/ 32 | dist/ 33 | dist-ssr/ 34 | *.local 35 | 36 | # IDE 37 | .vscode/ 38 | .idea/ 39 | *.swp 40 | *.swo 41 | *.suo 42 | *.ntvs* 43 | *.njsproj 44 | *.sln 45 | 46 | # 系统文件 47 | .DS_Store 48 | Thumbs.db 49 | 50 | # 日志 51 | *.log 52 | logs/ 53 | 54 | # 打包和发布 55 | release/ 56 | backend/build/ 57 | backend/dist/ 58 | 59 | # 二进制和安装包 60 | *.app 61 | *.dmg 62 | *.pkg 63 | *.exe 64 | *.zip 65 | 66 | # ffmpeg 67 | ffmpeg_bin/ 68 | -------------------------------------------------------------------------------- /backend/check_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """检查数据库结构""" 3 | import sqlite3 4 | import os 5 | 6 | DB_PATH = "video_note.db" 7 | 8 | if os.path.exists(DB_PATH): 9 | conn = sqlite3.connect(DB_PATH) 10 | cursor = conn.cursor() 11 | 12 | # 检查表结构 13 | cursor.execute("PRAGMA table_info(video_tasks)") 14 | columns = cursor.fetchall() 15 | 16 | print("数据库表结构:") 17 | print("-" * 50) 18 | for col in columns: 19 | print(f" {col[1]} ({col[2]})") 20 | 21 | # 检查是否有 screenshot 字段 22 | column_names = [col[1] for col in columns] 23 | if "screenshot" in column_names: 24 | print("\n✓ screenshot 字段已存在") 25 | else: 26 | print("\n✗ screenshot 字段不存在,需要添加") 27 | print("执行迁移: python -c 'from app.db.migrate import migrate_add_screenshot_column; migrate_add_screenshot_column()'") 28 | 29 | conn.close() 30 | else: 31 | print(f"数据库文件 {DB_PATH} 不存在") 32 | 33 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-ai-note-frontend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@types/html2canvas": "^0.5.35", 13 | "axios": "^1.8.4", 14 | "github-markdown-css": "^5.8.1", 15 | "html2canvas": "^1.4.1", 16 | "jspdf": "^3.0.4", 17 | "lucide-react": "^0.487.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-hot-toast": "^2.5.2", 21 | "react-markdown": "^8.0.7", 22 | "react-medium-image-zoom": "^5.4.0", 23 | "remark-gfm": "^3.0.1", 24 | "zustand": "^5.0.3" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.2.0", 28 | "@types/react-dom": "^18.2.0", 29 | "@vitejs/plugin-react": "^4.3.4", 30 | "autoprefixer": "^10.4.21", 31 | "postcss": "^8.4.35", 32 | "tailwindcss": "^3.4.1", 33 | "typescript": "^5.7.2", 34 | "vite": "^6.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/app/transcriber/transcriber_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app.transcriber.base import Transcriber 3 | from app.transcriber.fast_whisper import FastWhisperTranscriber 4 | from app.utils.logger import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | # 支持的转录器类型 9 | _transcribers = { 10 | "fast-whisper": FastWhisperTranscriber, 11 | } 12 | 13 | 14 | def get_transcriber(transcriber_type: str = "fast-whisper") -> Transcriber: 15 | """ 16 | 获取转录器实例 17 | 18 | :param transcriber_type: 转录器类型 19 | :return: Transcriber 实例 20 | """ 21 | if transcriber_type not in _transcribers: 22 | raise ValueError(f"不支持的转录器类型: {transcriber_type}") 23 | 24 | transcriber_cls = _transcribers[transcriber_type] 25 | 26 | # 根据类型初始化 27 | if transcriber_type == "fast-whisper": 28 | model_size = os.getenv("WHISPER_MODEL_SIZE", "base") 29 | device = os.getenv("WHISPER_DEVICE", "cpu") 30 | return transcriber_cls(model_size=model_size, device=device) 31 | 32 | return transcriber_cls() 33 | 34 | -------------------------------------------------------------------------------- /frontend/src/components/ProviderIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ProviderIconProps { 4 | provider: string 5 | className?: string 6 | alt?: string 7 | } 8 | 9 | const ICON_FILENAMES: Record = { 10 | openai: 'openai-svgrepo-com.svg', 11 | deepseek: 'deepseek-color.svg', 12 | claude: 'claude-color.svg', 13 | gemini: 'gemini-color.svg', 14 | ollama: 'ollama.svg', 15 | chatglm: 'chatglm-color.svg', 16 | siliconcloud: 'siliconcloud-color.svg', 17 | deepseek_color: 'deepseek-color.svg', 18 | } 19 | 20 | export default function ProviderIcon({ provider, className, alt }: ProviderIconProps) { 21 | const filename = ICON_FILENAMES[provider] 22 | if (filename) { 23 | // file is located at frontend/icon/*.svg, this file is in frontend/src/components 24 | const src = new URL(`../../icon/${filename}`, import.meta.url).href 25 | return {alt 26 | } 27 | 28 | // fallback emoji 29 | return 🤖 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /backend/fix_markdown_paths.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 修复 markdown 文件中的图片路径 4 | 将 /static/screenshots/ 替换为 /api/note_results/screenshots/ 5 | """ 6 | import re 7 | from pathlib import Path 8 | 9 | NOTE_OUTPUT_DIR = Path("note_results") 10 | IMAGE_BASE_URL = "/api/note_results/screenshots" 11 | 12 | def fix_markdown_paths(): 13 | """修复所有 markdown 文件中的图片路径""" 14 | markdown_files = list(NOTE_OUTPUT_DIR.glob("*_markdown.md")) 15 | 16 | for md_file in markdown_files: 17 | print(f"处理文件: {md_file.name}") 18 | content = md_file.read_text(encoding='utf-8') 19 | 20 | # 修复图片路径 21 | old_content = content 22 | content = re.sub( 23 | r'!\[\]\(/static/screenshots/([^)]+)\)', 24 | lambda m: f"![]({IMAGE_BASE_URL.rstrip('/')}/{m.group(1)})", 25 | content 26 | ) 27 | 28 | if content != old_content: 29 | md_file.write_text(content, encoding='utf-8') 30 | print(f" ✓ 已修复路径") 31 | else: 32 | print(f" - 无需修复") 33 | 34 | if __name__ == "__main__": 35 | fix_markdown_paths() 36 | print("完成!") 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/components/UploadPage.tsx: -------------------------------------------------------------------------------- 1 | import UploadForm from './UploadForm' 2 | 3 | export default function UploadPage() { 4 | return ( 5 |
6 |
7 |
8 |

上传视频/音频

9 |

上传视频或音频文件,系统将自动处理并生成笔记

10 |
11 | 12 |
13 | 14 |
15 | 16 | {/* 使用提示 */} 17 |
18 |

💡 使用提示

19 |
    20 |
  • • 支持 MP4, AVI, MOV, MKV, MP3, WAV 等格式
  • 21 |
  • • 文件大小限制:最大 500MB
  • 22 |
  • • 上传后系统会自动处理:提取音频 → 转写文字 → 生成笔记
  • 23 |
  • • 处理完成后可以在首页查看任务列表和笔记预览
  • 24 |
  • • 可以选择是否生成截图标记(仅视频文件)
  • 25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /backend/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 检查 Python 环境 4 | if ! command -v python3 &> /dev/null; then 5 | echo "❌ 错误: 未找到 Python3,请先安装 Python 3.8+" 6 | exit 1 7 | fi 8 | 9 | # 检查 Python 版本 10 | PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}') 11 | echo "✓ Python 版本: $PYTHON_VERSION" 12 | 13 | # 检查虚拟环境 14 | if [ ! -d "venv" ]; then 15 | echo "📦 创建虚拟环境..." 16 | python3 -m venv venv 17 | if [ $? -ne 0 ]; then 18 | echo "❌ 虚拟环境创建失败" 19 | exit 1 20 | fi 21 | echo "✓ 虚拟环境创建成功" 22 | else 23 | echo "✓ 虚拟环境已存在" 24 | fi 25 | 26 | # 激活虚拟环境 27 | echo "🔧 激活虚拟环境..." 28 | source venv/bin/activate 29 | 30 | # 升级 pip 31 | echo "⬆️ 升级 pip..." 32 | pip install --upgrade pip -q 33 | 34 | # 安装依赖 35 | echo "📥 安装依赖包..." 36 | pip install -r requirements.txt 37 | if [ $? -ne 0 ]; then 38 | echo "❌ 依赖安装失败" 39 | exit 1 40 | fi 41 | echo "✓ 依赖安装完成" 42 | 43 | # 检查 .env 文件 44 | if [ ! -f ".env" ]; then 45 | echo "⚠️ 警告: 未找到 .env 文件" 46 | echo " 请复制 .env.example 为 .env 并配置环境变量:" 47 | echo " cp .env.example .env" 48 | echo " 然后编辑 .env 文件,填入你的 OPENAI_API_KEY" 49 | echo "" 50 | read -p "是否继续启动? (y/n) " -n 1 -r 51 | echo 52 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 53 | exit 1 54 | fi 55 | else 56 | echo "✓ 环境变量文件已配置" 57 | fi 58 | 59 | # 启动服务 60 | echo "" 61 | echo "🚀 启动后端服务..." 62 | echo " 访问地址: http://localhost:8483" 63 | echo " API 文档: http://localhost:8483/docs" 64 | echo "" 65 | python main.py 66 | 67 | -------------------------------------------------------------------------------- /backend/start.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 >nul 3 | 4 | REM 检查 Python 环境 5 | python --version >nul 2>&1 6 | if errorlevel 1 ( 7 | echo ❌ 错误: 未找到 Python,请先安装 Python 3.8+ 8 | pause 9 | exit /b 1 10 | ) 11 | 12 | python --version 13 | echo ✓ Python 环境检查通过 14 | echo. 15 | 16 | REM 检查虚拟环境 17 | if not exist "venv" ( 18 | echo 📦 创建虚拟环境... 19 | python -m venv venv 20 | if errorlevel 1 ( 21 | echo ❌ 虚拟环境创建失败 22 | pause 23 | exit /b 1 24 | ) 25 | echo ✓ 虚拟环境创建成功 26 | ) else ( 27 | echo ✓ 虚拟环境已存在 28 | ) 29 | 30 | REM 激活虚拟环境 31 | echo 🔧 激活虚拟环境... 32 | call venv\Scripts\activate.bat 33 | 34 | REM 升级 pip 35 | echo ⬆️ 升级 pip... 36 | python -m pip install --upgrade pip -q 37 | 38 | REM 安装依赖 39 | echo 📥 安装依赖包... 40 | pip install -r requirements.txt 41 | if errorlevel 1 ( 42 | echo ❌ 依赖安装失败 43 | pause 44 | exit /b 1 45 | ) 46 | echo ✓ 依赖安装完成 47 | echo. 48 | 49 | REM 检查 .env 文件 50 | if not exist ".env" ( 51 | echo ⚠️ 警告: 未找到 .env 文件 52 | echo 请复制 .env.example 为 .env 并配置环境变量: 53 | echo copy .env.example .env 54 | echo 然后编辑 .env 文件,填入你的 OPENAI_API_KEY 55 | echo. 56 | set /p continue="是否继续启动? (y/n): " 57 | if /i not "%continue%"=="y" ( 58 | exit /b 1 59 | ) 60 | ) else ( 61 | echo ✓ 环境变量文件已配置 62 | ) 63 | 64 | REM 启动服务 65 | echo. 66 | echo 🚀 启动后端服务... 67 | echo 访问地址: http://localhost:8483 68 | echo API 文档: http://localhost:8483/docs 69 | echo. 70 | python main.py 71 | 72 | pause 73 | 74 | -------------------------------------------------------------------------------- /backend/app/utils/video_helper.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import uuid 4 | from pathlib import Path 5 | from typing import Optional 6 | from app.utils.logger import get_logger 7 | from app.utils.ffmpeg_helper import get_ffmpeg_path 8 | 9 | logger = get_logger(__name__) 10 | 11 | 12 | def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str: 13 | """ 14 | 使用 ffmpeg 生成截图,返回生成图片路径 15 | 16 | :param video_path: 视频文件路径 17 | :param output_dir: 输出目录 18 | :param timestamp: 时间戳(秒) 19 | :param index: 截图索引 20 | :return: 生成的截图文件路径 21 | """ 22 | output_dir = Path(output_dir) 23 | output_dir.mkdir(parents=True, exist_ok=True) 24 | 25 | filename = f"screenshot_{index:03d}_{uuid.uuid4().hex[:8]}.jpg" 26 | output_path = output_dir / filename 27 | 28 | ffmpeg_path = get_ffmpeg_path() 29 | command = [ 30 | ffmpeg_path, 31 | "-ss", str(timestamp), 32 | "-i", str(video_path), 33 | "-frames:v", "1", 34 | "-q:v", "2", # 高质量 35 | "-y", # 覆盖已存在文件 36 | str(output_path) 37 | ] 38 | 39 | try: 40 | result = subprocess.run( 41 | command, 42 | capture_output=True, 43 | text=True, 44 | check=True 45 | ) 46 | logger.info(f"截图生成成功: {output_path} (时间戳: {timestamp}秒)") 47 | return str(output_path) 48 | except subprocess.CalledProcessError as e: 49 | logger.error(f"生成截图失败: {e.stderr}") 50 | raise Exception(f"生成截图失败: {e.stderr}") 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /frontend/src/store/taskStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | export interface Task { 4 | id: string 5 | filename: string 6 | status: 'pending' | 'processing' | 'transcribing' | 'summarizing' | 'completed' | 'failed' 7 | markdown?: string 8 | createdAt?: string 9 | } 10 | 11 | interface TaskStore { 12 | tasks: Task[] 13 | currentTaskId: string | null 14 | addTask: (task: Task) => void 15 | updateTask: (id: string, updates: Partial) => void 16 | setCurrentTask: (id: string | null) => void 17 | loadTasks: (tasks: Task[]) => void 18 | removeTask: (id: string) => void 19 | } 20 | 21 | export const useTaskStore = create((set) => ({ 22 | tasks: [], 23 | currentTaskId: null, 24 | 25 | addTask: (task) => 26 | set((state) => ({ 27 | tasks: [task, ...state.tasks], 28 | currentTaskId: task.id, 29 | })), 30 | 31 | updateTask: (id, updates) => 32 | set((state) => ({ 33 | tasks: state.tasks.map((task) => 34 | task.id === id ? { ...task, ...updates } : task 35 | ), 36 | })), 37 | 38 | setCurrentTask: (id) => 39 | set({ currentTaskId: id }), 40 | 41 | loadTasks: (tasks) => 42 | set({ tasks }), 43 | 44 | removeTask: (id) => 45 | set((state) => { 46 | const newTasks = state.tasks.filter((task) => task.id !== id) 47 | // 如果删除的是当前选中的任务,清除选中状态 48 | const newCurrentTaskId = state.currentTaskId === id ? null : state.currentTaskId 49 | return { 50 | tasks: newTasks, 51 | currentTaskId: newCurrentTaskId, 52 | } 53 | }), 54 | })) 55 | 56 | -------------------------------------------------------------------------------- /frontend/icon/claude-color.svg: -------------------------------------------------------------------------------- 1 | Claude -------------------------------------------------------------------------------- /frontend/icon/openai-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | OpenAI icon -------------------------------------------------------------------------------- /backend/app/db/migrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | 数据库迁移脚本 3 | 用于添加新字段到现有数据库 4 | """ 5 | import sqlite3 6 | from pathlib import Path 7 | import os 8 | import sys 9 | 10 | # 添加项目根目录到路径 11 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 12 | 13 | try: 14 | from app.utils.logger import get_logger 15 | logger = get_logger(__name__) 16 | except: 17 | import logging 18 | logging.basicConfig(level=logging.INFO) 19 | logger = logging.getLogger(__name__) 20 | 21 | DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./video_note.db") 22 | # 处理相对路径和绝对路径 23 | if DATABASE_URL.startswith("sqlite:///"): 24 | DB_PATH = DATABASE_URL.replace("sqlite:///", "") 25 | # 如果是相对路径,转换为绝对路径 26 | if not os.path.isabs(DB_PATH): 27 | DB_PATH = os.path.join(os.getcwd(), DB_PATH) 28 | else: 29 | DB_PATH = DATABASE_URL 30 | 31 | 32 | def migrate_add_screenshot_column(): 33 | """添加 screenshot 字段到 video_tasks 表""" 34 | if not Path(DB_PATH).exists(): 35 | logger.info("数据库不存在,将在首次运行时自动创建") 36 | return 37 | 38 | try: 39 | conn = sqlite3.connect(DB_PATH) 40 | cursor = conn.cursor() 41 | 42 | # 检查字段是否已存在 43 | cursor.execute("PRAGMA table_info(video_tasks)") 44 | columns = [column[1] for column in cursor.fetchall()] 45 | 46 | if "screenshot" not in columns: 47 | logger.info("添加 screenshot 字段到 video_tasks 表...") 48 | cursor.execute("ALTER TABLE video_tasks ADD COLUMN screenshot INTEGER DEFAULT 0") 49 | conn.commit() 50 | logger.info("✓ screenshot 字段添加成功") 51 | else: 52 | logger.info("screenshot 字段已存在,跳过迁移") 53 | 54 | conn.close() 55 | except Exception as e: 56 | logger.error(f"数据库迁移失败: {e}") 57 | raise 58 | 59 | 60 | if __name__ == "__main__": 61 | migrate_add_screenshot_column() 62 | 63 | -------------------------------------------------------------------------------- /frontend/src/components/MarkdownViewer.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown' 2 | import remarkGfm from 'remark-gfm' 3 | import { useTaskStore } from '../store/taskStore' 4 | import 'github-markdown-css/github-markdown.css' 5 | 6 | interface MarkdownViewerProps { 7 | markdown?: string 8 | } 9 | 10 | export default function MarkdownViewer({ markdown: propMarkdown }: MarkdownViewerProps = {}) { 11 | const { tasks, currentTaskId } = useTaskStore() 12 | 13 | const currentTask = tasks.find(t => t.id === currentTaskId) 14 | const markdown = propMarkdown || currentTask?.markdown || '' 15 | 16 | if (!currentTaskId) { 17 | return ( 18 |
19 |
20 |

请选择一个任务查看笔记

21 |
22 |
23 | ) 24 | } 25 | 26 | if (currentTask?.status === 'failed') { 27 | return ( 28 |
29 |
30 |

任务处理失败,请重试

31 |
32 |
33 | ) 34 | } 35 | 36 | if (!markdown && currentTask?.status !== 'completed') { 37 | return ( 38 |
39 |
40 |

正在生成笔记,请稍候...

41 |
42 |
43 | ) 44 | } 45 | 46 | return ( 47 |
48 |
49 |

笔记预览

50 | {currentTask && ( 51 |

{currentTask.filename}

52 | )} 53 |
54 | 55 |
56 | 57 | {markdown} 58 | 59 |
60 |
61 | ) 62 | } 63 | 64 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Sidebar from './components/Sidebar' 3 | import MainContent from './components/MainContent' 4 | import CurrentModelDisplay from './components/CurrentModelDisplay' 5 | import { Toaster } from 'react-hot-toast' 6 | 7 | type MenuItem = 'home' | 'upload' | 'model' | 'settings' | 'download' 8 | 9 | function App() { 10 | const [activeMenu, setActiveMenu] = useState('home') 11 | 12 | return ( 13 |
14 | {/* 顶部导航栏 */} 15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 |

Video AI Note

24 |

智能视频笔记生成工具

25 |
26 |
27 | {/* 右侧:当前模型显示 */} 28 |
29 | 30 |
31 |
32 | 33 | {/* 主内容区 */} 34 |
35 | {/* 左侧:菜单栏 */} 36 | 37 | 38 | {/* 右侧:功能配置和操作区 */} 39 |
40 | 41 |
42 |
43 | 44 |
45 | ) 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /frontend/icon/deepseek-color.svg: -------------------------------------------------------------------------------- 1 | DeepSeek -------------------------------------------------------------------------------- /backend/app/transcriber/fast_whisper.py: -------------------------------------------------------------------------------- 1 | import os 2 | from faster_whisper import WhisperModel 3 | from app.transcriber.base import Transcriber 4 | from app.models.transcriber_model import TranscriptResult, TranscriptSegment 5 | from app.utils.logger import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | 10 | class FastWhisperTranscriber(Transcriber): 11 | """使用 faster-whisper 进行音频转录""" 12 | 13 | def __init__(self, model_size: str = "base", device: str = "cpu"): 14 | """ 15 | 初始化转录器 16 | 17 | :param model_size: 模型大小 (tiny, base, small, medium, large) 18 | :param device: 设备 (cpu, cuda) 19 | """ 20 | self.model_size = model_size 21 | self.device = device 22 | self._model = None 23 | logger.info(f"配置 FastWhisper 转录器: model_size={model_size}, device={device}") 24 | 25 | @property 26 | def model(self): 27 | if self._model is None: 28 | logger.info(f"正在加载 FastWhisper 模型: {self.model_size}...") 29 | self._model = WhisperModel(self.model_size, device=self.device) 30 | logger.info("FastWhisper 模型加载完成") 31 | return self._model 32 | 33 | def transcript(self, file_path: str) -> TranscriptResult: 34 | """转录音频文件""" 35 | logger.info(f"开始转录: {file_path}") 36 | 37 | segments, info = self.model.transcribe( 38 | file_path, 39 | beam_size=5, 40 | language=None, # 自动检测语言 41 | vad_filter=True # 启用语音活动检测 42 | ) 43 | 44 | # 提取语言 45 | language = info.language 46 | 47 | # 处理分段 48 | transcript_segments = [] 49 | full_text_parts = [] 50 | 51 | for segment in segments: 52 | transcript_segments.append( 53 | TranscriptSegment( 54 | start=segment.start, 55 | end=segment.end, 56 | text=segment.text.strip() 57 | ) 58 | ) 59 | full_text_parts.append(segment.text.strip()) 60 | 61 | full_text = " ".join(full_text_parts) 62 | 63 | logger.info(f"转录完成: 语言={language}, 分段数={len(transcript_segments)}") 64 | 65 | return TranscriptResult( 66 | language=language, 67 | full_text=full_text, 68 | segments=transcript_segments 69 | ) 70 | -------------------------------------------------------------------------------- /frontend/icon/chatglm-color.svg: -------------------------------------------------------------------------------- 1 | ChatGLM -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import asynccontextmanager 3 | from pathlib import Path 4 | 5 | import uvicorn 6 | from fastapi import FastAPI 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from fastapi.staticfiles import StaticFiles 9 | from dotenv import load_dotenv 10 | 11 | from app.db.init_db import init_db 12 | from app.exceptions.exception_handlers import register_exception_handlers 13 | from app.utils.logger import get_logger 14 | from app import create_app 15 | from app.transcriber.transcriber_provider import get_transcriber 16 | 17 | logger = get_logger(__name__) 18 | load_dotenv() 19 | 20 | # 创建必要的目录 21 | UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads") 22 | NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results") 23 | STATIC_DIR = os.getenv("STATIC_DIR", "static") 24 | 25 | for dir_path in [UPLOAD_DIR, NOTE_OUTPUT_DIR, STATIC_DIR]: 26 | Path(dir_path).mkdir(parents=True, exist_ok=True) 27 | 28 | 29 | @asynccontextmanager 30 | async def lifespan(app: FastAPI): 31 | """应用生命周期管理""" 32 | # 初始化数据库 33 | init_db() 34 | 35 | # 初始化转录器 36 | transcriber_type = os.getenv("TRANSCRIBER_TYPE", "fast-whisper") 37 | get_transcriber(transcriber_type=transcriber_type) 38 | 39 | logger.info("应用启动完成") 40 | yield 41 | logger.info("应用关闭") 42 | 43 | 44 | app = create_app(lifespan=lifespan) 45 | 46 | # CORS 配置 47 | origins = [ 48 | "http://localhost:5173", 49 | "http://localhost:3000", 50 | "http://127.0.0.1:5173", 51 | ] 52 | 53 | app.add_middleware( 54 | CORSMiddleware, 55 | allow_origins=origins, 56 | allow_credentials=True, 57 | allow_methods=["*"], 58 | allow_headers=["*"], 59 | ) 60 | 61 | # 注册异常处理器 62 | register_exception_handlers(app) 63 | 64 | # 静态文件服务 65 | # 使用 /api/uploads 以匹配前端的 vite proxy 配置 66 | app.mount("/api/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") 67 | app.mount("/api/static", StaticFiles(directory=STATIC_DIR), name="static") 68 | 69 | # note_results 目录服务(包含截图) 70 | NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results") 71 | Path(NOTE_OUTPUT_DIR).mkdir(parents=True, exist_ok=True) 72 | app.mount("/api/note_results", StaticFiles(directory=NOTE_OUTPUT_DIR), name="note_results") 73 | 74 | 75 | if __name__ == "__main__": 76 | port = int(os.getenv("BACKEND_PORT", 8483)) 77 | host = os.getenv("BACKEND_HOST", "0.0.0.0") 78 | logger.info(f"启动服务器 {host}:{port}") 79 | # 使用导入字符串以支持 reload 80 | uvicorn.run("main:app", host=host, port=port, reload=True) 81 | 82 | -------------------------------------------------------------------------------- /frontend/src/components/ContentPreviewModal.tsx: -------------------------------------------------------------------------------- 1 | import { X } from 'lucide-react' 2 | import TranscriptViewer from './TranscriptViewer' 3 | import EnhancedMarkdownViewer from './EnhancedMarkdownViewer' 4 | 5 | interface ContentPreviewModalProps { 6 | type: 'transcript' | 'markdown' | 'video' 7 | content: any 8 | title: string 9 | filename?: string 10 | taskId?: string 11 | onClose: () => void 12 | } 13 | 14 | export default function ContentPreviewModal({ 15 | type, 16 | content, 17 | title, 18 | filename, 19 | taskId, 20 | onClose, 21 | }: ContentPreviewModalProps) { 22 | return ( 23 |
{ 26 | if (e.target === e.currentTarget) { 27 | onClose() 28 | } 29 | }} 30 | > 31 |
32 | {/* 头部 */} 33 |
34 |
35 |

{title}

36 | {filename &&

{filename}

} 37 |
38 | 45 |
46 | 47 | {/* 内容区域 */} 48 |
49 | {type === 'transcript' ? ( 50 |
51 | 52 |
53 | ) : type === 'video' ? ( 54 |
55 | 63 |
64 | ) : ( 65 |
66 | 67 |
68 | )} 69 |
70 |
71 |
72 | ) 73 | } 74 | 75 | -------------------------------------------------------------------------------- /backend/ENV_SETUP.md: -------------------------------------------------------------------------------- 1 | # 环境变量配置说明 2 | 3 | ## 必需配置 4 | 5 | ### OPENAI_API_KEY 6 | 7 | 这是**必需**的环境变量,用于调用 OpenAI API 生成笔记。 8 | 9 | **获取方式:** 10 | 1. 访问 https://platform.openai.com/api-keys 11 | 2. 登录你的 OpenAI 账号 12 | 3. 创建新的 API Key 13 | 4. 复制 API Key 14 | 15 | **配置方法:** 16 | 17 | 1. 在 `backend` 目录下创建 `.env` 文件(如果不存在) 18 | 2. 添加以下内容: 19 | 20 | ```env 21 | OPENAI_API_KEY=sk-your-actual-api-key-here 22 | ``` 23 | 24 | **注意:** 25 | - 不要将 `.env` 文件提交到 Git(已在 .gitignore 中) 26 | - API Key 以 `sk-` 开头 27 | - 确保 API Key 有效且有足够的余额 28 | 29 | ## 完整配置示例 30 | 31 | 创建 `backend/.env` 文件,内容如下: 32 | 33 | ```env 34 | # GPT 配置(必需) 35 | OPENAI_API_KEY=sk-your-api-key-here 36 | OPENAI_BASE_URL=https://api.openai.com/v1 37 | GPT_MODEL=gpt-4o-mini 38 | 39 | # 转录器配置(可选) 40 | TRANSCRIBER_TYPE=fast-whisper 41 | WHISPER_MODEL_SIZE=base 42 | WHISPER_DEVICE=cpu 43 | 44 | # 服务器配置(可选) 45 | BACKEND_HOST=0.0.0.0 46 | BACKEND_PORT=8483 47 | 48 | # 文件存储(可选) 49 | UPLOAD_DIR=uploads 50 | NOTE_OUTPUT_DIR=note_results 51 | STATIC_DIR=static 52 | ``` 53 | 54 | ## 配置说明 55 | 56 | ### GPT 配置 57 | 58 | - `OPENAI_API_KEY`: OpenAI API 密钥(**必需**) 59 | - `OPENAI_BASE_URL`: API 基础 URL,默认 `https://api.openai.com/v1` 60 | - 如果使用代理或兼容 API,可以修改此值 61 | - 例如:`https://api.deepseek.com/v1`(DeepSeek) 62 | - `GPT_MODEL`: 使用的模型,默认 `gpt-4o-mini` 63 | - 可选:`gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo` 等 64 | 65 | ### 转录器配置 66 | 67 | - `TRANSCRIBER_TYPE`: 转录器类型,默认 `fast-whisper` 68 | - `WHISPER_MODEL_SIZE`: Whisper 模型大小,默认 `base` 69 | - 可选:`tiny`, `base`, `small`, `medium`, `large` 70 | - 模型越大,准确度越高,但速度越慢 71 | - `WHISPER_DEVICE`: 运行设备,默认 `cpu` 72 | - 如果有 NVIDIA GPU,可以设置为 `cuda` 73 | 74 | ### 服务器配置 75 | 76 | - `BACKEND_HOST`: 服务器监听地址,默认 `0.0.0.0`(所有接口) 77 | - `BACKEND_PORT`: 服务器端口,默认 `8483` 78 | 79 | ### 文件存储 80 | 81 | - `UPLOAD_DIR`: 上传文件存储目录,默认 `uploads` 82 | - `NOTE_OUTPUT_DIR`: 笔记结果存储目录,默认 `note_results` 83 | - `STATIC_DIR`: 静态文件目录,默认 `static` 84 | 85 | ## 验证配置 86 | 87 | 启动后端服务后,检查日志中是否有错误: 88 | 89 | ```bash 90 | cd backend 91 | ./start.sh 92 | ``` 93 | 94 | 如果看到 "OPENAI_API_KEY 未设置" 错误,说明 `.env` 文件配置不正确。 95 | 96 | ## 常见问题 97 | 98 | ### Q: 如何检查 API Key 是否有效? 99 | 100 | A: 可以在命令行测试: 101 | ```bash 102 | curl https://api.openai.com/v1/models \ 103 | -H "Authorization: Bearer sk-your-api-key" 104 | ``` 105 | 106 | ### Q: 可以使用其他 GPT 服务吗? 107 | 108 | A: 目前只支持 OpenAI 兼容的 API。如果需要支持其他服务(如 DeepSeek、Qwen),需要修改 `app/gpt/openai_gpt.py` 或添加新的实现。 109 | 110 | ### Q: 如何提高转录准确度? 111 | 112 | A: 可以: 113 | 1. 使用更大的 Whisper 模型(如 `medium` 或 `large`) 114 | 2. 使用 GPU 加速(设置 `WHISPER_DEVICE=cuda`) 115 | 116 | ### Q: 配置修改后需要重启吗? 117 | 118 | A: 是的,修改 `.env` 文件后需要重启后端服务。 119 | 120 | -------------------------------------------------------------------------------- /backend/app/db/video_task_dao.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from app.db.engine import SessionLocal 3 | from app.db.models.video_task import VideoTask 4 | from app.utils.logger import get_logger 5 | 6 | logger = get_logger(__name__) 7 | 8 | 9 | def create_task(task_id: str, filename: str, screenshot: bool = False) -> VideoTask: 10 | """创建新任务""" 11 | db = SessionLocal() 12 | try: 13 | task = VideoTask( 14 | task_id=task_id, 15 | filename=filename, 16 | status="pending", 17 | screenshot=1 if screenshot else 0 18 | ) 19 | db.add(task) 20 | db.commit() 21 | db.refresh(task) 22 | return task 23 | except Exception as e: 24 | db.rollback() 25 | logger.error(f"创建任务失败: {e}") 26 | raise 27 | finally: 28 | db.close() 29 | 30 | 31 | def get_task_by_id(task_id: str) -> VideoTask: 32 | """根据 task_id 获取任务""" 33 | db = SessionLocal() 34 | try: 35 | task = db.query(VideoTask).filter(VideoTask.task_id == task_id).first() 36 | # 如果任务存在但没有 screenshot 字段,设置默认值 37 | if task and not hasattr(task, 'screenshot'): 38 | task.screenshot = 0 39 | return task 40 | finally: 41 | db.close() 42 | 43 | 44 | def update_task_status(task_id: str, status: str, markdown: str = None): 45 | """更新任务状态""" 46 | db = SessionLocal() 47 | try: 48 | task = db.query(VideoTask).filter(VideoTask.task_id == task_id).first() 49 | if task: 50 | task.status = status 51 | if markdown: 52 | task.markdown = markdown 53 | db.commit() 54 | db.refresh(task) 55 | return task 56 | return None 57 | except Exception as e: 58 | db.rollback() 59 | logger.error(f"更新任务状态失败: {e}") 60 | raise 61 | finally: 62 | db.close() 63 | 64 | 65 | def get_all_tasks(limit: int = 50): 66 | """获取所有任务""" 67 | db = SessionLocal() 68 | try: 69 | return db.query(VideoTask).order_by(VideoTask.created_at.desc()).limit(limit).all() 70 | finally: 71 | db.close() 72 | 73 | 74 | def delete_task_by_id(task_id: str) -> bool: 75 | """根据 task_id 删除任务""" 76 | db = SessionLocal() 77 | try: 78 | task = db.query(VideoTask).filter(VideoTask.task_id == task_id).first() 79 | if task: 80 | db.delete(task) 81 | db.commit() 82 | logger.info(f"任务 {task_id} 已从数据库删除") 83 | return True 84 | return False 85 | except Exception as e: 86 | db.rollback() 87 | logger.error(f"删除任务失败: {e}") 88 | raise 89 | finally: 90 | db.close() 91 | 92 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Home, Settings, Bot, Upload, Download, ChevronLeft, ChevronRight } from 'lucide-react' 3 | 4 | interface SidebarProps { 5 | activeMenu: 'home' | 'upload' | 'model' | 'settings' | 'download' 6 | onMenuChange: (menu: 'home' | 'upload' | 'model' | 'settings' | 'download') => void 7 | } 8 | 9 | export default function Sidebar({ activeMenu, onMenuChange }: SidebarProps) { 10 | const [collapsed, setCollapsed] = useState(false) 11 | 12 | const menuItems = [ 13 | { 14 | id: 'home' as const, 15 | name: '首页', 16 | icon: , 17 | }, 18 | { 19 | id: 'upload' as const, 20 | name: '上传', 21 | icon: , 22 | }, 23 | { 24 | id: 'model' as const, 25 | name: '模型配置', 26 | icon: , 27 | }, 28 | { 29 | id: 'download' as const, 30 | name: '下载', 31 | icon: , 32 | }, 33 | { 34 | id: 'settings' as const, 35 | name: '设置', 36 | icon: , 37 | }, 38 | ] 39 | 40 | return ( 41 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /frontend/icon/gemini-color.svg: -------------------------------------------------------------------------------- 1 | Gemini -------------------------------------------------------------------------------- /frontend/src/components/TranscriptViewer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { ScrollArea } from './ui/ScrollArea' 3 | 4 | interface Segment { 5 | start: number 6 | end: number 7 | text: string 8 | } 9 | 10 | interface TranscriptViewerProps { 11 | transcript?: { 12 | language?: string 13 | full_text?: string 14 | segments?: Segment[] 15 | } 16 | } 17 | 18 | export default function TranscriptViewer({ transcript }: TranscriptViewerProps) { 19 | const [activeSegment, setActiveSegment] = useState(null) 20 | 21 | const formatTime = (seconds: number): string => { 22 | const mins = Math.floor(seconds / 60) 23 | const secs = Math.floor(seconds % 60) 24 | return `${mins}:${secs.toString().padStart(2, '0')}` 25 | } 26 | 27 | if (!transcript?.segments?.length) { 28 | return ( 29 |
30 | 暂无转写内容 31 |
32 | ) 33 | } 34 | 35 | return ( 36 |
37 |
38 |

转写结果

39 | {transcript.language && ( 40 |

检测语言: {transcript.language}

41 | )} 42 |
43 | 44 |
45 |
时间
46 |
内容
47 |
48 | 49 | 50 |
51 | {transcript.segments.map((segment, index) => ( 52 |
setActiveSegment(index)} 58 | > 59 |
60 | {formatTime(segment.start)} 61 |
62 |
63 | {segment.text} 64 |
65 |
66 | ))} 67 |
68 |
69 | 70 | {transcript.segments.length > 0 && ( 71 |
72 | 共 {transcript.segments.length} 条片段 73 | 74 | 总时长:{' '} 75 | {formatTime( 76 | transcript.segments[transcript.segments.length - 1]?.end || 0 77 | )} 78 | 79 |
80 | )} 81 |
82 | ) 83 | } 84 | 85 | -------------------------------------------------------------------------------- /backend/video_note_ai.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import os 3 | from PyInstaller.utils.hooks import collect_all, copy_metadata 4 | 5 | datas = [ 6 | ('../frontend/dist', 'dist'), 7 | ] 8 | 9 | # 检查 .env 是否存在 10 | if os.path.exists('.env'): 11 | datas.append(('.env', '.')) 12 | 13 | # 收集依赖的元数据和文件 14 | binaries = [] 15 | hiddenimports = [ 16 | 'uvicorn.loops.auto', 17 | 'uvicorn.loops.asyncio', 18 | 'uvicorn.protocols.http.auto', 19 | 'uvicorn.protocols.http.h11', 20 | 'uvicorn.lifespan.on', 21 | 'uvicorn.logging', 22 | 'app.routers.note', 23 | 'app.routers.model', 24 | 'faster_whisper', 25 | 'engineio.async_drivers.asgi', 26 | 'python-multipart', 27 | ] 28 | 29 | # Collect libraries 30 | for lib in ['faster_whisper', 'ctranslate2', 'imageio_ffmpeg', 'google.generativeai', 'openai', 'pywebview']: 31 | try: 32 | tmp_ret = collect_all(lib) 33 | datas += tmp_ret[0] 34 | binaries += tmp_ret[1] 35 | hiddenimports += tmp_ret[2] 36 | except Exception as e: 37 | print(f"Warning: Could not collect {lib}: {e}") 38 | 39 | # Copy metadata for some packages that might need it 40 | def safe_copy_metadata(package_name): 41 | try: 42 | return copy_metadata(package_name) 43 | except Exception as e: 44 | print(f"Warning: Could not copy metadata for {package_name}: {e}") 45 | return [] 46 | 47 | datas += safe_copy_metadata('tqdm') 48 | # datas += safe_copy_metadata('regex') # regex might not be installed 49 | datas += safe_copy_metadata('requests') 50 | datas += safe_copy_metadata('packaging') 51 | datas += safe_copy_metadata('filelock') 52 | datas += safe_copy_metadata('numpy') 53 | datas += safe_copy_metadata('tokenizers') 54 | datas += safe_copy_metadata('huggingface-hub') 55 | datas += safe_copy_metadata('google-generativeai') 56 | datas += safe_copy_metadata('openai') 57 | 58 | a = Analysis( 59 | ['app_entry.py'], 60 | pathex=[], 61 | binaries=binaries, 62 | datas=datas, 63 | hiddenimports=hiddenimports, 64 | hookspath=[], 65 | hooksconfig={}, 66 | runtime_hooks=[], 67 | excludes=[], 68 | noarchive=False, 69 | optimize=0, 70 | ) 71 | pyz = PYZ(a.pure) 72 | 73 | exe = EXE( 74 | pyz, 75 | a.scripts, 76 | [], 77 | exclude_binaries=True, 78 | name='VideoNoteAI', 79 | debug=False, 80 | bootloader_ignore_signals=False, 81 | strip=False, 82 | upx=True, 83 | console=False, 84 | disable_windowed_traceback=False, 85 | argv_emulation=False, 86 | target_arch=None, 87 | codesign_identity=None, 88 | entitlements_file=None, 89 | ) 90 | coll = COLLECT( 91 | exe, 92 | a.binaries, 93 | a.datas, 94 | strip=False, 95 | upx=True, 96 | upx_exclude=[], 97 | name='VideoNoteAI', 98 | ) 99 | app = BUNDLE( 100 | coll, 101 | name='VideoNoteAI.app', 102 | icon='icon.icns', 103 | bundle_identifier='com.jackjia.videonoteai', 104 | ) 105 | -------------------------------------------------------------------------------- /frontend/icon/ollama.svg: -------------------------------------------------------------------------------- 1 | Ollama -------------------------------------------------------------------------------- /frontend/src/components/MainContent.tsx: -------------------------------------------------------------------------------- 1 | import { useTaskStore } from '../store/taskStore' 2 | import UploadForm from './UploadForm' 3 | import TaskList from './TaskList' 4 | import TaskSteps from './TaskSteps' 5 | import ModelConfig from './ModelConfig' 6 | import UploadPage from './UploadPage' 7 | import VideoDownloader from './VideoDownloader' 8 | 9 | interface MainContentProps { 10 | activeMenu: 'home' | 'upload' | 'model' | 'settings' 11 | } 12 | 13 | export default function MainContent({ activeMenu }: MainContentProps) { 14 | const { currentTaskId } = useTaskStore() 15 | 16 | if (activeMenu === 'upload') { 17 | return 18 | } 19 | 20 | if (activeMenu === 'model') { 21 | return 22 | } 23 | 24 | if (activeMenu === 'download') { 25 | return 26 | } 27 | 28 | if (activeMenu === 'settings') { 29 | return ( 30 |
31 |
32 | 33 |

设置

34 |

设置功能开发中...

35 |
36 |
37 | ) 38 | } 39 | 40 | // 首页:任务列表 + 步骤 41 | return ( 42 |
43 | {/* 主内容区:任务列表 + 步骤 */} 44 |
45 | {/* 左侧:任务列表 */} 46 | 54 | 55 | {/* 右侧:步骤区域 */} 56 |
57 | {currentTaskId ? ( 58 | 59 | ) : ( 60 |
61 |
62 |
📝
63 |

选择一个任务查看详情

64 |

65 | 在左侧任务列表中点击任务来查看处理步骤 66 |

67 |
68 |
69 | )} 70 |
71 |
72 |
73 | ) 74 | } 75 | 76 | // 临时Settings图标组件 77 | function Settings({ className }: { className: string }) { 78 | return ( 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /backend/app/utils/ffmpeg_helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | FFmpeg 工具模块 3 | 自动检测系统 ffmpeg,如果没有则使用 imageio-ffmpeg 提供的 ffmpeg 4 | """ 5 | import os 6 | import shutil 7 | import platform 8 | from pathlib import Path 9 | from typing import Optional 10 | from app.utils.logger import get_logger 11 | 12 | logger = get_logger(__name__) 13 | 14 | # 项目根目录 15 | PROJECT_ROOT = Path(__file__).parent.parent.parent 16 | 17 | # 优先使用环境变量中的 FFMPEG_BIN_DIR (由 app_entry.py 设置的用户可写目录) 18 | ffmpeg_bin_env = os.getenv("FFMPEG_BIN_DIR") 19 | if ffmpeg_bin_env: 20 | FFMPEG_DIR = Path(ffmpeg_bin_env) 21 | else: 22 | FFMPEG_DIR = PROJECT_ROOT / "ffmpeg_bin" 23 | 24 | try: 25 | FFMPEG_DIR.mkdir(parents=True, exist_ok=True) 26 | except Exception as e: 27 | # 即使创建失败也继续,因为可能不需要下载 ffmpeg 28 | logger.warning(f"无法创建 ffmpeg 目录 {FFMPEG_DIR}: {e}") 29 | 30 | 31 | def get_ffmpeg_path() -> str: 32 | """ 33 | 获取 ffmpeg 可执行文件路径 34 | 优先级: 35 | 1. 系统已安装的 ffmpeg 36 | 2. 项目目录中的 ffmpeg(如果已下载) 37 | 3. imageio-ffmpeg 提供的 ffmpeg(会自动下载) 38 | 39 | :return: ffmpeg 可执行文件路径 40 | """ 41 | # 1. 首先检查系统是否已安装 ffmpeg 42 | system_ffmpeg = shutil.which("ffmpeg") 43 | if system_ffmpeg: 44 | logger.info(f"使用系统 ffmpeg: {system_ffmpeg}") 45 | return system_ffmpeg 46 | 47 | # 2. 检查项目目录中是否有 ffmpeg 48 | system = platform.system().lower() 49 | if system == "windows": 50 | ffmpeg_exe = FFMPEG_DIR / "ffmpeg.exe" 51 | else: 52 | ffmpeg_exe = FFMPEG_DIR / "ffmpeg" 53 | 54 | if ffmpeg_exe.exists() and os.access(ffmpeg_exe, os.X_OK): 55 | logger.info(f"使用项目目录中的 ffmpeg: {ffmpeg_exe}") 56 | return str(ffmpeg_exe) 57 | 58 | # 3. 使用 imageio-ffmpeg 提供的 ffmpeg 59 | try: 60 | import imageio_ffmpeg 61 | ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe() 62 | logger.info(f"使用 imageio-ffmpeg 提供的 ffmpeg: {ffmpeg_path}") 63 | 64 | # 可选:将 ffmpeg 复制到项目目录以便后续使用 65 | try: 66 | if system == "windows": 67 | target_path = FFMPEG_DIR / "ffmpeg.exe" 68 | else: 69 | target_path = FFMPEG_DIR / "ffmpeg" 70 | 71 | if not target_path.exists(): 72 | shutil.copy2(ffmpeg_path, target_path) 73 | # 在 Unix 系统上确保可执行权限 74 | if system != "windows": 75 | os.chmod(target_path, 0o755) 76 | logger.info(f"已将 ffmpeg 复制到项目目录: {target_path}") 77 | except Exception as e: 78 | logger.warning(f"复制 ffmpeg 到项目目录失败,但不影响使用: {e}") 79 | 80 | return ffmpeg_path 81 | except ImportError: 82 | logger.error("imageio-ffmpeg 未安装,请运行: pip install imageio-ffmpeg") 83 | raise ImportError( 84 | "未找到 ffmpeg。请安装 imageio-ffmpeg: pip install imageio-ffmpeg\n" 85 | "或者手动安装 ffmpeg: https://ffmpeg.org/download.html" 86 | ) 87 | except Exception as e: 88 | logger.error(f"获取 ffmpeg 失败: {e}") 89 | raise Exception(f"无法获取 ffmpeg: {e}") 90 | 91 | 92 | def check_ffmpeg_available() -> bool: 93 | """ 94 | 检查 ffmpeg 是否可用 95 | 96 | :return: True 如果可用,False 否则 97 | """ 98 | try: 99 | ffmpeg_path = get_ffmpeg_path() 100 | # 验证 ffmpeg 是否真的可用 101 | import subprocess 102 | result = subprocess.run( 103 | [ffmpeg_path, "-version"], 104 | capture_output=True, 105 | timeout=5 106 | ) 107 | return result.returncode == 0 108 | except Exception as e: 109 | logger.warning(f"ffmpeg 不可用: {e}") 110 | return False 111 | -------------------------------------------------------------------------------- /frontend/src/components/FileConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import { X, FileVideo, CheckCircle2 } from 'lucide-react' 2 | import { useState } from 'react' 3 | 4 | interface FileConfirmDialogProps { 5 | file: File | null 6 | open: boolean 7 | onConfirm: (screenshot: boolean) => void 8 | onCancel: () => void 9 | } 10 | 11 | export default function FileConfirmDialog({ 12 | file, 13 | open, 14 | onConfirm, 15 | onCancel, 16 | }: FileConfirmDialogProps) { 17 | const [enableScreenshot, setEnableScreenshot] = useState(true) 18 | 19 | if (!open || !file) return null 20 | 21 | const formatFileSize = (bytes: number) => { 22 | if (bytes < 1024) return bytes + ' B' 23 | if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB' 24 | return (bytes / (1024 * 1024)).toFixed(2) + ' MB' 25 | } 26 | 27 | return ( 28 |
29 |
30 |
31 |
32 |

确认上传文件

33 | 39 |
40 | 41 |
42 |
43 | 44 |
45 |

{file.name}

46 |
47 |

类型: {file.type || '未知'}

48 |

大小: {formatFileSize(file.size)}

49 |

修改时间: {new Date(file.lastModified).toLocaleString()}

50 |
51 |
52 |
53 | 54 |
55 |

处理流程:

56 |
    57 |
  1. 上传文件到服务器
  2. 58 |
  3. 提取音频(如果是视频文件)
  4. 59 |
  5. 转写音频为文字
  6. 60 |
  7. 使用 AI 生成结构化笔记
  8. 61 |
62 |
63 | 64 |
65 | 76 |
77 | 78 |
79 | 85 | 92 |
93 |
94 |
95 |
96 |
97 | ) 98 | } 99 | 100 | -------------------------------------------------------------------------------- /frontend/src/components/CurrentModelDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Brain } from 'lucide-react' 3 | import ProviderIcon from './ProviderIcon' 4 | 5 | interface ModelConfig { 6 | provider: string 7 | apiKey: string 8 | baseUrl?: string 9 | model: string 10 | } 11 | 12 | const PROVIDER_LABELS: Record = { 13 | openai: 'OpenAI', 14 | deepseek: 'DeepSeek', 15 | qwen: 'Qwen', 16 | claude: 'Claude', 17 | gemini: 'Gemini', 18 | groq: 'Groq', 19 | ollama: 'Ollama', 20 | } 21 | 22 | const PROVIDER_COLORS: Record = { 23 | openai: 'bg-green-100 text-green-700 border-green-200', 24 | deepseek: 'bg-blue-100 text-blue-700 border-blue-200', 25 | qwen: 'bg-purple-100 text-purple-700 border-purple-200', 26 | claude: 'bg-orange-100 text-orange-700 border-orange-200', 27 | gemini: 'bg-yellow-100 text-yellow-700 border-yellow-200', 28 | groq: 'bg-indigo-100 text-indigo-700 border-indigo-200', 29 | ollama: 'bg-teal-100 text-teal-700 border-teal-200', 30 | } 31 | 32 | export default function CurrentModelDisplay() { 33 | const [currentModel, setCurrentModel] = useState<{ 34 | name: string 35 | provider: string 36 | providerName: string 37 | } | null>(null) 38 | 39 | useEffect(() => { 40 | const loadCurrentModel = () => { 41 | try { 42 | const savedSelected = localStorage.getItem('selectedModel') 43 | 44 | if (!savedSelected) { 45 | setCurrentModel(null) 46 | return 47 | } 48 | 49 | // 解析选中的模型 ID(格式:provider-modelId) 50 | // 例如:ollama-Llama-3.1-8B-Instruct-abliterated-GGUF:Q4_K_M 51 | const firstDashIndex = savedSelected.indexOf('-') 52 | if (firstDashIndex <= 0) { 53 | setCurrentModel(null) 54 | return 55 | } 56 | 57 | const providerId = savedSelected.substring(0, firstDashIndex) 58 | const modelId = savedSelected.substring(firstDashIndex + 1) 59 | 60 | // 直接使用模型 ID 作为显示名称(去掉可能的 provider 前缀) 61 | // 如果 modelId 包含 provider 前缀(如 ollama-Llama-3.1),则去掉 62 | let modelName = modelId 63 | if (modelId.startsWith(providerId + '-')) { 64 | modelName = modelId.substring(providerId.length + 1) 65 | } 66 | 67 | setCurrentModel({ 68 | name: modelName, 69 | provider: providerId, 70 | providerName: PROVIDER_LABELS[providerId] || providerId, 71 | }) 72 | } catch (error) { 73 | console.error('加载当前模型失败:', error) 74 | setCurrentModel(null) 75 | } 76 | } 77 | 78 | loadCurrentModel() 79 | 80 | // 监听 storage 变化(同窗口内的变化不会触发 storage 事件,所以需要自定义事件) 81 | const handleStorageChange = (e: StorageEvent) => { 82 | if (e.key === 'selectedModel') { 83 | loadCurrentModel() 84 | } 85 | } 86 | window.addEventListener('storage', handleStorageChange) 87 | 88 | // 监听自定义事件(用于同窗口内的变化) 89 | const handleCustomStorageChange = () => { 90 | loadCurrentModel() 91 | } 92 | window.addEventListener('modelChanged', handleCustomStorageChange) 93 | 94 | // 定期检查配置变化(作为备用方案) 95 | const interval = setInterval(loadCurrentModel, 1000) 96 | 97 | return () => { 98 | window.removeEventListener('storage', handleStorageChange) 99 | window.removeEventListener('modelChanged', handleCustomStorageChange) 100 | clearInterval(interval) 101 | } 102 | }, []) 103 | 104 | if (!currentModel) { 105 | return ( 106 |
107 | 108 | 当前模型: 未选择 109 |
110 | ) 111 | } 112 | 113 | return ( 114 |
115 | 116 | 当前模型: 117 | {currentModel.name} 118 | {currentModel.providerName} 119 |
120 | ) 121 | } 122 | 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video AI Note 2 | 3 | 一个智能视频笔记生成工具,支持自动提取视频音频、转写文字,并使用 AI 生成结构化笔记。 4 | 5 | **完全本地化处理,保护数据隐私** - 所有数据处理均在本地完成,支持 Ollama 等本地大模型,无需联网即可使用。 6 | 7 | ## 功能特性 8 | 9 | - **完全本地化处理** - 所有数据在本地处理,不上传到云端,保护隐私安全 10 | - **支持本地大模型** - 支持 Ollama 4B 等本地模型,完全离线运行,无需 API 密钥 11 | - 直接上传视频文件(支持常见视频格式) 12 | - 自动音频转文字(使用 fast-whisper,本地运行) 13 | - AI 生成结构化笔记(支持 OpenAI/DeepSeek/Qwen/Ollama 等) 14 | - Markdown 格式输出(图片自动嵌入为 base64) 15 | - PDF 导出(支持可复制文本格式) 16 | - 视频预览功能 17 | - 任务历史记录 18 | - 多模型配置支持 19 | - 截图自动插入(可选) 20 | 21 | ### FFmpeg 说明 22 | 23 | 项目会自动处理 FFmpeg 的安装和使用: 24 | 25 | - 如果系统已安装 FFmpeg,会优先使用系统版本 26 | - 如果没有安装,首次运行时会自动下载 FFmpeg 到项目目录(`backend/ffmpeg_bin/`) 27 | - 使用 `imageio-ffmpeg` 包自动管理 FFmpeg 二进制文件 28 | 29 | 如果你想使用系统级别的 FFmpeg,可以手动安装: 30 | 31 | ```bash 32 | # Mac 33 | brew install ffmpeg 34 | 35 | # Ubuntu/Debian 36 | sudo apt install ffmpeg 37 | 38 | # Windows 39 | # 从 https://ffmpeg.org/download.html 下载安装 40 | ``` 41 | 42 | ## 安装 43 | 44 | ### 后端配置 45 | 46 | #### 方式一:使用启动脚本(推荐) 47 | 48 | 启动脚本会自动创建和激活虚拟环境: 49 | 50 | ```bash 51 | cd backend 52 | 53 | # Linux/Mac 54 | chmod +x start.sh 55 | ./start.sh 56 | 57 | # Windows 58 | start.bat 59 | ``` 60 | 61 | #### 方式二:手动配置 62 | 63 | ```bash 64 | cd backend 65 | 66 | # 创建虚拟环境 67 | python3 -m venv venv 68 | 69 | # 激活虚拟环境 70 | # Linux/Mac: 71 | source venv/bin/activate 72 | # Windows: 73 | # venv\Scripts\activate 74 | 75 | # 升级 pip 76 | pip install --upgrade pip 77 | 78 | # 安装依赖 79 | pip install -r requirements.txt 80 | ``` 81 | 82 | ### 前端配置 83 | 84 | ```bash 85 | cd frontend 86 | 87 | # 安装依赖 88 | npm install 89 | # 或 90 | pnpm install 91 | # 或 92 | yarn install 93 | ``` 94 | 95 | ## 使用 96 | 97 | ### 启动后端 98 | 99 | 如果使用启动脚本,直接运行即可。如果手动配置,需要先激活虚拟环境: 100 | 101 | ```bash 102 | # 确保虚拟环境已激活(命令行前会显示 (venv)) 103 | # 然后运行 104 | python main.py 105 | ``` 106 | 107 | **注意:每次启动前都需要激活虚拟环境!** 108 | 109 | 后端将在 `http://localhost:8483` 启动 110 | 111 | ### 启动前端 112 | 113 | ```bash 114 | cd frontend 115 | 116 | # 启动开发服务器 117 | npm run dev 118 | # 或 119 | pnpm dev 120 | ``` 121 | 122 | 前端将在 `http://localhost:5173` 启动 123 | 124 | ### 使用流程 125 | 126 | 1. 打开浏览器访问 `http://localhost:5173` 127 | 2. 在"模型配置"页面配置你的 AI 模型: 128 | - **本地运行(推荐)**:选择 Ollama,配置本地模型(如 `llama3.2:3b`、`qwen2.5:4b` 等),无需 API 密钥 129 | - **云端 API**:也可选择 OpenAI/DeepSeek/Qwen 等云端 API 130 | 3. 在"上传"页面选择视频或音频文件 131 | 4. 在任务列表中选择任务,按步骤执行: 132 | - 文件上传(可查看视频) 133 | - 提取音频 134 | - 音频转写(可查看转写结果) 135 | - 生成笔记(可查看 Markdown 笔记) 136 | 5. 下载生成的 Markdown 或 PDF 文件 137 | 138 | ### 本地运行配置(Ollama) 139 | 140 | 如需完全离线运行,推荐使用 Ollama 本地模型: 141 | 142 | 1. **安装 Ollama**(如果尚未安装): 143 | 144 | ```bash 145 | # Mac/Linux 146 | curl -fsSL https://ollama.com/install.sh | sh 147 | 148 | # Windows 149 | # 从 https://ollama.com/download 下载安装 150 | ``` 151 | 2. **下载模型**(推荐 4B 参数模型,性能与速度平衡): 152 | 153 | ```bash 154 | # 下载 4B 模型示例 155 | ollama pull llama3.2:3b 156 | # 或 157 | ollama pull qwen2.5:4b 158 | # 或 159 | ollama pull phi3:mini 160 | ``` 161 | 3. **在模型配置中选择 Ollama**: 162 | 163 | - 模型类型选择 "Ollama" 164 | - 模型名称填写你下载的模型(如 `llama3.2:3b`) 165 | - API 地址默认为 `http://localhost:11434`(Ollama 默认端口) 166 | 167 | **优势**: 168 | 169 | - 完全离线运行,无需网络连接 170 | - 数据不上传,保护隐私 171 | - 无需 API 密钥,无使用费用 172 | - 4B 模型在普通硬件上即可流畅运行 173 | 174 | ## 项目结构 175 | 176 | ``` 177 | video-Ai-note/ 178 | ├── backend/ # FastAPI 后端 179 | │ ├── app/ 180 | │ │ ├── routers/ # API 路由 181 | │ │ ├── services/ # 业务逻辑 182 | │ │ ├── transcriber/ # 音频转文字 183 | │ │ ├── gpt/ # GPT 集成 184 | │ │ └── db/ # 数据库 185 | │ ├── uploads/ # 上传文件存储 186 | │ ├── note_results/ # 笔记结果存储 187 | │ └── main.py 188 | └── frontend/ # React 前端 189 | └── src/ 190 | ├── components/ # 组件 191 | ├── services/ # API 服务 192 | └── store/ # 状态管理 193 | ``` 194 | 195 | ## 注意事项 196 | 197 | - **数据隐私**:所有视频、音频、转写文本和生成的笔记均存储在本地,不会上传到任何服务器 198 | - **本地运行**:使用 Ollama 等本地模型时,完全离线运行,无需网络连接和 API 密钥 199 | - 必须使用 Python 虚拟环境(推荐使用启动脚本自动管理) 200 | - FFmpeg 会自动下载到项目目录,无需手动安装 201 | - 首次运行会自动创建数据库 202 | - 上传的视频文件会保存在 `backend/uploads` 目录 203 | - 生成的笔记和截图会保存在 `backend/note_results` 目录 204 | - FFmpeg 二进制文件会保存在 `backend/ffmpeg_bin/` 目录(自动创建) 205 | - 详细虚拟环境使用指南请查看 [VENV_GUIDE.md](backend/VENV_GUIDE.md) 206 | -------------------------------------------------------------------------------- /frontend/src/components/StepProgress.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle2, Circle, Loader2, XCircle, Play } from 'lucide-react' 2 | 3 | export type StepStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'waiting_confirm' 4 | 5 | interface Step { 6 | id: string 7 | name: string 8 | description: string 9 | status: StepStatus 10 | canConfirm?: boolean 11 | onConfirm?: () => void 12 | result?: any 13 | onClick?: () => void 14 | } 15 | 16 | interface StepProgressProps { 17 | steps: Step[] 18 | currentStep?: number 19 | } 20 | 21 | export default function StepProgress({ steps, currentStep }: StepProgressProps) { 22 | const getStepIcon = (status: StepStatus) => { 23 | switch (status) { 24 | case 'completed': 25 | return 26 | case 'processing': 27 | return 28 | case 'failed': 29 | return 30 | case 'waiting_confirm': 31 | return 32 | default: 33 | return 34 | } 35 | } 36 | 37 | const getStepColor = (status: StepStatus, isCurrent: boolean) => { 38 | if (isCurrent && status === 'processing') return 'border-blue-500 bg-blue-50' 39 | if (status === 'completed') return 'border-green-500 bg-green-50' 40 | if (status === 'failed') return 'border-red-500 bg-red-50' 41 | if (status === 'waiting_confirm') return 'border-orange-500 bg-orange-50' 42 | return 'border-gray-300 bg-white' 43 | } 44 | 45 | return ( 46 |
47 | {steps.map((step, index) => { 48 | const isCurrent = currentStep === index 49 | const isActive = step.status === 'processing' || isCurrent 50 | 51 | return ( 52 |
60 |
61 |
{getStepIcon(step.status)}
62 |
63 |
64 |

69 | {index + 1}. {step.name} 70 |

71 | {step.status === 'processing' && ( 72 | 73 | 进行中... 74 | 75 | )} 76 | {step.status === 'waiting_confirm' && ( 77 | 78 | 等待确认 79 | 80 | )} 81 |
82 |

{step.description}

83 | 84 | {/* 显示结果 */} 85 | {step.result && step.status === 'completed' && ( 86 |
87 | {step.result} 88 |
89 | )} 90 | 91 | {/* 确认按钮 */} 92 | {step.canConfirm && step.status === 'waiting_confirm' && step.onConfirm && ( 93 | 100 | )} 101 |
102 |
103 |
104 | ) 105 | })} 106 |
107 | ) 108 | } 109 | 110 | 111 | -------------------------------------------------------------------------------- /frontend/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | // 使用相对路径,通过 Vite 代理访问后端 4 | // 开发环境:通过 vite.config.ts 中的 proxy 配置代理到 http://localhost:8483 5 | // 生产环境:可以设置 VITE_API_BASE_URL 环境变量 6 | const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api' 7 | 8 | const api = axios.create({ 9 | baseURL: API_BASE_URL, 10 | timeout: 60000, // 增加超时时间,因为文件上传和转写可能需要较长时间 11 | }) 12 | 13 | // 上传视频文件 14 | export const uploadVideo = async ( 15 | file: File, 16 | screenshot: boolean = false, 17 | modelConfig: { 18 | provider: string 19 | api_key: string 20 | base_url?: string 21 | model: string 22 | } | null = null, 23 | onProgress?: (progress: number) => void 24 | ) => { 25 | const formData = new FormData() 26 | formData.append('file', file) 27 | formData.append('screenshot', screenshot.toString()) 28 | 29 | // 如果提供了模型配置,添加到请求中 30 | if (modelConfig) { 31 | formData.append('model_config', JSON.stringify(modelConfig)) 32 | } 33 | 34 | const response = await api.post('/upload', formData, { 35 | headers: { 36 | 'Content-Type': 'multipart/form-data', 37 | }, 38 | timeout: 300000, // 5分钟超时,适合大文件上传 39 | onUploadProgress: (progressEvent) => { 40 | if (progressEvent.total && onProgress) { 41 | const percentCompleted = Math.round( 42 | (progressEvent.loaded * 100) / progressEvent.total 43 | ) 44 | onProgress(percentCompleted) 45 | } 46 | }, 47 | }) 48 | 49 | return response 50 | } 51 | 52 | // 通过后端解析并下载哔哩哔哩视频 53 | export const downloadBilibili = async (url: string, cookie: string = '', quality: string = 'best') => { 54 | return await api.post('/download/bilibili', { 55 | url, 56 | cookie, 57 | quality, 58 | }) 59 | } 60 | 61 | // 启动 B 站扫码登录,返回 session_id 与二维码 base64 62 | export const startBilibiliLogin = async () => { 63 | return await api.post('/download/bilibili/start_login') 64 | } 65 | 66 | // 查询扫码登录状态 67 | export const getBilibiliLoginStatus = async (sessionId: string) => { 68 | return await api.get(`/download/bilibili/login_status?session_id=${sessionId}`) 69 | } 70 | 71 | // (start/login_status 接口由上方函数提供) 72 | 73 | // 查询后台合并任务状态 74 | export const getBilibiliTaskStatus = async (taskId: string) => { 75 | return await api.get(`/download/bilibili/task_status?task_id=${taskId}`) 76 | } 77 | 78 | // 获取任务状态 79 | export const getTaskStatus = async (taskId: string) => { 80 | return await api.get(`/task/${taskId}`) 81 | } 82 | 83 | // 获取任务列表 84 | export const getTasks = async (limit: number = 50) => { 85 | return await api.get(`/tasks?limit=${limit}`) 86 | } 87 | 88 | // 确认步骤 89 | export const confirmStep = async (taskId: string, step: string) => { 90 | return await api.post(`/task/${taskId}/confirm_step`, { step }) 91 | } 92 | 93 | // 重新生成笔记 94 | export const regenerateNote = async (taskId: string) => { 95 | // 获取当前选择的模型配置 96 | const selectedModelId = localStorage.getItem('selectedModel') 97 | const modelConfigs = localStorage.getItem('modelConfigs') 98 | 99 | let modelConfig = null 100 | if (selectedModelId && modelConfigs) { 101 | try { 102 | const configs = JSON.parse(modelConfigs) 103 | // 从 selectedModelId 中提取 provider 和 modelId 104 | const firstDashIndex = selectedModelId.indexOf('-') 105 | if (firstDashIndex > 0) { 106 | const provider = selectedModelId.substring(0, firstDashIndex) 107 | const modelId = selectedModelId.substring(firstDashIndex + 1) 108 | const providerConfig = configs[provider] 109 | 110 | if (providerConfig) { 111 | modelConfig = { 112 | provider, 113 | api_key: providerConfig.apiKey || '', 114 | base_url: providerConfig.baseUrl || '', 115 | model: modelId, 116 | } 117 | console.log('重新生成时使用的模型配置:', modelConfig) 118 | } 119 | } 120 | } catch (e) { 121 | console.error('解析模型配置失败:', e) 122 | } 123 | } 124 | 125 | // 将模型配置作为请求体传递(使用驼峰命名) 126 | return await api.post(`/task/${taskId}/regenerate`, { 127 | modelConfig: modelConfig 128 | }) 129 | } 130 | 131 | // 获取模型列表 132 | export const getModelList = async (config: { 133 | provider: string 134 | api_key: string 135 | base_url?: string 136 | }) => { 137 | return await api.post('/models/list', config) 138 | } 139 | 140 | // 测试模型连接 141 | export const testModelConnection = async (config: { 142 | provider: string 143 | api_key: string 144 | base_url?: string 145 | }) => { 146 | return await api.post('/models/test', config) 147 | } 148 | 149 | // 获取提供商列表 150 | export const getProviders = async () => { 151 | return await api.get('/providers') 152 | } 153 | 154 | // 删除任务 155 | export const deleteTask = async (taskId: string) => { 156 | return await api.delete(`/task/${taskId}`) 157 | } 158 | 159 | // 导出 PDF(可复制文本) 160 | export const exportPDF = async (taskId: string) => { 161 | const response = await api.get(`/task/${taskId}/export_pdf`, { 162 | responseType: 'blob', 163 | }) 164 | return response 165 | } 166 | 167 | -------------------------------------------------------------------------------- /backend/app_entry.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uvicorn 4 | import multiprocessing 5 | import imageio_ffmpeg 6 | from pathlib import Path 7 | from fastapi.staticfiles import StaticFiles 8 | from fastapi.responses import FileResponse 9 | from fastapi import Request 10 | import socket 11 | import threading 12 | import webview 13 | import time 14 | 15 | # --- 关键修复:设置用户可写的数据目录 --- 16 | try: 17 | user_home = Path.home() 18 | app_data_dir = user_home / "Documents" / "VideoNoteAI_Data" 19 | app_data_dir.mkdir(parents=True, exist_ok=True) 20 | 21 | os.environ["UPLOAD_DIR"] = str(app_data_dir / "uploads") 22 | os.environ["NOTE_OUTPUT_DIR"] = str(app_data_dir / "note_results") 23 | os.environ["STATIC_DIR"] = str(app_data_dir / "static") 24 | os.environ["FFMPEG_BIN_DIR"] = str(app_data_dir / "ffmpeg_bin") 25 | os.environ["DATABASE_URL"] = f"sqlite:///{app_data_dir}/video_note.db" 26 | os.environ["HF_HOME"] = str(app_data_dir / "cache" / "huggingface") 27 | 28 | (app_data_dir / "uploads").mkdir(exist_ok=True) 29 | (app_data_dir / "ffmpeg_bin").mkdir(exist_ok=True) 30 | (app_data_dir / "note_results").mkdir(exist_ok=True) 31 | (app_data_dir / "static").mkdir(exist_ok=True) 32 | (app_data_dir / "cache").mkdir(exist_ok=True) 33 | 34 | log_file = app_data_dir / "app_debug.log" 35 | sys.stdout = open(log_file, "a", encoding="utf-8", buffering=1) 36 | sys.stderr = open(log_file, "a", encoding="utf-8", buffering=1) 37 | 38 | print(f"\n{'='*50}") 39 | print(f"Application starting at {os.environ.get('Current_Time', '')}") 40 | print(f"Data directory: {app_data_dir}") 41 | except Exception as e: 42 | pass 43 | 44 | # --- End of Fix --- 45 | 46 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 47 | 48 | try: 49 | from main import app, logger 50 | except Exception as e: 51 | print(f"Critical error importing main: {e}") 52 | import traceback 53 | traceback.print_exc() 54 | sys.exit(1) 55 | 56 | def get_resource_path(relative_path): 57 | if hasattr(sys, '_MEIPASS'): 58 | return os.path.join(sys._MEIPASS, relative_path) 59 | return os.path.join(os.path.abspath("."), relative_path) 60 | 61 | try: 62 | ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() 63 | print(f"Found ffmpeg at: {ffmpeg_exe}") 64 | ffmpeg_dir = os.path.dirname(ffmpeg_exe) 65 | os.environ["PATH"] = ffmpeg_dir + os.pathsep + os.environ["PATH"] 66 | except Exception as e: 67 | print(f"Failed to setup ffmpeg path: {e}") 68 | 69 | frontend_dist = get_resource_path("dist") 70 | 71 | if os.path.exists(frontend_dist): 72 | logger.info(f"Mounting frontend from {frontend_dist}") 73 | 74 | assets_dir = os.path.join(frontend_dist, "assets") 75 | if os.path.exists(assets_dir): 76 | app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") 77 | 78 | @app.middleware("http") 79 | async def spa_fallback(request: Request, call_next): 80 | response = await call_next(request) 81 | if response.status_code == 404 and not request.url.path.startswith("/api"): 82 | file_path = os.path.join(frontend_dist, request.url.path.lstrip("/")) 83 | if os.path.exists(file_path) and os.path.isfile(file_path): 84 | return FileResponse(file_path) 85 | index_path = os.path.join(frontend_dist, "index.html") 86 | if os.path.exists(index_path): 87 | return FileResponse(index_path) 88 | return response 89 | else: 90 | logger.warning(f"Frontend dist directory not found at {frontend_dist}") 91 | 92 | class ServerThread(threading.Thread): 93 | def __init__(self, app, host, port): 94 | super().__init__() 95 | self.server = uvicorn.Server(config=uvicorn.Config( 96 | app=app, 97 | host=host, 98 | port=port, 99 | log_level="info", 100 | loop="asyncio" 101 | )) 102 | 103 | def run(self): 104 | self.server.run() 105 | 106 | def stop(self): 107 | self.server.should_exit = True 108 | 109 | def check_port(host, port): 110 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 111 | return s.connect_ex((host, port)) == 0 112 | 113 | def main(): 114 | multiprocessing.freeze_support() 115 | 116 | port = int(os.getenv("BACKEND_PORT", 8483)) 117 | host = "127.0.0.1" 118 | 119 | url = f"http://localhost:{port}" 120 | print(f"Starting server at {url}") 121 | 122 | # 启动服务器线程 123 | server_thread = ServerThread(app, host, port) 124 | server_thread.start() 125 | 126 | # 等待服务器启动 127 | # 虽然 pywebview 会一直加载,但最好确保端口可达 128 | # 不过为了响应速度,我们可以直接打开窗口 129 | 130 | # 创建 PyWebView 窗口 131 | window = webview.create_window( 132 | title='Video Note AI', 133 | url=url, 134 | width=1280, 135 | height=800, 136 | resizable=True 137 | ) 138 | 139 | # 启动 GUI 循环 140 | # 这会阻塞主线程,直到窗口关闭 141 | webview.start(debug=False) 142 | 143 | # 窗口关闭后清理 144 | print("Stopping server...") 145 | if server_thread.is_alive(): 146 | server_thread.stop() 147 | server_thread.join(timeout=3) 148 | sys.exit(0) 149 | 150 | if __name__ == "__main__": 151 | main() 152 | -------------------------------------------------------------------------------- /frontend/src/components/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react' 2 | import { CheckCircle2, XCircle, Loader2, Clock, Trash2 } from 'lucide-react' 3 | import { useTaskStore } from '../store/taskStore' 4 | import { getTasks, deleteTask } from '../services/api' 5 | import toast from 'react-hot-toast' 6 | 7 | const statusIcons = { 8 | completed: , 9 | failed: , 10 | pending: , 11 | processing: , 12 | transcribing: , 13 | summarizing: , 14 | } 15 | 16 | const statusText = { 17 | pending: '等待中', 18 | processing: '处理中', 19 | transcribing: '转写中', 20 | summarizing: '生成中', 21 | completed: '已完成', 22 | failed: '失败', 23 | } 24 | 25 | export default function TaskList() { 26 | const { tasks, currentTaskId, setCurrentTask, loadTasks, removeTask } = useTaskStore() 27 | const [deletingIds, setDeletingIds] = useState>(new Set()) 28 | const tasksLoadedRef = useRef(false) 29 | 30 | // 加载任务列表 31 | useEffect(() => { 32 | // 防止重复加载 33 | if (tasksLoadedRef.current) { 34 | return 35 | } 36 | 37 | const loadTaskList = async () => { 38 | tasksLoadedRef.current = true 39 | try { 40 | const response = await getTasks() 41 | if (response.data.code === 200) { 42 | const taskList = response.data.data.map((task: any) => ({ 43 | id: task.task_id, 44 | filename: task.filename, 45 | status: task.status, 46 | markdown: task.markdown, 47 | createdAt: task.created_at, 48 | })) 49 | loadTasks(taskList) 50 | } 51 | } catch (error: any) { 52 | console.error('加载任务列表失败:', error) 53 | // 如果是连接错误,不显示错误(可能是后端未启动) 54 | if (error.code !== 'ECONNREFUSED' && error.code !== 'ERR_CONNECTION_TIMED_OUT') { 55 | console.warn('无法连接到后端服务,请确保后端已启动') 56 | } 57 | // 加载失败时重置标记,允许重试 58 | tasksLoadedRef.current = false 59 | } 60 | } 61 | 62 | loadTaskList() 63 | }, [loadTasks]) 64 | 65 | const handleDelete = async (taskId: string, e: React.MouseEvent) => { 66 | e.stopPropagation() // 阻止触发任务选择 67 | 68 | const task = tasks.find(t => t.id === taskId) 69 | const taskName = task?.filename || '任务' 70 | 71 | if (!window.confirm(`确定要删除 "${taskName}" 吗?\n\n删除后将无法恢复,包括:\n- 任务记录\n- 上传的文件\n- 生成的笔记和转写结果\n- 相关截图`)) { 72 | return 73 | } 74 | 75 | setDeletingIds((prev) => new Set(prev).add(taskId)) 76 | 77 | try { 78 | const response = await deleteTask(taskId) 79 | if (response.data.code === 200) { 80 | removeTask(taskId) 81 | toast.success('任务删除成功') 82 | } else { 83 | toast.error(response.data.msg || '删除失败') 84 | } 85 | } catch (error: any) { 86 | console.error('删除任务失败:', error) 87 | toast.error(error.response?.data?.msg || '删除失败,请稍后重试') 88 | } finally { 89 | setDeletingIds((prev) => { 90 | const newSet = new Set(prev) 91 | newSet.delete(taskId) 92 | return newSet 93 | }) 94 | } 95 | } 96 | 97 | return ( 98 |
99 | {tasks.length === 0 ? ( 100 |
101 |

暂无任务

102 |

上传文件后任务将显示在这里

103 |
104 | ) : ( 105 |
106 | {tasks.map((task) => ( 107 |
{ 115 | setCurrentTask(task.id) 116 | }} 117 | > 118 |
119 |
120 |

121 | {task.filename} 122 |

123 |
124 | {statusIcons[task.status]} 125 | 126 | {statusText[task.status]} 127 | 128 |
129 |
130 | 142 |
143 |
144 | ))} 145 |
146 | )} 147 |
148 | ) 149 | } 150 | -------------------------------------------------------------------------------- /backend/app/gpt/openai_gpt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from openai import OpenAI 3 | from app.gpt.base import GPT 4 | from app.models.transcriber_model import TranscriptResult 5 | from app.utils.logger import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | 10 | class OpenAIGPT(GPT): 11 | """使用 OpenAI API 生成笔记""" 12 | 13 | def __init__(self, api_key: str = None, base_url: str = None, model: str = None): 14 | """ 15 | 初始化 OpenAI GPT 16 | 17 | :param api_key: API 密钥 18 | :param base_url: API 基础 URL 19 | :param model: 模型名称 20 | """ 21 | self.api_key = api_key or os.getenv("OPENAI_API_KEY") 22 | self.base_url = base_url or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") 23 | self.model = model or os.getenv("GPT_MODEL", "gpt-4o-mini") 24 | 25 | if not self.api_key: 26 | raise ValueError("OPENAI_API_KEY 未设置") 27 | 28 | self.client = OpenAI( 29 | api_key=self.api_key, 30 | base_url=self.base_url 31 | ) 32 | 33 | logger.info(f"初始化 OpenAI GPT: model={self.model}") 34 | 35 | def summarize(self, transcript: TranscriptResult, filename: str = "", screenshot: bool = False) -> str: 36 | """生成笔记""" 37 | logger.info(f"开始生成笔记... (screenshot={screenshot})") 38 | 39 | # 构建提示词(传递 screenshot 参数) 40 | prompt = self._build_prompt(transcript, filename, screenshot) 41 | 42 | # 构建 system message 43 | system_content = "你是一个专业的笔记助手,擅长将视频转录内容整理成清晰、有条理且信息丰富的笔记。" 44 | if screenshot: 45 | system_content += "\n\n**重要**:当用户要求添加截图标记时,你必须在相关章节后插入 `*Screenshot-[mm:ss]` 格式的标记。这是强制要求,不能忽略。" 46 | 47 | # 调用 API 48 | try: 49 | response = self.client.chat.completions.create( 50 | model=self.model, 51 | messages=[ 52 | {"role": "system", "content": system_content}, 53 | {"role": "user", "content": prompt} 54 | ], 55 | temperature=0.7, 56 | ) 57 | 58 | markdown = response.choices[0].message.content.strip() 59 | logger.info("笔记生成完成") 60 | 61 | # 如果启用了截图,检查是否包含截图标记 62 | if screenshot: 63 | import re 64 | pattern = r"\*Screenshot-\[(\d{2}):(\d{2})\]|\*Screenshot-(\d{2}):(\d{2})" 65 | matches = list(re.finditer(pattern, markdown)) 66 | if matches: 67 | logger.info(f"✓ 笔记中包含 {len(matches)} 个截图标记") 68 | else: 69 | logger.warning("⚠ 警告:启用了截图功能,但生成的笔记中没有找到截图标记!") 70 | 71 | return markdown 72 | 73 | except Exception as e: 74 | logger.error(f"生成笔记失败: {e}") 75 | raise 76 | 77 | def _build_prompt(self, transcript: TranscriptResult, filename: str, screenshot: bool = False) -> str: 78 | """构建提示词""" 79 | # 构建分段文本 80 | segment_text = "" 81 | for seg in transcript.segments: 82 | mm = int(seg.start // 60) 83 | ss = int(seg.start % 60) 84 | segment_text += f"[{mm:02d}:{ss:02d}] {seg.text}\n" 85 | 86 | screenshot_instruction = "" 87 | if screenshot: 88 | screenshot_instruction = """ 89 | 90 | 8. **截图占位符(强制要求)**: 91 | 92 | ⚠️ **这是强制要求,不能忽略!** ⚠️ 93 | 94 | 你**必须**在笔记中插入至少 2-5 个截图标记。截图标记应该插入在以下类型的章节后面: 95 | - **视觉演示**:展示界面、效果、结果、画面内容 96 | - **代码演示**:显示代码、配置、参数设置 97 | - **UI 交互**:操作步骤、界面变化、点击操作 98 | - **对比效果**:前后对比、不同参数的效果 99 | - **关键操作**:重要步骤、关键设置、重要时刻 100 | - **人物介绍**:嘉宾出场、重要人物介绍 101 | - **场景切换**:重要场景、关键转折点 102 | 103 | **截图标记格式(严格遵循)**: 104 | ``` 105 | *Screenshot-[mm:ss] 106 | ``` 107 | - 格式必须完全一致:`*Screenshot-[mm:ss]`(注意:星号开头,方括号是必需的) 108 | - 时间戳格式:mm:ss(两位分钟:两位秒,例如 01:23 表示 1分23秒) 109 | - 时间戳应该对应该章节在视频中的开始时间 110 | 111 | **插入位置(非常重要)**: 112 | 113 | ⚠️ **位置规则**: 114 | - 截图标记必须插入在**章节的所有内容之后**,紧跟在章节的最后一行内容后面 115 | - 格式结构:**章节标题 → 章节内容(列表/段落)→ 空行 → 截图标记 → 空行 → 下一个章节** 116 | 117 | - **错误示例 1**(不要这样做 - 在标题后): 118 | ``` 119 | ### 节目开场 120 | *Screenshot-[00:30] ← ❌ 错误:在标题后,内容前 121 | - 韩立表达了参加... 122 | ``` 123 | 124 | - **错误示例 2**(不要这样做 - 在内容中间): 125 | ``` 126 | ### 节目开场 127 | - 韩立表达了参加... 128 | *Screenshot-[00:30] ← ❌ 错误:在内容中间 129 | - 继续其他内容... 130 | ``` 131 | 132 | - **正确示例**(必须这样做): 133 | ``` 134 | ### 节目开场 135 | - 韩立表达了参加由何欢宗举办的相亲节目的兴奋之情,并希望能获得新的理解与体验。 136 | - 节目气氛轻松,观众反应热烈。 137 | 138 | *Screenshot-[00:30] 139 | 140 | ### 嘉宾介绍 141 | - 三号女嘉宾与韩立的互动,提到彼此的旧识与关系。 142 | - 韩立提到自己与女嘉宾的过去,带有幽默的调侃。 143 | 144 | *Screenshot-[02:15] 145 | 146 | ### 个人故事 147 | ``` 148 | 149 | - **关键规则**: 150 | 1. 截图标记必须在章节的**所有内容都写完后**才插入 151 | 2. 截图标记前必须有**一个空行**(与内容分隔) 152 | 3. 截图标记后必须有**一个空行**(与下一个章节分隔) 153 | 4. 截图标记**不能**在章节标题后、内容中间或内容开头 154 | 5. 每个需要截图的章节,标记都必须在**该章节内容的最后** 155 | 156 | **数量要求**: 157 | - **至少插入 2-5 个截图标记** 158 | - 根据视频长度和内容复杂度,适当增加标记数量 159 | - 不要只添加一个标记,要为多个重要章节都添加标记 160 | 161 | **时间戳选择**: 162 | - 查看上面的视频分段,选择每个重要章节对应的开始时间 163 | - 例如:如果"节目介绍"章节在 [00:30] 开始,就在该章节后添加 `*Screenshot-[00:30]` 164 | - 确保时间戳与章节内容对应 165 | 166 | **重要提醒**: 167 | - 这是**强制要求**,不是可选项 168 | - 如果视频内容涉及任何视觉元素(人物、界面、场景等),都必须添加截图标记 169 | - 不要忘记添加截图标记,这是生成笔记的必需步骤 170 | """ 171 | 172 | prompt = f"""请根据以下视频转录内容生成结构化的 Markdown 笔记。 173 | 174 | 文件名称: {filename} 175 | 176 | 语言要求: 177 | - 笔记必须使用 **中文** 撰写 178 | - 专有名词、技术术语、品牌名称和人名应适当保留 **英文** 179 | 180 | 视频分段(格式:开始时间 - 内容): 181 | --- 182 | {segment_text} 183 | --- 184 | 185 | 你的任务: 186 | 根据上面的分段转录内容,生成结构化的笔记,遵循以下原则: 187 | 188 | 1. **完整信息**:记录尽可能多的相关细节,确保内容全面 189 | 2. **去除无关内容**:省略广告、填充词、问候语和不相关的言论 190 | 3. **保留关键细节**:保留重要事实、示例、结论和建议 191 | 4. **可读布局**:使用标题、列表等格式,保持段落简短 192 | 5. **数学公式**:视频中提及的数学公式必须保留,并以 LaTeX 语法形式呈现 193 | 6. 使用标题层级(##, ###)组织内容 194 | 7. 在主要章节后可以添加时间标记,格式:`*Content-[mm:ss]`{screenshot_instruction} 195 | 196 | 输出说明: 197 | - 仅返回最终的 **Markdown 内容** 198 | - **不要**将输出包裹在代码块中(例如:```markdown,```) 199 | 200 | 请在笔记末尾添加一个 **AI 总结**部分,简要总结整个视频的核心内容。 201 | 202 | 现在开始生成笔记:""" 203 | 204 | return prompt 205 | 206 | -------------------------------------------------------------------------------- /frontend/src/components/UploadForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Upload, Loader2 } from 'lucide-react' 3 | import { uploadVideo } from '../services/api' 4 | import { useTaskStore } from '../store/taskStore' 5 | import toast from 'react-hot-toast' 6 | import FileConfirmDialog from './FileConfirmDialog' 7 | 8 | // 格式化文件大小 9 | const formatFileSize = (bytes: number): string => { 10 | if (bytes === 0) return '0 B' 11 | const k = 1024 12 | const sizes = ['B', 'KB', 'MB', 'GB'] 13 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 14 | return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] 15 | } 16 | 17 | export default function UploadForm() { 18 | const [uploading, setUploading] = useState(false) 19 | const [uploadProgress, setUploadProgress] = useState(0) 20 | const [selectedFile, setSelectedFile] = useState(null) 21 | const [showConfirm, setShowConfirm] = useState(false) 22 | const { addTask } = useTaskStore() 23 | 24 | const handleFileSelect = (e: React.ChangeEvent) => { 25 | const file = e.target.files?.[0] 26 | if (!file) return 27 | 28 | // 检查文件类型(基于扩展名和 MIME 类型) 29 | const fileExtension = file.name.split('.').pop()?.toLowerCase() 30 | const allowedExtensions = ['mp4', 'avi', 'mov', 'mkv', 'webm', 'mp3', 'wav', 'm4a', 'flv', 'wmv'] 31 | 32 | const isAllowedExtension = fileExtension && allowedExtensions.includes(fileExtension) 33 | const isAllowedMime = file.type.startsWith('video/') || file.type.startsWith('audio/') 34 | 35 | if (!isAllowedExtension && !isAllowedMime) { 36 | toast.error('不支持的文件类型,请上传视频或音频文件') 37 | e.target.value = '' 38 | return 39 | } 40 | 41 | // 检查文件大小(限制 500MB) 42 | if (file.size > 500 * 1024 * 1024) { 43 | toast.error('文件大小不能超过 500MB') 44 | e.target.value = '' 45 | return 46 | } 47 | 48 | // 显示确认对话框 49 | setSelectedFile(file) 50 | setShowConfirm(true) 51 | e.target.value = '' 52 | } 53 | 54 | const handleConfirmUpload = async (screenshot: boolean) => { 55 | if (!selectedFile) return 56 | 57 | // 获取当前选择的模型配置 58 | const selectedModelId = localStorage.getItem('selectedModel') 59 | const modelConfigs = localStorage.getItem('modelConfigs') 60 | 61 | let modelConfig = null 62 | if (selectedModelId && modelConfigs) { 63 | try { 64 | const configs = JSON.parse(modelConfigs) 65 | // 从 selectedModelId 中提取 provider 和 modelId 66 | // selectedModelId 格式: "provider-modelId" 67 | // 找到第一个 '-' 的位置,前面是 provider,后面是 modelId 68 | const firstDashIndex = selectedModelId.indexOf('-') 69 | if (firstDashIndex > 0) { 70 | const provider = selectedModelId.substring(0, firstDashIndex) 71 | const modelId = selectedModelId.substring(firstDashIndex + 1) 72 | const providerConfig = configs[provider] 73 | 74 | if (providerConfig) { 75 | // 使用从 selectedModelId 解析出来的 modelId 76 | // 这是用户实际选择的模型 77 | modelConfig = { 78 | provider, 79 | api_key: providerConfig.apiKey || '', 80 | base_url: providerConfig.baseUrl || '', 81 | model: modelId, // 使用从 selectedModelId 解析的 modelId 82 | } 83 | console.log('上传时使用的模型配置:', modelConfig) 84 | } 85 | } 86 | } catch (e) { 87 | console.error('解析模型配置失败:', e) 88 | } 89 | } else { 90 | console.warn('未选择模型或模型配置不存在') 91 | } 92 | 93 | setShowConfirm(false) 94 | setUploading(true) 95 | setUploadProgress(0) 96 | 97 | try { 98 | const response = await uploadVideo( 99 | selectedFile, 100 | screenshot, 101 | modelConfig, 102 | (progress) => { 103 | setUploadProgress(progress) 104 | } 105 | ) 106 | 107 | if (response.data.code === 200) { 108 | const { task_id, filename } = response.data.data 109 | 110 | // 添加到任务列表 111 | addTask({ 112 | id: task_id, 113 | filename, 114 | status: 'pending', 115 | }) 116 | 117 | toast.success('文件上传成功!') 118 | setSelectedFile(null) 119 | setUploadProgress(0) 120 | } else { 121 | toast.error(response.data.msg || '上传失败') 122 | } 123 | } catch (error: any) { 124 | console.error('上传失败:', error) 125 | if (error.code === 'ECONNABORTED') { 126 | toast.error('上传超时,请检查网络连接或文件大小') 127 | } else { 128 | toast.error(error.response?.data?.msg || '上传失败,请稍后重试') 129 | } 130 | } finally { 131 | setUploading(false) 132 | setUploadProgress(0) 133 | } 134 | } 135 | 136 | const handleCancelUpload = () => { 137 | setShowConfirm(false) 138 | setSelectedFile(null) 139 | } 140 | 141 | return ( 142 | <> 143 |
144 | 179 |
180 | 181 | 187 | 188 | ) 189 | } 190 | -------------------------------------------------------------------------------- /frontend/src/components/VideoDownloader.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import { toast } from 'react-hot-toast' 3 | import { downloadBilibili, startBilibiliLogin, getBilibiliLoginStatus, getBilibiliTaskStatus } from '../services/api' 4 | 5 | export default function VideoDownloader() { 6 | const [url, setUrl] = useState('') 7 | const [cookie, setCookie] = useState('') 8 | const [loading, setLoading] = useState(false) 9 | const [quality, setQuality] = useState('best') 10 | const [qrBase64, setQrBase64] = useState(null) 11 | const [sessionId, setSessionId] = useState(null) 12 | const [loginInProgress, setLoginInProgress] = useState(false) 13 | const [loginFinished, setLoginFinished] = useState(false) 14 | const [autoDownloadTriggered, setAutoDownloadTriggered] = useState(false) 15 | const pollRef = useRef(null) 16 | const taskPollRef = useRef(null) 17 | 18 | const handleDownload = async () => { 19 | if (!url) { 20 | toast.error('请输入哔哩哔哩视频链接') 21 | return 22 | } 23 | 24 | setLoading(true) 25 | try { 26 | // 如果使用扫码登录并已完成,则将 cookie 设为 session: 27 | const cookieToSend = loginFinished && sessionId ? `session:${sessionId}` : cookie 28 | const resp = await downloadBilibili(url, cookieToSend, quality) 29 | // 如果返回直接 download_url,则打开;如果返回 task_id,则开始轮询任务状态 30 | if (resp.data && resp.data.download_url) { 31 | window.open(resp.data.download_url, '_blank') 32 | toast.success('已打开下载链接') 33 | } else if (resp.data && resp.data.task_id) { 34 | const taskId = resp.data.task_id 35 | toast.loading(`后台合并开始,任务 ${taskId} 已提交`) 36 | // 开始轮询任务状态 37 | taskPollRef.current = window.setInterval(async () => { 38 | try { 39 | const st = await getBilibiliTaskStatus(taskId) 40 | const data = st.data 41 | if (data) { 42 | const status = data.status 43 | const progress = data.progress || 0 44 | if (status === 'running') { 45 | toast.loading(`合并进行中:${progress}% (任务 ${taskId})`) 46 | } else if (status === 'completed') { 47 | if (taskPollRef.current) { 48 | clearInterval(taskPollRef.current) 49 | taskPollRef.current = null 50 | } 51 | toast.success(`合并完成,正在打开文件`) 52 | // 打开静态下载链接 53 | if (data.output) { 54 | window.open(data.output, '_blank') 55 | } else { 56 | toast.error('合并完成但未返回下载地址') 57 | } 58 | } else if (status === 'failed') { 59 | if (taskPollRef.current) { 60 | clearInterval(taskPollRef.current) 61 | taskPollRef.current = null 62 | } 63 | toast.error(`合并失败: ${data.error || '未知错误'}`) 64 | } 65 | } 66 | } catch (err) { 67 | console.error('task poll error', err) 68 | } 69 | }, 2000) 70 | } else if (resp.data && resp.data.message) { 71 | toast.success(resp.data.message) 72 | } else { 73 | toast.success('请求已发送,检查后端任务列表以查看进度') 74 | } 75 | } catch (e: any) { 76 | console.error('download error', e) 77 | const msg = e?.response?.data?.message || e.message || '下载请求失败' 78 | toast.error(msg) 79 | } finally { 80 | setLoading(false) 81 | } 82 | } 83 | 84 | // 启动扫码登录流程 85 | const handleStartLogin = async () => { 86 | try { 87 | setLoginInProgress(true) 88 | const resp = await startBilibiliLogin() 89 | const { session_id, qr_image_base64 } = resp.data 90 | setSessionId(session_id) 91 | setQrBase64(qr_image_base64) 92 | 93 | // 开始轮询登录状态 94 | pollRef.current = window.setInterval(async () => { 95 | try { 96 | const st = await getBilibiliLoginStatus(session_id) 97 | if (st.data && st.data.finished) { 98 | setLoginFinished(true) 99 | setLoginInProgress(false) 100 | // 自动填充 cookie 为 session:ID,方便直接下载 101 | setCookie(`session:${session_id}`) 102 | if (pollRef.current) { 103 | clearInterval(pollRef.current) 104 | pollRef.current = null 105 | } 106 | toast.success('登录成功,已自动使用该会话进行下载') 107 | } 108 | } catch (err) { 109 | console.error('login poll error', err) 110 | } 111 | }, 2000) 112 | } catch (err: any) { 113 | console.error('start login error', err) 114 | toast.error(err?.response?.data?.detail || '启动扫码登录失败') 115 | setLoginInProgress(false) 116 | } 117 | } 118 | 119 | useEffect(() => { 120 | return () => { 121 | if (pollRef.current) { 122 | clearInterval(pollRef.current) 123 | } 124 | if (taskPollRef.current) { 125 | clearInterval(taskPollRef.current) 126 | } 127 | } 128 | }, []) 129 | 130 | // 当扫码登录成功且已有视频链接时,自动发起解析下载(只触发一次) 131 | useEffect(() => { 132 | if (loginFinished && sessionId && url && !autoDownloadTriggered) { 133 | setAutoDownloadTriggered(true) 134 | toast('检测到已登录,会在 1 秒后自动开始解析并下载', { icon: '🔔' }) 135 | setTimeout(() => { 136 | handleDownload() 137 | }, 1000) 138 | } 139 | }, [loginFinished, sessionId, url, autoDownloadTriggered]) 140 | 141 | return ( 142 |
143 |
144 |

多平台视频下载(当前:哔哩哔哩)

145 |
146 |
147 | 154 | {sessionId && ( 155 | 会话:{sessionId} 156 | )} 157 |
158 | 159 | {qrBase64 && !loginFinished && ( 160 |
161 | 162 | bili-qr 163 |

请使用哔哩哔哩 App 扫码,等待页面提示登录完成。

164 |
165 | )} 166 | 167 |
168 | 169 | setUrl(e.target.value)} 172 | placeholder="例如 https://www.bilibili.com/video/BV1F7qDBeEGy/" 173 | className="w-full border rounded px-3 py-2" 174 | /> 175 |
176 | 177 |
178 | 179 |