feat: generate video
This commit is contained in:
parent
d50815fb96
commit
7b5b73fb1a
51
app/api/generate/video/route.ts
Normal file
51
app/api/generate/video/route.ts
Normal file
@ -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)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useParams, useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -12,7 +12,8 @@ import {
|
|||||||
Clipboard,
|
Clipboard,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Loader2,
|
Loader2,
|
||||||
Star
|
Star,
|
||||||
|
Video
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format, differenceInMinutes } from 'date-fns';
|
import { format, differenceInMinutes } from 'date-fns';
|
||||||
import TimelineSlider from './components/TimelineSlider';
|
import TimelineSlider from './components/TimelineSlider';
|
||||||
@ -111,6 +112,9 @@ export default function HostDetail() {
|
|||||||
const [expandedCredentials, setExpandedCredentials] = useState<string[]>([]);
|
const [expandedCredentials, setExpandedCredentials] = useState<string[]>([]);
|
||||||
const [revealedPasswords, setRevealedPasswords] = useState<string[]>([]);
|
const [revealedPasswords, setRevealedPasswords] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 生成视频相关状态
|
||||||
|
const [generatingVideo, setGeneratingVideo] = useState(false);
|
||||||
|
|
||||||
// 日历和滑块状态
|
// 日历和滑块状态
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
const [timeRange, setTimeRange] = useState({ min: 0, max: 0 });
|
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<UserGroup[]>(() => {
|
const credentialsByUser = useMemo<UserGroup[]>(() => {
|
||||||
const userMap = new Map<string, Credential[]>();
|
const userMap = new Map<string, Credential[]>();
|
||||||
@ -786,15 +816,31 @@ export default function HostDetail() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间点详情</h2>
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间点详情</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => {
|
<button
|
||||||
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
|
onClick={generateVideo}
|
||||||
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
disabled={generatingVideo}
|
||||||
}}
|
className="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm rounded focus:outline-none transition-colors"
|
||||||
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
title="生成视频"
|
||||||
>
|
>
|
||||||
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingRecords ? 'animate-spin' : ''}`} />
|
{generatingVideo ? (
|
||||||
</button>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{generatingVideo ? '生成中...' : '生成视频'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
|
||||||
|
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingRecords ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{records.length > 0 ? (
|
{records.length > 0 ? (
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const mimeToExtensionMap: Record<string, string> = {
|
|||||||
'image/jpg': 'jpg',
|
'image/jpg': 'jpg',
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'image/bmp': 'bmp',
|
'image/bmp': 'bmp',
|
||||||
|
'image/avif': 'avif',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -32,7 +32,8 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D
|
|||||||
|
|
||||||
const vBuffer = await compressImagesToAv1Video(picBuffers)
|
const vBuffer = await compressImagesToAv1Video(picBuffers)
|
||||||
|
|
||||||
storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname)
|
return vBuffer
|
||||||
|
// storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user