mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-11 18:10:06 +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/note_results
|
||||||
/backend/models
|
/backend/models
|
||||||
/backend/.idea/*
|
/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 { Alert } from 'antd'
|
||||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||||
|
import { uploadFile } from '@/services/upload.ts'
|
||||||
// ✅ 定义表单 schema
|
// ✅ 定义表单 schema
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
video_url: z.string().url('请输入正确的视频链接'),
|
.object({
|
||||||
platform: z.string().nonempty('请选择平台'),
|
video_url: z.string(),
|
||||||
quality: z.enum(['fast', 'medium', 'slow'], {
|
platform: z.string().nonempty('请选择平台'),
|
||||||
required_error: '请选择音频质量',
|
quality: z.enum(['fast', 'medium', 'slow'], {
|
||||||
}),
|
required_error: '请选择音频质量',
|
||||||
screenshot: z.boolean().optional(),
|
}),
|
||||||
link: z.boolean().optional(),
|
screenshot: z.boolean().optional(),
|
||||||
model_name: z.string().nonempty('请选择模型'),
|
link: z.boolean().optional(),
|
||||||
format: z.array(z.string()).default([]), // ✨ 确保默认是空数组
|
model_name: z.string().nonempty('请选择模型'),
|
||||||
style: z.string().nonempty('请选择笔记生成风格'),
|
format: z.array(z.string()).default([]),
|
||||||
extras: z.string().optional(),
|
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>
|
type NoteFormValues = z.infer<typeof formSchema>
|
||||||
const noteFormats = [
|
const noteFormats = [
|
||||||
@@ -121,6 +149,7 @@ const NoteForm = () => {
|
|||||||
extras: '', // 初始化为空字符串
|
extras: '', // 初始化为空字符串
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const platform = form.watch('platform')
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setShowFeatureHint(false)
|
setShowFeatureHint(false)
|
||||||
@@ -129,7 +158,25 @@ const NoteForm = () => {
|
|||||||
console.log('🚀 isGenerating', getCurrentTask()?.status)
|
console.log('🚀 isGenerating', getCurrentTask()?.status)
|
||||||
return getCurrentTask()?.status === 'PENDING'
|
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) => {
|
const onSubmit = async (data: NoteFormValues) => {
|
||||||
console.log('🎯 提交内容:', data)
|
console.log('🎯 提交内容:', data)
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -151,13 +198,16 @@ const NoteForm = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden p-4">
|
<>
|
||||||
|
|
||||||
<ScrollArea className="sm:h-[400px] md:h-[800px]">
|
<ScrollArea className="sm:h-[400px] md:h-[800px]">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||||
<div className="flex w-full items-center gap-2 py-1.5">
|
<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() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{isGenerating() ? '正在生成…' : '生成笔记'}
|
{isGenerating() ? '正在生成…' : '生成笔记'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,7 +243,7 @@ const NoteForm = () => {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||||||
<SelectItem value="youtube">Youtube</SelectItem>
|
<SelectItem value="youtube">Youtube</SelectItem>
|
||||||
{/*<SelectItem value="local">本地视频</SelectItem>*/}
|
<SelectItem value="local">本地视频</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -208,13 +258,72 @@ const NoteForm = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">*/}
|
{/*<p className="text-xs text-neutral-500">*/}
|
||||||
{/* 支持哔哩哔哩视频链接,例如:*/}
|
{/* 支持哔哩哔哩视频链接,例如:*/}
|
||||||
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
|
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
|
||||||
@@ -366,6 +475,7 @@ const NoteForm = () => {
|
|||||||
<label key={item.value} className="flex items-center space-x-2">
|
<label key={item.value} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value?.includes(item.value)}
|
checked={field.value?.includes(item.value)}
|
||||||
|
disabled={item.value === 'link' && platform === 'local'}
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
const currentValue = field.value ?? [] // ✨ 保底是数组
|
const currentValue = field.value ?? [] // ✨ 保底是数组
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -419,7 +529,7 @@ const NoteForm = () => {
|
|||||||
{/* 添加一些额外的说明或功能介绍 */}
|
{/* 添加一些额外的说明或功能介绍 */}
|
||||||
|
|
||||||
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
|
{/*<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)}
|
onClick={() => onSelect(task.id)}
|
||||||
>
|
>
|
||||||
{/* 封面图 */}
|
{/* 封面图 */}
|
||||||
<img
|
{task.platform === 'local' ? (
|
||||||
src={
|
<img
|
||||||
task.audioMeta.cover_url
|
src={
|
||||||
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
task.audioMeta.cover_url ? `${task.audioMeta.cover_url}` : '/placeholder.png'
|
||||||
: '/placeholder.png'
|
}
|
||||||
}
|
alt="封面"
|
||||||
alt="封面"
|
className="h-10 w-12 rounded-md object-cover"
|
||||||
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. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
1. **完整信息**:记录尽可能多的相关细节,确保内容全面。
|
||||||
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。
|
2. **清晰结构**:用合适的标题级别(`##`,`###`)整理内容,概述每个部分的要点。(如果额外重要的任务有格式需求可以不遵守)
|
||||||
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
3. **去除无关内容**:省略广告、填充词、问候语和不相关的言论。
|
||||||
4. **保留关键细节**:保留重要事实、示例、结论和建议。
|
4. **保留关键细节**:保留重要事实、示例、结论和建议。(如果额外重要的任务有格式需求可以不遵守)
|
||||||
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。
|
5. **可读布局**:必要时使用项目符号,并保持段落简短,增强可读性。(如果额外重要的任务有格式需求可以不遵守)
|
||||||
|
|
||||||
额外重要的任务如下(每一个都必须严格完成):
|
额外重要的任务如下(每一个都必须严格完成):
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,24 @@ def get_style_format(style):
|
|||||||
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
|
'minimal': '1. **精简信息**: 仅记录最重要的内容,简洁明了。',
|
||||||
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
|
'detailed': '2. **详细记录**: 包含完整的时间戳和每个部分的详细讨论。',
|
||||||
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
|
'academic': '3. **学术风格**: 适合学术报告,正式且结构化。',
|
||||||
|
'xiaohongshu': '''4. **小红书风格**:
|
||||||
|
### 擅长使用下面的爆款关键词:
|
||||||
|
好用到哭,大数据,教科书般,小白必看,宝藏,绝绝子神器,都给我冲,划重点,笑不活了,YYDS,秘方,我不允许,压箱底,建议收藏,停止摆烂,上天在提醒你,挑战全网,手把手,揭秘,普通女生,沉浸式,有手就能做吹爆,好用哭了,搞钱必看,狠狠搞钱,打工人,吐血整理,家人们,隐藏,高级感,治愈,破防了,万万没想到,爆款,永远可以相信被夸爆手残党必备,正确姿势
|
||||||
|
|
||||||
|
### 采用二极管标题法创作标题:
|
||||||
|
- 正面刺激法:产品或方法+只需1秒 (短期)+便可开挂(逆天效果)
|
||||||
|
- 负面刺激法:你不XXX+绝对会后悔 (天大损失) +(紧迫感)
|
||||||
|
利用人们厌恶损失和负面偏误的心理
|
||||||
|
|
||||||
|
### 写作技巧
|
||||||
|
1. 使用惊叹号、省略号等标点符号增强表达力,营造紧迫感和惊喜感。
|
||||||
|
2. **使用emoji表情符号,来增加文字的活力**
|
||||||
|
3. 采用具有挑战性和悬念的表述,引发读、“无敌者好奇心,例如“暴涨词汇量”了”、“拒绝焦虑”等
|
||||||
|
4. 利用正面刺激和负面激,诱发读者的本能需求和动物基本驱动力,如“离离原上谱”、“你不知道的项目其实很赚”等
|
||||||
|
5. 融入热点话题和实用工具,提高文章的实用性和时效性,如“2023年必知”、“chatGPT狂飙进行时”等
|
||||||
|
6. 描述具体的成果和效果,强调标题中的关键词,使其更具吸引力,例如“英语底子再差,搞清这些语法你也能拿130+”
|
||||||
|
7. 使用吸引人的标题:''',
|
||||||
|
|
||||||
'xiaohongshu': '4. **小红书风格**: 适合社交平台分享,亲切、口语化。',
|
|
||||||
'life_journal': '5. **生活向**: 记录个人生活感悟,情感化表达。',
|
'life_journal': '5. **生活向**: 记录个人生活感悟,情感化表达。',
|
||||||
'task_oriented': '6. **任务导向**: 强调任务、目标,适合工作和待办事项。',
|
'task_oriented': '6. **任务导向**: 强调任务、目标,适合工作和待办事项。',
|
||||||
'business': '7. **商业风格**: 适合商业报告、会议纪要,正式且精准。',
|
'business': '7. **商业风格**: 适合商业报告、会议纪要,正式且精准。',
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
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 pydantic import BaseModel, validator
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
@@ -46,14 +47,21 @@ class VideoRequest(BaseModel):
|
|||||||
@validator("video_url")
|
@validator("video_url")
|
||||||
def validate_supported_url(cls, v):
|
def validate_supported_url(cls, v):
|
||||||
url = str(v)
|
url = str(v)
|
||||||
# 支持平台校验
|
parsed = urlparse(url)
|
||||||
if not is_supported_video_url(url):
|
if parsed.scheme in ("http", "https"):
|
||||||
raise ValueError("暂不支持该视频平台或链接格式无效")
|
# 是网络链接,继续用原有平台校验
|
||||||
|
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
|
return v
|
||||||
|
|
||||||
|
|
||||||
NOTE_OUTPUT_DIR = "note_results"
|
NOTE_OUTPUT_DIR = "note_results"
|
||||||
|
UPLOAD_DIR = "uploads"
|
||||||
|
|
||||||
def save_note_to_file(task_id: str, note):
|
def save_note_to_file(task_id: str, note):
|
||||||
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
|
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
|
||||||
@@ -97,6 +105,19 @@ def delete_task(data:RecordRequest):
|
|||||||
return R.error(msg=e)
|
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")
|
@router.post("/generate_note")
|
||||||
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
||||||
try:
|
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
|
import json
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from app.downloaders.local_downloader import LocalDownloader
|
||||||
from app.enmus.task_status_enums import TaskStatus
|
from app.enmus.task_status_enums import TaskStatus
|
||||||
import os
|
import os
|
||||||
from typing import Union, Optional
|
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.models.notes_model import AudioDownloadResult
|
||||||
from app.enmus.note_enums import DownloadQuality
|
from app.enmus.note_enums import DownloadQuality
|
||||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||||
|
from app.services.constant import SUPPORT_PLATFORM_MAP
|
||||||
|
|
||||||
from app.services.provider import ProviderService
|
from app.services.provider import ProviderService
|
||||||
from app.transcriber.base import Transcriber
|
from app.transcriber.base import Transcriber
|
||||||
@@ -92,15 +94,10 @@ class NoteGenerator:
|
|||||||
return gpt
|
return gpt
|
||||||
|
|
||||||
def get_downloader(self, platform: str) -> Downloader:
|
def get_downloader(self, platform: str) -> Downloader:
|
||||||
if platform == "bilibili":
|
downloader =SUPPORT_PLATFORM_MAP[platform]
|
||||||
logger.info("下载 Bilibili 平台视频")
|
if downloader:
|
||||||
return BilibiliDownloader()
|
logger.info(f"使用{downloader}下载器")
|
||||||
elif platform == "youtube":
|
return downloader
|
||||||
logger.info("下载 YouTube 平台视频")
|
|
||||||
return YoutubeDownloader()
|
|
||||||
elif platform == 'douyin':
|
|
||||||
logger.info("下载 Douyin 平台视频")
|
|
||||||
return DouyinDownloader()
|
|
||||||
else:
|
else:
|
||||||
logger.warning("不支持的平台")
|
logger.warning("不支持的平台")
|
||||||
raise ValueError(f"不支持的平台:{platform}")
|
raise ValueError(f"不支持的平台:{platform}")
|
||||||
@@ -217,6 +214,7 @@ class NoteGenerator:
|
|||||||
need_video=screenshot
|
need_video=screenshot
|
||||||
)
|
)
|
||||||
_path=audio.raw_info.get('path')
|
_path=audio.raw_info.get('path')
|
||||||
|
print('_path',_path)
|
||||||
with open(audio_cache_path, "w", encoding="utf-8") as f:
|
with open(audio_cache_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
|
json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
|
||||||
logger.info(f"音频下载并缓存成功,task_id={task_id}")
|
logger.info(f"音频下载并缓存成功,task_id={task_id}")
|
||||||
@@ -301,9 +299,10 @@ class NoteGenerator:
|
|||||||
# -------- 6. 完成 --------
|
# -------- 6. 完成 --------
|
||||||
self.update_task_status(task_id, TaskStatus.SUCCESS)
|
self.update_task_status(task_id, TaskStatus.SUCCESS)
|
||||||
logger.info(f"✅ 笔记生成成功,task_id={task_id}")
|
logger.info(f"✅ 笔记生成成功,task_id={task_id}")
|
||||||
transcription_finished.send({
|
if platform != 'local':
|
||||||
"file_path": audio.file_path,
|
transcription_finished.send({
|
||||||
})
|
"file_path": audio.file_path,
|
||||||
|
})
|
||||||
return NoteResult(
|
return NoteResult(
|
||||||
markdown=markdown,
|
markdown=markdown,
|
||||||
transcript=transcript,
|
transcript=transcript,
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import shutil
|
||||||
|
from dotenv import load_dotenv
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import uuid
|
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:
|
def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str:
|
||||||
"""
|
"""
|
||||||
使用 ffmpeg 生成截图,返回生成图片路径
|
使用 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)
|
subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
return output_path
|
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 和 static/screenshots)
|
||||||
static_dir = "static"
|
static_dir = "static"
|
||||||
|
uploads_dir = "uploads"
|
||||||
if not os.path.exists(static_dir):
|
if not os.path.exists(static_dir):
|
||||||
os.makedirs(static_dir)
|
os.makedirs(static_dir)
|
||||||
|
if not os.path.exists(uploads_dir):
|
||||||
|
os.makedirs(uploads_dir)
|
||||||
|
|
||||||
if not os.path.exists(out_dir):
|
if not os.path.exists(out_dir):
|
||||||
os.makedirs(out_dir)
|
os.makedirs(out_dir)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
|
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
|
||||||
|
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
register_handler()
|
register_handler()
|
||||||
@app.on_event("startup")
|
@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