From 7b5b73fb1abf723062bd5fcfac8df546bd4ca6eb Mon Sep 17 00:00:00 2001 From: feie9454 Date: Sat, 5 Jul 2025 14:26:57 +0800 Subject: [PATCH] feat: generate video --- app/api/generate/video/route.ts | 51 +++++++++++++++++++++++++ app/hosts/[hostname]/page.tsx | 68 +++++++++++++++++++++++++++------ lib/encodeVideo.ts | 1 + lib/schedule/compressPics.ts | 3 +- 4 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 app/api/generate/video/route.ts diff --git a/app/api/generate/video/route.ts b/app/api/generate/video/route.ts new file mode 100644 index 0000000..e095acc --- /dev/null +++ b/app/api/generate/video/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' +import { compressPics } from '@/lib/schedule/compressPics' +// 切换记录的星标状态 +async function handleGenerateVideo(req: NextRequest) { + try { + // get from query + // ?hostname=DESKTOP-JHHNH9C&startTime=1751104800&endTime=1751108400 + const { searchParams } = req.nextUrl + const hostname = searchParams.get('hostname') + const startTimeParam = searchParams.get('startTime') + const endTimeParam = searchParams.get('endTime') + if (!hostname) { + return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) + } + if (!startTimeParam) { + return NextResponse.json({ error: '缺少开始时间' }, { status: 400 }) + } + if (!endTimeParam) { + return NextResponse.json({ error: '缺少结束时间' }, { status: 400 }) + } + + // 将时间戳从秒转换为毫秒 + let startTime = new Date(parseInt(startTimeParam) * 1000) + let endTime = new Date(parseInt(endTimeParam) * 1000) + + let buffer = await compressPics(hostname, startTime, endTime) + + if (!buffer) { + return NextResponse.json({ error: '没有找到符合条件的截图' }, { status: 404 }) + } + + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'video/mp4', + 'Content-Disposition': `attachment; filename="${hostname}-${startTime.toISOString()}-${endTime.toISOString()}.mp4"`, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }) + + } catch (error) { + console.error('视频生成失败:', error) + return NextResponse.json({ error: '操作失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleGenerateVideo) \ No newline at end of file diff --git a/app/hosts/[hostname]/page.tsx b/app/hosts/[hostname]/page.tsx index 54ecc15..b956b14 100644 --- a/app/hosts/[hostname]/page.tsx +++ b/app/hosts/[hostname]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { ArrowLeft, @@ -12,7 +12,8 @@ import { Clipboard, ShieldAlert, Loader2, - Star + Star, + Video } from 'lucide-react'; import { format, differenceInMinutes } from 'date-fns'; import TimelineSlider from './components/TimelineSlider'; @@ -111,6 +112,9 @@ export default function HostDetail() { const [expandedCredentials, setExpandedCredentials] = useState([]); const [revealedPasswords, setRevealedPasswords] = useState([]); + // 生成视频相关状态 + const [generatingVideo, setGeneratingVideo] = useState(false); + // 日历和滑块状态 const [selectedDate, setSelectedDate] = useState(null); const [timeRange, setTimeRange] = useState({ min: 0, max: 0 }); @@ -314,6 +318,32 @@ export default function HostDetail() { } }; + // 生成视频 + const generateVideo = async () => { + if (!showDetailTimeline || !timeRange.min || !timeRange.max) { + alert('请先选择时间范围'); + return; + } + + try { + setGeneratingVideo(true); + + const startTime = Math.floor(timeRange.min / 1000); + const endTime = Math.floor(timeRange.max / 1000); + + const videoUrl = `/api/generate/video?hostname=${encodeURIComponent(hostname)}&startTime=${startTime}&endTime=${endTime}`; + + // 在新标签页中打开视频 + window.open(videoUrl, '_blank'); + + } catch (error) { + console.error('生成视频失败:', error); + alert('生成视频失败,请稍后重试'); + } finally { + setGeneratingVideo(false); + } + }; + // 组织凭据数据按用户分组 const credentialsByUser = useMemo(() => { const userMap = new Map(); @@ -786,15 +816,31 @@ export default function HostDetail() {

时间点详情

- +
+ + +
{records.length > 0 ? ( diff --git a/lib/encodeVideo.ts b/lib/encodeVideo.ts index 67f2635..e8e9ff5 100644 --- a/lib/encodeVideo.ts +++ b/lib/encodeVideo.ts @@ -25,6 +25,7 @@ const mimeToExtensionMap: Record = { 'image/jpg': 'jpg', 'image/webp': 'webp', 'image/bmp': 'bmp', + 'image/avif': 'avif', }; /** diff --git a/lib/schedule/compressPics.ts b/lib/schedule/compressPics.ts index f25488c..20c1de9 100644 --- a/lib/schedule/compressPics.ts +++ b/lib/schedule/compressPics.ts @@ -32,7 +32,8 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D const vBuffer = await compressImagesToAv1Video(picBuffers) - storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname) + return vBuffer + // storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname) }