feat(local): 添加本地视频处理功能

- 实现本地视频上传和处理功能
- 新增 LocalDownloader 类处理本地视频
- 更新前端界面支持本地视频选择
- 添加视频封面提取和保存功能
- 优化后端路由支持本地视频上传
This commit is contained in:
思诺特
2025-04-28 13:34:09 +08:00
parent eb0a46183d
commit c65de4654f
16 changed files with 406 additions and 52 deletions

5
.gitignore vendored
View File

@@ -316,4 +316,7 @@ cython_debug/
/backend/note_results
/backend/models
/backend/.idea/*
/backend/bili_note.db
/backend/bili_note.db
/backend/uploads/*
/backend/.idea/*
/BiliNote_frontend/.idea/*

View File

@@ -35,20 +35,48 @@ import { useModelStore } from '@/store/modelStore'
import { Alert } from 'antd'
import { Textarea } from '@/components/ui/textarea.tsx'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import { uploadFile } from '@/services/upload.ts'
// ✅ 定义表单 schema
const formSchema = z.object({
video_url: z.string().url('请输入正确的视频链接'),
platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow'], {
required_error: '请选择音频质量',
}),
screenshot: z.boolean().optional(),
link: z.boolean().optional(),
model_name: z.string().nonempty('请选择模型'),
format: z.array(z.string()).default([]), // ✨ 确保默认是空数组
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
})
const formSchema = z
.object({
video_url: z.string(),
platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow'], {
required_error: '请选择音频质量',
}),
screenshot: z.boolean().optional(),
link: z.boolean().optional(),
model_name: z.string().nonempty('请选择模型'),
format: z.array(z.string()).default([]),
style: z.string().nonempty('请选择笔记生成风格'),
extras: z.string().optional(),
})
.superRefine((data, ctx) => {
const { video_url, platform } = data
if (platform === 'local') {
if (!video_url || typeof video_url !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '本地视频路径不能为空',
path: ['video_url'],
})
}
} else {
try {
const url = new URL(video_url)
if (!(url.protocol === 'http:' || url.protocol === 'https:')) {
throw new Error()
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '请输入正确的视频链接',
path: ['video_url'],
})
}
}
})
type NoteFormValues = z.infer<typeof formSchema>
const noteFormats = [
@@ -121,6 +149,7 @@ const NoteForm = () => {
extras: '', // 初始化为空字符串
},
})
const platform = form.watch('platform')
const onClose = () => {
setShowFeatureHint(false)
@@ -129,7 +158,25 @@ const NoteForm = () => {
console.log('🚀 isGenerating', getCurrentTask()?.status)
return getCurrentTask()?.status === 'PENDING'
}
const handleFileUpload = async (file: File, onSuccess: (url: string) => void) => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
const res = await uploadFile(formData)
if (res.data.code === 0) {
const uploadedUrl = res.data.data.url
console.log('✅ 上传成功', uploadedUrl)
onSuccess(uploadedUrl)
}
} catch (error) {
console.error('上传失败', error)
// 可以弹个 toast 或者提示上传失败
}
}
// TODO 修复选择其他视频平台以后再选择本地视频还可以选择 Link 的问题
const onSubmit = async (data: NoteFormValues) => {
console.log('🎯 提交内容:', data)
const payload = {
@@ -151,13 +198,16 @@ const NoteForm = () => {
}, [])
return (
<div className="flex h-full w-full flex-col overflow-hidden p-4">
<>
<ScrollArea className="sm:h-[400px] md:h-[800px]">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<div className="flex w-full items-center gap-2 py-1.5">
<Button type="submit" className="bg-primary w-full sm:w-full" disabled={isGenerating()}>
<Button
type="submit"
className="bg-primary w-full sm:w-full"
disabled={isGenerating()}
>
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isGenerating() ? '正在生成…' : '生成笔记'}
</Button>
@@ -193,7 +243,7 @@ const NoteForm = () => {
<SelectContent>
<SelectItem value="bilibili"></SelectItem>
<SelectItem value="youtube">Youtube</SelectItem>
{/*<SelectItem value="local">本地视频</SelectItem>*/}
<SelectItem value="local"></SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -208,13 +258,72 @@ const NoteForm = () => {
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="视频链接" {...field} />
{form.watch('platform') === 'local' ? (
<div className="flex flex-col gap-2">
{/* 第一行:本地路径输入框 */}
<Input placeholder="请输入本地视频路径" {...field} className="w-full" />
</div>
) : (
<Input placeholder="请输入视频网站链接" {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="video_url"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
{form.watch('platform') === 'local' ? (
<div
className="hover:border-primary flex h-40 w-full cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
onDragOver={e => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={e => {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileUpload(file, uploadedUrl => {
field.onChange(uploadedUrl)
})
}
}}
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = (e: any) => {
const file = e.target.files?.[0]
if (file) {
handleFileUpload(file, uploadedUrl => {
field.onChange(uploadedUrl)
})
}
}
input.click()
}}
>
<div className="text-center text-sm text-gray-500">
<p className="mb-2"></p>
<p className="text-xs text-gray-400"></p>
</div>
</div>
) : (
<></>
)}
</FormControl>
{/* ❗可以不要FormMessage不然重复两次报错提示 */}
</FormItem>
)}
/>
{/*<p className="text-xs text-neutral-500">*/}
{/* 支持哔哩哔哩视频链接,例如:*/}
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
@@ -366,6 +475,7 @@ const NoteForm = () => {
<label key={item.value} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(item.value)}
disabled={item.value === 'link' && platform === 'local'}
onCheckedChange={checked => {
const currentValue = field.value ?? [] // ✨ 保底是数组
if (checked) {
@@ -419,7 +529,7 @@ const NoteForm = () => {
{/* 添加一些额外的说明或功能介绍 */}
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
</div>
</>
)
}

View File

@@ -45,15 +45,25 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
onClick={() => onSelect(task.id)}
>
{/* 封面图 */}
<img
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
className="h-10 w-12 rounded-md object-cover"
/>
{task.platform === 'local' ? (
<img
src={
task.audioMeta.cover_url ? `${task.audioMeta.cover_url}` : '/placeholder.png'
}
alt="封面"
className="h-10 w-12 rounded-md object-cover"
/>
) : (
<img
src={
task.audioMeta.cover_url
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
className="h-10 w-12 rounded-md object-cover"
/>
)}
{/* 标题 + 状态 */}

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request' // 你项目里封装好的axios或者fetch
export const uploadFile = (formData: FormData) => {
return request.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}

View File

@@ -44,10 +44,10 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
## 📸 截图预览
![screenshot](./doc/image1.png)
![screenshot](./doc/image2.png)
![screenshot](./doc/image3.png)
![screenshot](./doc/image.png)
![screenshot](./doc/image4.png)
![screenshot](./doc/image5.png)
## 🚀 快速开始

View File

@@ -0,0 +1,137 @@
import os
import subprocess
from abc import ABC
from typing import Optional
from app.downloaders.base import Downloader
from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult
import os
import subprocess
from app.utils.video_helper import save_cover_to_static
class LocalDownloader(Downloader, ABC):
def __init__(self):
super().__init__()
def extract_cover(self, input_path: str, output_dir: Optional[str] = None) -> str:
"""
从本地视频文件中提取一张封面图(默认取第一帧)
:param input_path: 输入视频路径
:param output_dir: 输出目录,默认和视频同目录
:return: 提取出的封面图片路径
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if output_dir is None:
output_dir = os.path.dirname(input_path)
base_name = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(output_dir, f"{base_name}_cover.jpg")
try:
command = [
'ffmpeg',
'-i', input_path,
'-ss', '00:00:01', # 跳到视频第1秒防止黑屏
'-vframes', '1', # 只截取一帧
'-q:v', '2', # 输出质量高一点qscale2是很高
'-y', # 覆盖
output_path
]
subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
if not os.path.exists(output_path):
raise RuntimeError(f"封面图片生成失败: {output_path}")
return output_path
except subprocess.CalledProcessError as e:
raise RuntimeError(f"提取封面失败: {output_path}") from e
def convert_to_mp3(self,input_path: str, output_path: str = None) -> str:
"""
将本地视频文件转为 MP3 音频文件
:param input_path: 输入文件路径(如 .mp4
:param output_path: 输出文件路径(可选,默认同目录同名 .mp3
:return: 生成的 mp3 文件路径
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if output_path is None:
base, _ = os.path.splitext(input_path)
output_path = base + ".mp3"
try:
# 调用 ffmpeg 转换
command = [
'ffmpeg',
'-i', input_path,
'-vn', # 不要视频流
'-acodec', 'libmp3lame', # 使用mp3编码
'-y', # 覆盖输出文件
output_path
]
subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
if not os.path.exists(output_path):
raise RuntimeError(f"mp3 文件生成失败: {output_path}")
return output_path
except subprocess.CalledProcessError as e:
raise RuntimeError(f"mp3 文件生成失败: {output_path}") from e
def download_video(self, video_url: str, output_dir: str = None) -> str:
"""
处理本地文件路径,返回视频文件路径
"""
if video_url.startswith('/uploads'):
project_root = os.getcwd()
video_url = os.path.join(project_root, video_url.lstrip('/'))
video_url = os.path.normpath(video_url)
if not os.path.exists(video_url):
raise FileNotFoundError()
return video_url
def download(
self,
video_url: str,
output_dir: str = None,
quality: DownloadQuality = "fast",
need_video: Optional[bool] = False
) -> AudioDownloadResult:
"""
处理本地文件路径,返回音频元信息
"""
if video_url.startswith('/uploads'):
project_root = os.getcwd()
video_url = os.path.join(project_root, video_url.lstrip('/'))
video_url = os.path.normpath(video_url)
if not os.path.exists(video_url):
raise FileNotFoundError(f"本地文件不存在: {video_url}")
file_name = os.path.basename(video_url)
title, _ = os.path.splitext(file_name)
print(title, file_name,video_url)
file_path=self.convert_to_mp3(video_url)
cover_path = self.extract_cover(video_url)
cover_url = save_cover_to_static(cover_path)
print('file——path',file_path)
return AudioDownloadResult(
file_path=file_path,
title=title,
duration=0, # 可选:后续加上读取时长
cover_url=cover_url, # 暂无封面
platform="local",
video_id=title,
raw_info={
'path': file_path
},
video_path=None
)

View File

@@ -27,10 +27,10 @@ BASE_PROMPT = '''
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
2. **清晰结构**:用合适的标题级别(`##``###`)整理内容,概述每个部分的要点。
2. **清晰结构**:用合适的标题级别(`##``###`)整理内容,概述每个部分的要点。(如果额外重要的任务有格式需求可以不遵守)
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
4. **保留关键细节**:保留重要事实、示例、结论和建议。
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
额外重要的任务如下(每一个都必须严格完成):

View File

@@ -61,8 +61,24 @@ def get_style_format(style):
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
'xiaohongshu': '''4. **小红书风格**:
### 擅长使用下面的爆款关键词:
好用到哭,大数据,教科书般,小白必看,宝藏,绝绝子神器,都给我冲,划重点笑不活了YYDS秘方我不允许压箱底建议收藏停止摆烂上天在提醒你挑战全网手把手揭秘普通女生沉浸式有手就能做吹爆好用哭了搞钱必看狠狠搞钱打工人吐血整理家人们隐藏高级感治愈破防了万万没想到爆款永远可以相信被夸爆手残党必备正确姿势
### 采用二极管标题法创作标题:
- 正面刺激法:产品或方法+只需1秒 (短期)+便可开挂(逆天效果)
- 负面刺激法:你不XXX+绝对会后悔 (天大损失) +(紧迫感)
利用人们厌恶损失和负面偏误的心理
### 写作技巧
1. 使用惊叹号、省略号等标点符号增强表达力,营造紧迫感和惊喜感。
2. **使用emoji表情符号来增加文字的活力**
3. 采用具有挑战性和悬念的表述,引发读、“无敌者好奇心,例如“暴涨词汇量”了”、“拒绝焦虑”等
4. 利用正面刺激和负面激,诱发读者的本能需求和动物基本驱动力,如“离离原上谱”、“你不知道的项目其实很赚”等
5. 融入热点话题和实用工具提高文章的实用性和时效性如“2023年必知”、“chatGPT狂飙进行时”等
6. 描述具体的成果和效果强调标题中的关键词使其更具吸引力例如“英语底子再差搞清这些语法你也能拿130+”
7. 使用吸引人的标题:''',
'xiaohongshu': '4. **小红书风格**: 适合社交平台分享,亲切、口语化。',
'life_journal': '5. **生活向**: 记录个人生活感悟,情感化表达。',
'task_oriented': '6. **任务导向**: 强调任务、目标,适合工作和待办事项。',
'business': '7. **商业风格**: 适合商业报告、会议纪要,正式且精准。',

View File

@@ -3,8 +3,9 @@ import json
import os
import uuid
from typing import Optional
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File
from pydantic import BaseModel, validator
from dataclasses import asdict
@@ -46,14 +47,21 @@ class VideoRequest(BaseModel):
@validator("video_url")
def validate_supported_url(cls, v):
url = str(v)
# 支持平台校验
if not is_supported_video_url(url):
raise ValueError("暂不支持该视频平台或链接格式无效")
parsed = urlparse(url)
if parsed.scheme in ("http", "https"):
# 是网络链接,继续用原有平台校验
if not is_supported_video_url(url):
raise ValueError("暂不支持该视频平台或链接格式无效")
else:
# 是本地路径,检测一下文件是否存在
if not url.startswith('/uploads') and not os.path.exists(url):
raise ValueError("本地文件路径不存在")
return v
NOTE_OUTPUT_DIR = "note_results"
UPLOAD_DIR = "uploads"
def save_note_to_file(task_id: str, note):
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
@@ -97,6 +105,19 @@ def delete_task(data:RecordRequest):
return R.error(msg=e)
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
os.makedirs(UPLOAD_DIR, exist_ok=True)
file_location = os.path.join(UPLOAD_DIR, file.filename)
with open(file_location, "wb+") as f:
f.write(await file.read())
# 假设你静态目录挂载了 /uploads
return R.success({"url": f"/uploads/{file.filename}"})
@router.post("/generate_note")
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
try:

View File

@@ -0,0 +1,12 @@
from app.downloaders.bilibili_downloader import BilibiliDownloader
from app.downloaders.douyin_downloader import DouyinDownloader
from app.downloaders.local_downloader import LocalDownloader
from app.downloaders.youtube_downloader import YoutubeDownloader
SUPPORT_PLATFORM_MAP = {
'youtube':YoutubeDownloader(),
'bilibili':BilibiliDownloader(),
'tiktok':DouyinDownloader(),
'douyin':DouyinDownloader(),
'local':LocalDownloader()
}

View File

@@ -1,6 +1,7 @@
import json
from dataclasses import asdict
from app.downloaders.local_downloader import LocalDownloader
from app.enmus.task_status_enums import TaskStatus
import os
from typing import Union, Optional
@@ -23,6 +24,7 @@ from app.models.notes_model import NoteResult
from app.models.notes_model import AudioDownloadResult
from app.enmus.note_enums import DownloadQuality
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.services.constant import SUPPORT_PLATFORM_MAP
from app.services.provider import ProviderService
from app.transcriber.base import Transcriber
@@ -92,15 +94,10 @@ class NoteGenerator:
return gpt
def get_downloader(self, platform: str) -> Downloader:
if platform == "bilibili":
logger.info("下载 Bilibili 平台视频")
return BilibiliDownloader()
elif platform == "youtube":
logger.info("下载 YouTube 平台视频")
return YoutubeDownloader()
elif platform == 'douyin':
logger.info("下载 Douyin 平台视频")
return DouyinDownloader()
downloader =SUPPORT_PLATFORM_MAP[platform]
if downloader:
logger.info(f"使用{downloader}下载器")
return downloader
else:
logger.warning("不支持的平台")
raise ValueError(f"不支持的平台:{platform}")
@@ -217,6 +214,7 @@ class NoteGenerator:
need_video=screenshot
)
_path=audio.raw_info.get('path')
print('_path',_path)
with open(audio_cache_path, "w", encoding="utf-8") as f:
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
logger.info(f"音频下载并缓存成功task_id={task_id}")
@@ -301,9 +299,10 @@ class NoteGenerator:
# -------- 6. 完成 --------
self.update_task_status(task_id, TaskStatus.SUCCESS)
logger.info(f"✅ 笔记生成成功task_id={task_id}")
transcription_finished.send({
"file_path": audio.file_path,
})
if platform != 'local':
transcription_finished.send({
"file_path": audio.file_path,
})
return NoteResult(
markdown=markdown,
transcript=transcript,

View File

@@ -1,8 +1,15 @@
import shutil
from dotenv import load_dotenv
import subprocess
import os
import uuid
load_dotenv()
api_path = os.getenv("API_BASE_URL", "http://localhost")
BACKEND_PORT= os.getenv("BACKEND_PORT", 8000)
BACKEND_BASE_URL = f"{api_path}:{BACKEND_PORT}"
from typing import Optional
def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str:
"""
使用 ffmpeg 生成截图,返回生成图片路径
@@ -24,3 +31,30 @@ def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index:
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return output_path
def save_cover_to_static(local_cover_path: str, subfolder: Optional[str] = "cover") -> str:
"""
将封面图片保存到 static 目录下,并返回前端可访问的路径
:param local_cover_path: 本地原封面路径比如提取出来的jpg
:param subfolder: 子目录,默认是 cover可以自定义
:return: 前端访问路径,例如 /static/cover/xxx.jpg
"""
# 项目根目录
project_root = os.getcwd()
# static目录
static_dir = os.path.join(project_root, "static")
# 确定目标子目录
target_dir = os.path.join(static_dir, subfolder or "cover")
os.makedirs(target_dir, exist_ok=True)
# 拷贝文件
file_name = os.path.basename(local_cover_path)
target_path = os.path.join(target_dir, file_name)
shutil.copy2(local_cover_path, target_path) # 保留原时间戳、权限
image_relative_path = f"/static/{subfolder}/{file_name}".replace("\\", "/")
url_path = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}"
# 返回前端可访问的路径
return url_path

View File

@@ -22,15 +22,18 @@ out_dir = os.getenv('OUT_DIR', './static/screenshots')
# 自动创建本地目录static 和 static/screenshots
static_dir = "static"
uploads_dir = "uploads"
if not os.path.exists(static_dir):
os.makedirs(static_dir)
if not os.path.exists(uploads_dir):
os.makedirs(uploads_dir)
if not os.path.exists(out_dir):
os.makedirs(out_dir)
app = create_app()
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
async def startup_event():
register_handler()
@app.on_event("startup")

BIN
doc/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

BIN
doc/image4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
doc/image5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB