280 lines
8.3 KiB
TypeScript
280 lines
8.3 KiB
TypeScript
import { createLogger } from "@/lib/observability/logger";
|
|
import { after } from "next/server";
|
|
import {
|
|
getAiModelById,
|
|
getEnabledAiModelsWithProvider,
|
|
updateMessageById,
|
|
} from "@/lib/db/queries";
|
|
import {
|
|
isImageGenerationModelDescriptor,
|
|
isVideoModelDescriptor,
|
|
} from "@/lib/ai/model-classification";
|
|
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
|
|
|
const chatApiLogger = createLogger("chat.videoModels");
|
|
|
|
|
|
export type VideoModeType =
|
|
| "text-to-video"
|
|
| "first-frame"
|
|
| "first-last-frame"
|
|
| "reference-images";
|
|
|
|
export function isVideoProviderType(
|
|
providerType?: string | null,
|
|
model?: {
|
|
modelId?: string | null;
|
|
capabilities?: unknown;
|
|
supportsImageGen?: boolean | null;
|
|
},
|
|
): boolean {
|
|
return isVideoModelDescriptor({
|
|
providerType,
|
|
modelId: model?.modelId,
|
|
capabilities: model?.capabilities,
|
|
supportsImageGen: model?.supportsImageGen,
|
|
});
|
|
}
|
|
|
|
export function isImageGenerationModelType(
|
|
providerType?: string | null,
|
|
model?: {
|
|
modelId?: string | null;
|
|
capabilities?: unknown;
|
|
supportsImageGen?: boolean | null;
|
|
},
|
|
): boolean {
|
|
return isImageGenerationModelDescriptor({
|
|
providerType,
|
|
modelId: model?.modelId,
|
|
capabilities: model?.capabilities,
|
|
supportsImageGen: model?.supportsImageGen,
|
|
});
|
|
}
|
|
|
|
export function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
|
|
const normalized = modelId.toLowerCase();
|
|
|
|
if (
|
|
normalized === "gen4.5" ||
|
|
normalized === "gen4_turbo" ||
|
|
normalized === "gen4_aleph" ||
|
|
normalized === "gen3a_turbo" ||
|
|
normalized === "veo3" ||
|
|
normalized === "veo3.1" ||
|
|
normalized === "veo3.1_fast"
|
|
) {
|
|
return ["text-to-video", "first-frame"];
|
|
}
|
|
|
|
if (normalized.includes("lite-t2v")) {
|
|
return ["text-to-video"];
|
|
}
|
|
if (normalized.includes("lite-i2v")) {
|
|
return ["first-frame", "first-last-frame", "reference-images"];
|
|
}
|
|
if (normalized.includes("pro-fast")) {
|
|
return ["text-to-video", "first-frame"];
|
|
}
|
|
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
|
|
return ["text-to-video", "first-frame", "first-last-frame"];
|
|
}
|
|
|
|
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
|
}
|
|
|
|
export function getSupportedVideoModesForModel(model: any): VideoModeType[] {
|
|
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[]).filter(
|
|
(mode): mode is VideoModeType =>
|
|
mode === "text-to-video" ||
|
|
mode === "first-frame" ||
|
|
mode === "first-last-frame" ||
|
|
mode === "reference-images",
|
|
);
|
|
|
|
if (capabilityModes.length > 0) {
|
|
return capabilityModes;
|
|
}
|
|
|
|
const modelId = String(model?.modelId || "");
|
|
return inferSupportedVideoModesByModelId(modelId);
|
|
}
|
|
|
|
export function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
|
|
const normalized = modelId.toLowerCase();
|
|
const isProFast = normalized.includes("pro-fast");
|
|
const isLiteT2V = normalized.includes("lite-t2v");
|
|
const isLiteI2V = normalized.includes("lite-i2v");
|
|
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
|
|
const isV15Pro = normalized.includes("seedance-1-5-pro");
|
|
|
|
switch (mode) {
|
|
case "first-last-frame":
|
|
if (isV15Pro) return 150;
|
|
if (isPro) return 120;
|
|
if (isLiteI2V) return 90;
|
|
if (isProFast) return -1000;
|
|
if (isLiteT2V) return -1000;
|
|
return 10;
|
|
case "first-frame":
|
|
if (isV15Pro) return 140;
|
|
if (isProFast) return 130;
|
|
if (isPro) return 120;
|
|
if (isLiteI2V) return 100;
|
|
if (isLiteT2V) return 20;
|
|
return 10;
|
|
case "reference-images":
|
|
if (isV15Pro) return 150;
|
|
if (isLiteI2V) return 140;
|
|
if (isPro) return 110;
|
|
if (isProFast || isLiteT2V) return -1000;
|
|
return 10;
|
|
case "text-to-video":
|
|
default:
|
|
if (isV15Pro) return 140;
|
|
if (isLiteT2V) return 130;
|
|
if (isProFast) return 120;
|
|
if (isPro) return 100;
|
|
if (isLiteI2V) return 80;
|
|
return 10;
|
|
}
|
|
}
|
|
|
|
export function selectBestVideoSubModelForMode(
|
|
subModels: Array<{ model: any }>,
|
|
mode: VideoModeType,
|
|
): { model: any } | null {
|
|
const compatible = subModels.filter(({ model }) => {
|
|
const supportedModes = getSupportedVideoModesForModel(model);
|
|
return supportedModes.includes(mode);
|
|
});
|
|
|
|
const candidates = compatible.length > 0 ? compatible : subModels;
|
|
if (candidates.length === 0) return null;
|
|
|
|
return [...candidates].sort((a, b) => {
|
|
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
|
|
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
|
|
return scoreB - scoreA;
|
|
})[0];
|
|
}
|
|
|
|
export async function resolveStoryboardModelCandidates(
|
|
selectedModelRef: string,
|
|
): Promise<StoryboardModelCandidate[]> {
|
|
const selectedModel = await getAiModelById({ id: selectedModelRef }).catch(() => undefined);
|
|
if (!selectedModel) return [];
|
|
|
|
const configuredFallbackIds = [
|
|
selectedModel.fallbackModelId1,
|
|
selectedModel.fallbackModelId2,
|
|
].filter((item): item is string => Boolean(String(item || "").trim()));
|
|
|
|
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
|
|
const sameProvider = allModels
|
|
.filter(
|
|
({ model, provider }: any) =>
|
|
provider?.id === selectedModel.providerId && model?.id !== selectedModel.id && model?.isEnabled,
|
|
)
|
|
.map(({ model }: any) => String(model?.id || "").trim())
|
|
.filter(Boolean);
|
|
|
|
const candidateIds = Array.from(new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider]));
|
|
const byModelRef = new Map<string, any>();
|
|
allModels
|
|
.map(({ model }: any) => model)
|
|
.filter(Boolean)
|
|
.forEach((model: any) => {
|
|
byModelRef.set(String(model.id), model);
|
|
if (model.modelId) {
|
|
byModelRef.set(String(model.modelId), model);
|
|
}
|
|
});
|
|
|
|
return candidateIds
|
|
.map((id) => byModelRef.get(id))
|
|
.filter(Boolean)
|
|
.map((model: any) => ({
|
|
id: String(model.id),
|
|
modelId: String(model.modelId || "").trim(),
|
|
displayName: String(model.displayName || model.modelId || "").trim(),
|
|
}))
|
|
.filter((item) => item.modelId);
|
|
}
|
|
|
|
export function extractVideoUrlFromText(text: string): string | null {
|
|
const normalizedText = String(text || "");
|
|
if (!normalizedText) return null;
|
|
|
|
const htmlMatch = normalizedText.match(/<video[^>]+src="([^"]+)"/i);
|
|
if (htmlMatch?.[1]) return htmlMatch[1];
|
|
|
|
const markdownMatch = normalizedText.match(
|
|
/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i,
|
|
);
|
|
if (markdownMatch?.[1]) return markdownMatch[1];
|
|
|
|
const plainMatch = normalizedText.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
|
return plainMatch?.[1] || null;
|
|
}
|
|
|
|
export function isAlreadyArchivedVideoUrl(url: string): boolean {
|
|
return /\/uploads\//i.test(url);
|
|
}
|
|
|
|
export function scheduleVideoArchive(params: {
|
|
chatId: string;
|
|
messageId: string;
|
|
text: string;
|
|
temporaryVideoUrl: string;
|
|
taskId?: string;
|
|
metadata?: Record<string, any> | null;
|
|
}) {
|
|
const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params;
|
|
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
|
|
return;
|
|
}
|
|
|
|
const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48);
|
|
const delayHours = Number.isFinite(delayHoursRaw)
|
|
? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw)))
|
|
: 48;
|
|
const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000;
|
|
|
|
after(async () => {
|
|
try {
|
|
const nextMetadata = {
|
|
...(metadata || {}),
|
|
temporaryVideoUrl,
|
|
storageStatus: "temporary",
|
|
archiveMode: "delayed_job",
|
|
archiveAfterAt: archiveAfterAtMs,
|
|
archiveDelayHours: delayHours,
|
|
archiveTaskId: taskId || messageId,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
await updateMessageById({
|
|
messageId,
|
|
chatId,
|
|
videoStorageStatus: "temporary",
|
|
temporaryVideoUrl,
|
|
permanentVideoUrl: null,
|
|
archivedAt: null,
|
|
videoMetadata: nextMetadata as any,
|
|
});
|
|
|
|
chatApiLogger.info("[Chat API] 🕒 已登记延迟归档视频:", {
|
|
messageId,
|
|
archiveAfterAt: new Date(archiveAfterAtMs).toISOString(),
|
|
});
|
|
} catch (error) {
|
|
chatApiLogger.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", {
|
|
messageId,
|
|
temporaryVideoUrl,
|
|
error,
|
|
});
|
|
}
|
|
});
|
|
}
|