Skip to content

Commit 81cbd42

Browse files
committed
feat(pwa): M3 Task10 - 视频转笔记(浏览器上传 + 抖音链接引导)
1 parent 5cb10cc commit 81cbd42

3 files changed

Lines changed: 208 additions & 16 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* VideoImportPanel — 课堂助手移动端视频导入入口(PWA 视频转笔记主力通道)
3+
*
4+
* @ai-context: PWA 下替代桌面端"窗口选择"的入口:①「导入视频」= 选择手机录屏/
5+
* 网课下载/抖音保存的视频 → analyzeVideoFile 上传网关 analyze-video 生成笔记
6+
* (结果已持久化 classroomNoteStore);②抖音链接引导 = 粘贴分享链接时检测
7+
* douyin.com,提示"保存到相册再导入"(方案 A,不做服务端自动解析)。
8+
* @ai-context EN: PWA replacement for the desktop window-picker: (1) import a
9+
* screen-record/course video to upload via analyzeVideoFile and generate a
10+
* note (persisted already); (2) Douyin link guide — detect douyin.com and tell
11+
* the user to "save to album then import" (plan A, no server-side scraping).
12+
*/
13+
import { useRef, useState } from 'react';
14+
import { useToast } from '@/components/ui';
15+
import { Upload, Video, Loader2 } from 'lucide-react';
16+
import { analyzeVideoFile, isDouyinUrl } from '../lib/uploadVideoAnalysis';
17+
18+
export function VideoImportPanel() {
19+
const { toast } = useToast();
20+
const fileInputRef = useRef<HTMLInputElement>(null);
21+
const [douyinUrl, setDouyinUrl] = useState('');
22+
const [uploading, setUploading] = useState(false);
23+
24+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
25+
const file = e.target.files?.[0];
26+
e.target.value = ''; // 允许重复选择同一文件
27+
if (!file) return;
28+
setUploading(true);
29+
try {
30+
await analyzeVideoFile(file);
31+
toast({ type: 'success', message: '视频笔记已生成,可在笔记中查看' });
32+
} catch {
33+
toast({ type: 'error', message: '视频分析失败,请检查网络或文件格式' });
34+
} finally {
35+
setUploading(false);
36+
}
37+
};
38+
39+
const showDouyinGuide = isDouyinUrl(douyinUrl);
40+
41+
return (
42+
<div className="space-y-2.5">
43+
<button
44+
onClick={() => fileInputRef.current?.click()}
45+
disabled={uploading}
46+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg border border-dashed border-border/60 text-text-secondary hover:text-brand-600 hover:border-brand-400 active:scale-[0.98] transition-all text-b3 disabled:opacity-60"
47+
>
48+
{uploading ? (
49+
<><Loader2 className="w-4 h-4 animate-spin" /> 分析中,约 1-3 分钟…</>
50+
) : (
51+
<><Upload className="w-4 h-4" /> 导入视频生成笔记</>
52+
)}
53+
</button>
54+
<input
55+
ref={fileInputRef}
56+
type="file"
57+
accept="video/*"
58+
className="hidden"
59+
onChange={handleFileSelect}
60+
/>
61+
62+
{/* 抖音链接引导(方案 A:检测 douyin.com → 引导保存相册导入) */}
63+
<div className="space-y-1.5">
64+
<div className="flex items-center gap-1.5 text-[11px] text-text-tertiary">
65+
<Video className="w-3.5 h-3.5" />
66+
<span>抖音视频:粘贴分享链接获取导入指引</span>
67+
</div>
68+
<input
69+
value={douyinUrl}
70+
onChange={(e) => setDouyinUrl(e.target.value)}
71+
placeholder="粘贴抖音分享链接(如 v.douyin.com/…)"
72+
className="w-full px-2.5 py-1.5 rounded-kb-md border border-border/50 text-[12px] bg-bg-elevated/50 focus:outline-none focus:border-brand-400"
73+
/>
74+
{showDouyinGuide && (
75+
<p className="text-[11px] leading-relaxed text-brand-600">
76+
在抖音 App 中点「分享 → 保存到相册」,再点上方「导入视频」选择该视频即可生成笔记
77+
</p>
78+
)}
79+
</div>
80+
</div>
81+
);
82+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* 浏览器端视频上传分析(PWA 视频转笔记主力通道)
3+
*
4+
* @ai-context: 移动端 PWA 的"视频 → AI 笔记"链路(Spec §3.2):文件选择 →
5+
* fetch multipart 上传到网关 `/api/v1/multimodal/analyze-video`(Gemini 原生
6+
* 视频 → Qwen-VL 抽帧降级,300s 超时)→ 结果持久化 classroomNoteStore。
7+
* 桌面端(Electron)走 IPC ai_video_analyze(主进程读文件),本函数仅供
8+
* PWA/浏览器环境直接上传 File 对象。附抖音链接检测:粘贴分享链接时引导
9+
* 用户"保存到相册再导入"(方案 A,不做服务端自动解析)。
10+
* @ai-context EN: mobile PWA video→note pipeline — file picker → multipart
11+
* upload to gateway analyze-video (Gemini native video with Qwen-VL frame
12+
* fallback, 300s timeout) → persist via classroomNoteStore. Desktop uses the
13+
* IPC ai_video_analyze path; this function only serves PWA/browser File upload.
14+
* Includes Douyin link detection to guide "save to album then import" (plan A).
15+
*/
16+
import { supabase } from '@/lib/auth/supabaseClient';
17+
import { requireGatewayUrl } from '@/lib/ai/config';
18+
import { classroomNoteStore } from '@/lib/storage/classroomNoteStore';
19+
import type { AnalyzeResult } from '@/lib/ai/sessionAnalyzer';
20+
21+
/** 抖音分享链接域名(v.douyin.com 短链 + www.douyin.com 长链) */
22+
const DOUYIN_HOST_RE = /(^|\.)douyin\.com$/;
23+
24+
/** 判断 URL 是否为抖音分享链接(用于粘贴框引导) */
25+
export function isDouyinUrl(url: string): boolean {
26+
try {
27+
const host = new URL(url.trim()).hostname.toLowerCase();
28+
return DOUYIN_HOST_RE.test(host);
29+
} catch {
30+
return false;
31+
}
32+
}
33+
34+
/**
35+
* 上传视频文件到网关做多模态分析并持久化笔记
36+
* @param file - 用户选择的视频文件(手机录屏/网课下载/抖音保存的视频)
37+
* @param options - 可选时长(秒)与语言
38+
*/
39+
export async function analyzeVideoFile(
40+
file: File,
41+
options?: { duration?: number; language?: string },
42+
): Promise<AnalyzeResult> {
43+
const { data: { session } } = await supabase.auth.getSession();
44+
const token = session?.access_token;
45+
const baseUrl = requireGatewayUrl();
46+
47+
const formData = new FormData();
48+
formData.append('video_file', file, file.name);
49+
if (options?.duration !== undefined) formData.append('duration', String(options.duration));
50+
if (options?.language) formData.append('language', options.language);
51+
52+
const headers = new Headers();
53+
if (token) headers.set('Authorization', `Bearer ${token}`);
54+
headers.set('X-Request-ID', crypto.randomUUID());
55+
// 不显式设置 Content-Type:由浏览器为 FormData 自动生成 multipart boundary
56+
57+
const controller = new AbortController();
58+
const timeoutId = setTimeout(() => controller.abort(), 300000); // 与网关 300s 超时对齐
59+
60+
try {
61+
const resp = await fetch(`${baseUrl}/api/v1/multimodal/analyze-video`, {
62+
method: 'POST',
63+
headers,
64+
body: formData,
65+
signal: controller.signal,
66+
});
67+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
68+
const data = await resp.json() as {
69+
content: string;
70+
keyframes_analyzed: number;
71+
model_used: string;
72+
};
73+
74+
const analyzeResult: AnalyzeResult = {
75+
content: data.content,
76+
keyframesAnalyzed: data.keyframes_analyzed,
77+
modelUsed: data.model_used,
78+
source: 'remote',
79+
};
80+
81+
// 自动持久化(Dexie,PWA/Electron 通用)
82+
try {
83+
await classroomNoteStore.create({
84+
sessionId: crypto.randomUUID(),
85+
title: `视频笔记 ${new Date().toLocaleString('zh-CN')}`,
86+
content: analyzeResult.content,
87+
keyframesAnalyzed: analyzeResult.keyframesAnalyzed,
88+
modelUsed: analyzeResult.modelUsed,
89+
sourceType: 'video',
90+
duration: options?.duration ?? 0,
91+
});
92+
} catch (e) {
93+
console.warn('[uploadVideoAnalysis] 视频笔记持久化失败:', e);
94+
}
95+
96+
return analyzeResult;
97+
} finally {
98+
clearTimeout(timeoutId);
99+
}
100+
}

‎client/src/features/classroom/pages/ClassroomPage.tsx‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { classifyAnalysisError } from '../utils/analysisErrors';
2222
import { NoteInsertDialog } from '../components/NoteInsertDialog';
2323
import { ConfirmDialog } from '../components/ConfirmDialog';
2424
import { WindowSelectCard } from '../components/WindowSelectCard';
25+
import { VideoImportPanel } from '../components/VideoImportPanel';
2526
import { PathModeSelector } from '../components/PathModeSelector';
2627
import { SettingsCollapse } from '../components/SettingsCollapse';
2728
import { SonarControls } from '../components/SonarControls';
@@ -125,21 +126,28 @@ export default function ClassroomPage() {
125126
/* 配置态:窗口/路径/模式/设置 + 底部启动按钮 */
126127
<>
127128
<div className="flex-1 min-h-0 p-3 space-y-2.5 overflow-y-auto">
128-
<WindowSelectCard
129-
windows={capture.windows}
130-
selected={capture.selectedWindow}
131-
onSelect={capture.setSelectedWindow}
132-
onRefresh={capture.refreshWindows}
133-
loading={capture.windowsLoading}
134-
/>
135-
<PathModeSelector
136-
capturePath={capture.capturePath}
137-
onPathChange={capture.setCapturePath}
138-
mode={capture.mode}
139-
onModeChange={capture.handleModeChange}
140-
/>
141-
{/* P8 视觉提取模式:写入截图 metadata.visionMode,VisionWorker 按模式提取 */}
142-
<VisionModeSelector value={capture.visionMode} onChange={capture.setVisionMode} />
129+
{window.electronAPI ? (
130+
<>
131+
<WindowSelectCard
132+
windows={capture.windows}
133+
selected={capture.selectedWindow}
134+
onSelect={capture.setSelectedWindow}
135+
onRefresh={capture.refreshWindows}
136+
loading={capture.windowsLoading}
137+
/>
138+
<PathModeSelector
139+
capturePath={capture.capturePath}
140+
onPathChange={capture.setCapturePath}
141+
mode={capture.mode}
142+
onModeChange={capture.handleModeChange}
143+
/>
144+
{/* P8 视觉提取模式:写入截图 metadata.visionMode,VisionWorker 按模式提取 */}
145+
<VisionModeSelector value={capture.visionMode} onChange={capture.setVisionMode} />
146+
</>
147+
) : (
148+
/* PWA:视频导入 + 抖音链接引导(录屏/网课/抖音视频 → AI 笔记) */
149+
<VideoImportPanel />
150+
)}
143151
<SettingsCollapse config={capture.config} onChange={capture.handleConfigChange} />
144152
</div>
145153
<div className="p-3 border-t border-border/20">
@@ -151,7 +159,9 @@ export default function ClassroomPage() {
151159
: 'bg-bg-secondary text-text-tertiary cursor-not-allowed',
152160
)}>
153161
<Radar className="w-5 h-5" strokeWidth={1.5} />
154-
{capture.gatewayStatus === 'checking' ? '检查网关中…' : '开始回声定位'}
162+
{capture.gatewayStatus === 'checking'
163+
? '检查网关中…'
164+
: window.electronAPI ? '开始回声定位' : '开始录音转写'}
155165
</button>
156166
{!capture.canStart && (
157167
<p className="mt-1.5 text-center text-[11px] text-text-tertiary">

0 commit comments

Comments
 (0)