570 lines
19 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|