570 lines
19 KiB
TypeScript

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<string, unknown>).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<string, unknown>).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<string, unknown>)
: {};
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<string, unknown>)
: {};
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 },
);
}
}