diff --git a/src/app/api/video-file/[filename]/route.ts b/src/app/api/video-file/[filename]/route.ts new file mode 100644 index 0000000..c51d36d --- /dev/null +++ b/src/app/api/video-file/[filename]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { readVideoFile } from '@/lib/storage'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request, ctx: { params: Promise<{ filename: string }> }) { + const { filename } = await ctx.params; + const video = await readVideoFile(filename); + if (!video) return NextResponse.json({ error: 'video not found' }, { status: 404 }); + + return new Response(new Uint8Array(video.buf), { + headers: { + 'Content-Type': video.type, + 'Content-Length': String(video.buf.length), + 'Cache-Control': 'private, max-age=3600', + 'Accept-Ranges': 'bytes', + }, + }); +} diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts index 3ca5bf2..e6c326e 100644 --- a/src/app/api/video/generate/route.ts +++ b/src/app/api/video/generate/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { recordEvent } from '@/lib/auditDb'; import { generateSeedanceVideo } from '@/lib/videoProviders'; -import { loadSession, saveSession } from '@/lib/storage'; +import { loadSession, saveRemoteVideo, saveSession } from '@/lib/storage'; import { VIDEO_TEMPLATES } from '@/lib/templates'; import type { VideoGenerationRequest, VideoTask } from '@/lib/types'; @@ -14,12 +14,16 @@ export async function POST(req: Request) { recordEvent({ action: 'video.generate_started', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } }); const response = await generateSeedanceVideo(body); let task: VideoTask | undefined; + let videoUrl = response.videoUrl; if (body.sessionId && body.templateId) { const session = await loadSession(body.sessionId); if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); const template = VIDEO_TEMPLATES.find(item => item.id === body.templateId); const now = Date.now(); + if (videoUrl) { + videoUrl = await saveRemoteVideo(session.id, response.taskId || body.templateId, videoUrl); + } task = { id: `vid_${session.id}_${body.templateId}`, templateId: body.templateId, @@ -31,7 +35,7 @@ export async function POST(req: Request) { model: response.model, taskId: response.taskId, status: response.status, - videoUrl: response.videoUrl, + videoUrl, ratio: body.ratio || template?.ratio || '16:9', duration: body.duration || template?.duration || 6, submittedAt: now, @@ -46,7 +50,7 @@ export async function POST(req: Request) { } recordEvent({ action: 'video.generate_submitted', sessionId: body.sessionId, targetType: 'video', targetId: response.taskId ?? body.templateId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status, templateId: body.templateId } }); - return NextResponse.json({ ...response, task }); + return NextResponse.json({ ...response, videoUrl, task }); } catch (error) { const message = String(error); recordEvent({ action: 'video.generate_failed', sessionId: body.sessionId, targetType: 'video', targetId: body.templateId, status: 'error', provider: 'seedance', message }); diff --git a/src/app/api/video/status/[taskId]/route.ts b/src/app/api/video/status/[taskId]/route.ts index 6884724..65a643a 100644 --- a/src/app/api/video/status/[taskId]/route.ts +++ b/src/app/api/video/status/[taskId]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { recordEvent } from '@/lib/auditDb'; import { getSeedanceVideoTask } from '@/lib/videoProviders'; -import { loadSession, saveSession } from '@/lib/storage'; +import { loadSession, saveRemoteVideo, saveSession } from '@/lib/storage'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -12,16 +12,20 @@ export async function GET(req: Request, ctx: { params: Promise<{ taskId: string try { const response = await getSeedanceVideoTask(taskId); let task = undefined; + let videoUrl = response.videoUrl; if (sessionId) { const session = await loadSession(sessionId); if (session?.videoTasks?.length) { const index = session.videoTasks.findIndex(item => item.taskId === taskId); if (index >= 0) { + if (videoUrl) { + videoUrl = await saveRemoteVideo(session.id, taskId, videoUrl); + } task = { ...session.videoTasks[index], status: response.status, - videoUrl: response.videoUrl ?? session.videoTasks[index].videoUrl, + videoUrl: videoUrl ?? session.videoTasks[index].videoUrl, model: response.model, updatedAt: Date.now(), raw: response.raw, @@ -37,7 +41,7 @@ export async function GET(req: Request, ctx: { params: Promise<{ taskId: string } recordEvent({ action: 'video.status_checked', sessionId, targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } }); - return NextResponse.json({ ...response, task }); + return NextResponse.json({ ...response, videoUrl, task }); } catch (error) { const message = String(error); recordEvent({ action: 'video.status_failed', sessionId, targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message }); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 81fe3a5..2b996ff 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -12,6 +12,7 @@ const GEN_DIR = path.join(ROOT, 'generated'); const PACK_DIR = path.join(ROOT, 'packs'); const ANCHOR_DIR = path.join(ROOT, 'anchors'); const UPLOAD_DIR = path.join(ROOT, 'uploads'); +const VIDEO_DIR = path.join(ROOT, 'videos'); const EXPORT_DIR = path.join(ROOT, 'exports'); const BUCKET_DIRS = { @@ -24,7 +25,7 @@ const BUCKET_DIRS = { } as const; async function ensureDirs() { - await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true }))); + await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, VIDEO_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true }))); } export async function saveSession(s: GenSession) { @@ -70,6 +71,9 @@ export async function listSessions(): Promise { function extFromMime(mime: string): string { if (mime.includes('jpeg')) return 'jpg'; if (mime.includes('svg')) return 'svg'; + if (mime.includes('mp4')) return 'mp4'; + if (mime.includes('quicktime')) return 'mov'; + if (mime.includes('webm')) return 'webm'; if (mime.includes('png')) return 'png'; if (mime.includes('webp')) return 'webp'; return 'bin'; @@ -392,6 +396,51 @@ export async function readImageUrl(url: string): Promise<{ buf: Buffer; type: st throw new Error(`unsupported anchor image URL: ${url}`); } +function videoTypeFromFilename(filename: string) { + const ext = path.extname(filename).slice(1).toLowerCase(); + if (ext === 'webm') return 'video/webm'; + if (ext === 'mov') return 'video/quicktime'; + return 'video/mp4'; +} + +export async function saveRemoteVideo(sessionId: string, taskId: string, url: string): Promise { + if (url.startsWith('/api/video-file/')) return url; + if (!/^https?:\/\//i.test(url)) return url; + + await ensureDirs(); + const res = await fetch(url); + if (!res.ok) throw new Error(`failed to fetch generated video ${res.status}`); + + const type = res.headers.get('content-type')?.split(';')[0] || 'video/mp4'; + const fromPath = new URL(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]; + const inferredExt = (fromPath || extFromMime(type) || 'mp4').toLowerCase(); + const ext = inferredExt === 'bin' || inferredExt === 'm4v' ? 'mp4' : inferredExt; + const safeTaskId = safePart(taskId); + const filename = `${safePart(sessionId)}_${safeTaskId}.${ext}`; + const file = path.join(VIDEO_DIR, filename); + const buffer = Buffer.from(await res.arrayBuffer()); + await fs.writeFile(file, buffer); + recordEvent({ + action: 'video.saved', + sessionId, + targetType: 'video', + targetId: taskId, + status: 'ok', + metadata: { filename, bytes: buffer.length, type }, + }); + return `/api/video-file/${filename}`; +} + +export async function readVideoFile(filename: string): Promise<{ buf: Buffer; type: string } | null> { + try { + const safeFilename = path.basename(filename); + const buf = await fs.readFile(path.join(VIDEO_DIR, safeFilename)); + return { buf, type: videoTypeFromFilename(safeFilename) }; + } catch { + return null; + } +} + export async function saveRefImage(sessionId: string, idx: number, dataUrl: string): Promise { await ensureDirs(); const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);