mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-06 20:42:52 +08:00
feat(local): 添加本地视频处理功能
- 实现本地视频上传和处理功能 - 新增 LocalDownloader 类处理本地视频 - 更新前端界面支持本地视频选择 - 添加视频封面提取和保存功能 - 优化后端路由支持本地视频上传
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/*
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 + 状态 */}
|
||||
|
||||
|
||||
9
BillNote_frontend/src/services/upload.ts
Normal file
9
BillNote_frontend/src/services/upload.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -44,10 +44,10 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
|
||||
## 📸 截图预览
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
|
||||
137
backend/app/downloaders/local_downloader.py
Normal file
137
backend/app/downloaders/local_downloader.py
Normal 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', # 输出质量高一点(qscale,2是很高)
|
||||
'-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
|
||||
)
|
||||
@@ -27,10 +27,10 @@ BASE_PROMPT = '''
|
||||
根据上面的分段转录内容,生成结构化的笔记,遵循以下原则:
|
||||
|
||||
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
||||
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。
|
||||
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。(如果额外重要的任务有格式需求可以不遵守)
|
||||
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
||||
4. **保留关键细节**:保留重要事实、示例、结论和建议。
|
||||
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。
|
||||
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
||||
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
||||
|
||||
额外重要的任务如下(每一个都必须严格完成):
|
||||
|
||||
|
||||
@@ -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. **商业风格**: 适合商业报告、会议纪要,正式且精准。',
|
||||
|
||||
@@ -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:
|
||||
|
||||
12
backend/app/services/constant.py
Normal file
12
backend/app/services/constant.py
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
BIN
doc/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 812 KiB |
BIN
doc/image4.png
Normal file
BIN
doc/image4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
BIN
doc/image5.png
Normal file
BIN
doc/image5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Reference in New Issue
Block a user