import { and, desc, eq, inArray } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { getSystemSetting } from "@/lib/db/queries"; import { mediaAsset } from "@/lib/db/schema"; import { createLogger } from "@/lib/observability/logger"; import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, defaultVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import { VIDEO_CANVAS_SUBJECT_SOURCE_TYPE, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE, normalizeVideoCanvasSubjectText, resolveVideoCanvasSubjectCategory, validateVideoCanvasSubjectDraft, } from "@/lib/video-canvas/subjects"; export const runtime = "nodejs"; const logger = createLogger("video-canvas-subjects"); const createSubjectSchema = z.object({ assetIds: z.array(z.string().uuid()).min(1).max(10), category: z.string().trim().max(20), description: z.string().trim().min(1).max(1000), durationSeconds: z.number().finite().positive().max(3600).nullish(), mediaMode: z.enum(["images", "video"]), name: z.string().trim().min(1).max(100), voiceId: z.string().trim().min(1).max(160).default("none"), }); const updateSubjectSchema = createSubjectSchema.extend({ subjectId: z.string().uuid(), }); async function getSubjectLibraryConfig() { const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY, }).catch(() => null); return setting?.value ? normalizeVideoCanvasFunctionConfig(setting.value).subjectLibrary : defaultVideoCanvasFunctionConfig.subjectLibrary; } function readMetadataAssetIds(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) return []; const assetIds = (value as Record).assetIds; return Array.isArray(assetIds) ? assetIds.map((item) => String(item || "")).filter(Boolean) : []; } export async function GET(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const config = await getSubjectLibraryConfig(); const requestedCategory = new URL(request.url).searchParams.get("category"); const category = requestedCategory ? resolveVideoCanvasSubjectCategory(requestedCategory, config) : ""; const subjects = await db .select({ createdAt: mediaAsset.createdAt, description: mediaAsset.summary, displayName: mediaAsset.displayName, durationMs: mediaAsset.durationMs, id: mediaAsset.id, metadata: mediaAsset.metadata, originalUrl: mediaAsset.originalUrl, previewUrl: mediaAsset.previewUrl, thumbnailUrl: mediaAsset.thumbnailUrl, }) .from(mediaAsset) .where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.mediaType, "subject"), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE), eq(mediaAsset.status, "ready"), ), ) .orderBy(desc(mediaAsset.updatedAt)) .limit(100); const filteredSubjects = category ? subjects.filter((subject) => { const metadata = subject.metadata; return ( metadata && typeof metadata === "object" && !Array.isArray(metadata) && (metadata as Record).category === category ); }) : subjects; return NextResponse.json({ success: true, subjects: filteredSubjects }); } catch (error) { logger.error("subject list failed", { error }); return NextResponse.json( { success: false, error: "主体加载失败" }, { status: 500 }, ); } } export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const parsed = createSubjectSchema.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json( { success: false, error: parsed.error.issues[0]?.message || "请求参数无效" }, { status: 400 }, ); } const config = await getSubjectLibraryConfig(); const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config); const validationError = validateVideoCanvasSubjectDraft( { assetCount: parsed.data.assetIds.length, category, description: parsed.data.description, durationSeconds: parsed.data.durationSeconds, mediaMode: parsed.data.mediaMode, name: parsed.data.name, voiceId: parsed.data.voiceId, }, config, ); if (validationError) { return NextResponse.json( { success: false, error: validationError }, { status: config.enabled ? 400 : 403 }, ); } const uploadedAssets = await db .select({ id: mediaAsset.id, mediaType: mediaAsset.mediaType, mimeType: mediaAsset.mimeType, originalUrl: mediaAsset.originalUrl, previewUrl: mediaAsset.previewUrl, thumbnailUrl: mediaAsset.thumbnailUrl, }) .from(mediaAsset) .where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.status, "ready"), inArray(mediaAsset.id, parsed.data.assetIds), ), ); const assetById = new Map(uploadedAssets.map((asset) => [asset.id, asset])); const orderedAssets = parsed.data.assetIds.flatMap((id) => { const asset = assetById.get(id); return asset ? [asset] : []; }); if (orderedAssets.length !== parsed.data.assetIds.length) { return NextResponse.json( { success: false, error: "主体素材不存在或已失效" }, { status: 400 }, ); } const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video"; if (orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)) { return NextResponse.json( { success: false, error: "主体素材类型不一致" }, { status: 400 }, ); } let customVoiceAssetId: string | null = null; if (parsed.data.voiceId.startsWith("custom:")) { customVoiceAssetId = parsed.data.voiceId.slice("custom:".length); if (!z.string().uuid().safeParse(customVoiceAssetId).success) { return NextResponse.json( { success: false, error: "自定义音色无效" }, { status: 400 }, ); } const [voiceAsset] = await db .select({ id: mediaAsset.id }) .from(mediaAsset) .where( and( eq(mediaAsset.id, customVoiceAssetId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE), eq(mediaAsset.status, "ready"), ), ) .limit(1); if (!voiceAsset) { return NextResponse.json( { success: false, error: "自定义音色不存在或已失效" }, { status: 400 }, ); } } const primaryAsset = orderedAssets[0]; const description = normalizeVideoCanvasSubjectText( parsed.data.description, config.descriptionMaxLength, ); const name = normalizeVideoCanvasSubjectText( parsed.data.name, config.nameMaxLength, ); const voice = config.voices.find((item) => item.id === parsed.data.voiceId); const durationMs = parsed.data.mediaMode === "video" && parsed.data.durationSeconds ? Math.round(parsed.data.durationSeconds * 1000) : null; const subjectId = randomUUID(); const [subject] = await db .insert(mediaAsset) .values({ displayName: name, durationMs, id: subjectId, mediaType: "subject", metadata: { assetIds: parsed.data.assetIds, category, customVoiceAssetId, durationSeconds: parsed.data.durationSeconds || null, mediaMode: parsed.data.mediaMode, mediaUrls: orderedAssets.map((asset) => asset.originalUrl), voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""), voiceId: parsed.data.voiceId, voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"), }, mimeType: "application/x-video-canvas-subject", normalizedUrl: `video-canvas-subject://${subjectId}`, originalUrl: primaryAsset.originalUrl, previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl, sourceTable: "video_canvas_subject", sourceType: VIDEO_CANVAS_SUBJECT_SOURCE_TYPE, status: "ready", summary: { description }, thumbnailUrl: primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl, userId: session.user.id, }) .returning(); return NextResponse.json({ success: true, subject }); } catch (error) { logger.error("subject creation failed", { error }); return NextResponse.json( { success: false, error: "主体创建失败" }, { status: 500 }, ); } } export async function PATCH(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const parsed = updateSubjectSchema.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json( { success: false, error: parsed.error.issues[0]?.message || "请求参数无效" }, { status: 400 }, ); } const [existingSubject] = await db .select({ id: mediaAsset.id, metadata: mediaAsset.metadata }) .from(mediaAsset) .where( and( eq(mediaAsset.id, parsed.data.subjectId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE), ), ) .limit(1); if (!existingSubject) { return NextResponse.json( { success: false, error: "主体不存在" }, { status: 404 }, ); } const config = await getSubjectLibraryConfig(); const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config); const validationError = validateVideoCanvasSubjectDraft( { assetCount: parsed.data.assetIds.length, category, description: parsed.data.description, durationSeconds: parsed.data.durationSeconds, mediaMode: parsed.data.mediaMode, name: parsed.data.name, voiceId: parsed.data.voiceId, }, config, ); if (validationError) { return NextResponse.json( { success: false, error: validationError }, { status: config.enabled ? 400 : 403 }, ); } const ownedAssets = await db .select({ id: mediaAsset.id, mediaType: mediaAsset.mediaType, originalUrl: mediaAsset.originalUrl, previewUrl: mediaAsset.previewUrl, thumbnailUrl: mediaAsset.thumbnailUrl, }) .from(mediaAsset) .where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.status, "ready"), inArray(mediaAsset.id, parsed.data.assetIds), ), ); const assetById = new Map(ownedAssets.map((asset) => [asset.id, asset])); const orderedAssets = parsed.data.assetIds.flatMap((id) => { const asset = assetById.get(id); return asset ? [asset] : []; }); const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video"; if ( orderedAssets.length !== parsed.data.assetIds.length || orderedAssets.some((asset) => asset.mediaType !== expectedMediaType) ) { return NextResponse.json( { success: false, error: "主体素材不存在、已失效或类型不一致" }, { status: 400 }, ); } let customVoiceAssetId: string | null = null; if (parsed.data.voiceId.startsWith("custom:")) { customVoiceAssetId = parsed.data.voiceId.slice("custom:".length); if (!z.string().uuid().safeParse(customVoiceAssetId).success) { return NextResponse.json( { success: false, error: "自定义音色无效" }, { status: 400 }, ); } const [voiceAsset] = await db .select({ id: mediaAsset.id }) .from(mediaAsset) .where( and( eq(mediaAsset.id, customVoiceAssetId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE), eq(mediaAsset.status, "ready"), ), ) .limit(1); if (!voiceAsset) { return NextResponse.json( { success: false, error: "自定义音色不存在或已失效" }, { status: 400 }, ); } } const primaryAsset = orderedAssets[0]; const voice = config.voices.find((item) => item.id === parsed.data.voiceId); const previousMetadata = existingSubject.metadata && typeof existingSubject.metadata === "object" && !Array.isArray(existingSubject.metadata) ? (existingSubject.metadata as Record) : {}; const previousAssetIds = readMetadataAssetIds(existingSubject.metadata); const previousCustomVoiceId = String(previousMetadata.customVoiceAssetId || "").trim(); const [subject] = await db .update(mediaAsset) .set({ displayName: normalizeVideoCanvasSubjectText( parsed.data.name, config.nameMaxLength, ), durationMs: parsed.data.mediaMode === "video" && parsed.data.durationSeconds ? Math.round(parsed.data.durationSeconds * 1000) : null, metadata: { assetIds: parsed.data.assetIds, category, customVoiceAssetId, durationSeconds: parsed.data.durationSeconds || null, mediaMode: parsed.data.mediaMode, mediaUrls: orderedAssets.map((asset) => asset.originalUrl), voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""), voiceId: parsed.data.voiceId, voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"), }, originalUrl: primaryAsset.originalUrl, previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl, summary: { description: normalizeVideoCanvasSubjectText( parsed.data.description, config.descriptionMaxLength, ), }, thumbnailUrl: primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl, updatedAt: new Date(), }) .where(eq(mediaAsset.id, existingSubject.id)) .returning(); const removedAssetIds = previousAssetIds.filter( (assetId) => !parsed.data.assetIds.includes(assetId), ); if (removedAssetIds.length) { await db.delete(mediaAsset).where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE), inArray(mediaAsset.id, removedAssetIds), ), ); } if ( previousCustomVoiceId && previousCustomVoiceId !== customVoiceAssetId && z.string().uuid().safeParse(previousCustomVoiceId).success ) { await db.delete(mediaAsset).where( and( eq(mediaAsset.id, previousCustomVoiceId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE), ), ); } return NextResponse.json({ success: true, subject }); } catch (error) { logger.error("subject update failed", { error }); return NextResponse.json( { success: false, error: "主体保存失败" }, { status: 500 }, ); } } export async function DELETE(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = (await request.json().catch(() => null)) as { subjectId?: string; uploadAssetIds?: unknown; voiceAssetIds?: unknown; } | null; const uploadAssetIds = Array.isArray(body?.uploadAssetIds) ? body.uploadAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10) : []; const voiceAssetIds = Array.isArray(body?.voiceAssetIds) ? body.voiceAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10) : []; const subjectId = String(body?.subjectId || "").trim(); if (subjectId) { const [subject] = await db .select({ id: mediaAsset.id, metadata: mediaAsset.metadata }) .from(mediaAsset) .where( and( eq(mediaAsset.id, subjectId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE), ), ) .limit(1); if (!subject) { return NextResponse.json( { success: false, error: "主体不存在" }, { status: 404 }, ); } const referencedAssetIds = readMetadataAssetIds(subject.metadata); const subjectMetadata = subject.metadata && typeof subject.metadata === "object" && !Array.isArray(subject.metadata) ? (subject.metadata as Record) : {}; const customVoiceAssetId = String( subjectMetadata.customVoiceAssetId || "", ).trim(); await db.delete(mediaAsset).where(eq(mediaAsset.id, subject.id)); if (referencedAssetIds.length > 0) { await db .delete(mediaAsset) .where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE), inArray(mediaAsset.id, referencedAssetIds), ), ); } if (z.string().uuid().safeParse(customVoiceAssetId).success) { await db .delete(mediaAsset) .where( and( eq(mediaAsset.id, customVoiceAssetId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE), ), ); } return NextResponse.json({ success: true, removed: 1 }); } const cleanupGroups = [ { ids: uploadAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE }, { ids: voiceAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE }, ]; let removed = 0; for (const group of cleanupGroups) { if (group.ids.length === 0) continue; const deleted = await db .delete(mediaAsset) .where( and( eq(mediaAsset.userId, session.user.id), eq(mediaAsset.sourceType, group.sourceType), inArray(mediaAsset.id, group.ids), ), ) .returning({ id: mediaAsset.id }); removed += deleted.length; } return NextResponse.json({ success: true, removed }); } catch (error) { logger.error("subject deletion failed", { error }); return NextResponse.json( { success: false, error: "主体删除失败" }, { status: 500 }, ); } }