fix: archive generated videos locally
This commit is contained in:
20
src/app/api/video-file/[filename]/route.ts
Normal file
20
src/app/api/video-file/[filename]/route.ts
Normal file
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<GenSession[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
await ensureDirs();
|
||||
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
|
||||
Reference in New Issue
Block a user