fix: archive generated videos to S3 immediately
This commit is contained in:
parent
8386755e6d
commit
ea2885769c
@ -55,6 +55,7 @@ import type { DBMessage } from "@/lib/db/schema";
|
||||
import { generatedImage, message as messageTable } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { videoArchiver } from "@/lib/storage/video-archiver";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import type { AppUsage } from "@/lib/usage";
|
||||
@ -357,6 +358,96 @@ function selectBestVideoSubModelForMode(
|
||||
})[0];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function replaceVideoUrlInText(text: string, oldUrl: string, newUrl: string): string {
|
||||
if (!text || !oldUrl || !newUrl || oldUrl === newUrl) return text;
|
||||
return text.split(oldUrl).join(newUrl);
|
||||
}
|
||||
|
||||
function isAlreadyArchivedVideoUrl(url: string): boolean {
|
||||
return /\/uploads\//i.test(url);
|
||||
}
|
||||
|
||||
function scheduleVideoArchive(params: {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
temporaryVideoUrl: string;
|
||||
taskId?: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
}) {
|
||||
const { chatId, messageId, text, temporaryVideoUrl, taskId, metadata } = params;
|
||||
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
const permanentVideoUrl = await videoArchiver.archiveVideo({
|
||||
messageId,
|
||||
temporaryUrl: temporaryVideoUrl,
|
||||
taskId: taskId || messageId,
|
||||
metadata: {
|
||||
duration:
|
||||
typeof metadata?.duration === "number" && Number.isFinite(metadata.duration)
|
||||
? metadata.duration
|
||||
: undefined,
|
||||
resolution:
|
||||
typeof metadata?.resolution === "string" ? metadata.resolution : undefined,
|
||||
ratio: typeof metadata?.ratio === "string" ? metadata.ratio : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const archivedText = replaceVideoUrlInText(text, temporaryVideoUrl, permanentVideoUrl);
|
||||
const nextMetadata = {
|
||||
...(metadata || {}),
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl,
|
||||
storageStatus: "archived",
|
||||
archivedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await updateMessageById({
|
||||
messageId,
|
||||
chatId,
|
||||
parts: [{ type: "text", text: archivedText }],
|
||||
videoStorageStatus: "archived",
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl,
|
||||
archivedAt: new Date(),
|
||||
videoMetadata: nextMetadata as any,
|
||||
});
|
||||
|
||||
console.log("[Chat API] ✅ 视频已归档到永久存储:", {
|
||||
messageId,
|
||||
permanentVideoUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Chat API] ❌ 视频归档失败(保留临时地址):", {
|
||||
messageId,
|
||||
temporaryVideoUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function replayAssistantMessagesForDuplicateRequest({
|
||||
chatId,
|
||||
userMessageId,
|
||||
@ -3638,10 +3729,6 @@ export async function POST(request: Request) {
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as "qianwen-video" | "fal-video";
|
||||
console.log(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
const needsAsyncReplacement = currentVideoProvider === "qianwen-video";
|
||||
const scheduleVideoUrlReplacement = needsAsyncReplacement
|
||||
? (await import("@/lib/ai/providers/qianwen-video")).scheduleVideoUrlReplacement
|
||||
: null;
|
||||
|
||||
const qianwenVideoStream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
@ -3699,14 +3786,22 @@ export async function POST(request: Request) {
|
||||
const videoMarkdown = result.text || "视频生成失败";
|
||||
console.log(`[Chat API] ${currentVideoProvider} video generated, length:`, videoMarkdown.length);
|
||||
|
||||
// 提取临时视频 URL 和提示词(用于后续替换)
|
||||
const videoUrlMatch = videoMarkdown.match(/<video\s+src="([^"]+)"/);
|
||||
const tempVideoUrl = videoUrlMatch ? videoUrlMatch[1] : "";
|
||||
const promptMatch = videoMarkdown.match(/\*\*提示词\*\*:\s*(.+)/);
|
||||
const prompt = promptMatch ? promptMatch[1].trim() : "生成视频";
|
||||
// 提取视频 URL(后续会自动转存到永久存储)
|
||||
const tempVideoUrl = extractVideoUrlFromText(videoMarkdown) || "";
|
||||
|
||||
// 生成一个统一的消息 ID
|
||||
const messageId = generateUUID();
|
||||
|
||||
const videoMetadata = {
|
||||
provider: currentVideoProvider,
|
||||
model: finalModelId,
|
||||
mode: effectiveVideoParams?.mode,
|
||||
resolution: effectiveVideoParams?.resolution,
|
||||
ratio: effectiveVideoParams?.ratio,
|
||||
duration: effectiveVideoParams?.duration,
|
||||
status: "succeeded",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 写入消息到流
|
||||
writer.write({
|
||||
@ -3730,29 +3825,25 @@ export async function POST(request: Request) {
|
||||
parts: [{ type: "text", text: videoMarkdown }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
videoStorageStatus: tempVideoUrl ? "temporary" : null,
|
||||
temporaryVideoUrl: tempVideoUrl || null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: effectiveVideoParams
|
||||
? {
|
||||
provider: currentVideoProvider,
|
||||
mode: effectiveVideoParams.mode,
|
||||
resolution: effectiveVideoParams.resolution,
|
||||
ratio: effectiveVideoParams.ratio,
|
||||
duration: effectiveVideoParams.duration,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
: null,
|
||||
videoMetadata: videoMetadata as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log(`[Chat API] ✅ ${currentVideoProvider} video message saved successfully!`);
|
||||
|
||||
// 启动异步视频替换任务(5分钟后执行)
|
||||
if (needsAsyncReplacement && tempVideoUrl && scheduleVideoUrlReplacement) {
|
||||
console.log("[Chat API] 🕐 Scheduling video URL replacement in 5 minutes...");
|
||||
scheduleVideoUrlReplacement(id, messageId, tempVideoUrl, prompt);
|
||||
|
||||
if (tempVideoUrl) {
|
||||
scheduleVideoArchive({
|
||||
chatId: id,
|
||||
messageId,
|
||||
text: videoMarkdown,
|
||||
temporaryVideoUrl: tempVideoUrl,
|
||||
taskId: messageId,
|
||||
metadata: videoMetadata,
|
||||
});
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error(`[Chat API] ❌ Failed to save ${currentVideoProvider} video message:`, saveError);
|
||||
@ -4343,7 +4434,18 @@ export async function POST(request: Request) {
|
||||
|
||||
console.log("[Chat API] ✅ Video message updated successfully");
|
||||
if (temporaryVideoUrl) {
|
||||
console.log("[Chat API] 📹 临时视频URL已保存,将在8小时后自动归档到S3");
|
||||
console.log("[Chat API] 📹 临时视频URL已保存,开始异步归档到S3");
|
||||
scheduleVideoArchive({
|
||||
chatId: id,
|
||||
messageId: videoMessageId,
|
||||
text: cleanedText,
|
||||
temporaryVideoUrl,
|
||||
taskId:
|
||||
typeof successMetadata.taskId === "string" && successMetadata.taskId
|
||||
? successMetadata.taskId
|
||||
: videoMessageId,
|
||||
metadata: successMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[Chat API] Video generation finished successfully");
|
||||
@ -4503,7 +4605,7 @@ export async function POST(request: Request) {
|
||||
})),
|
||||
});
|
||||
|
||||
// 检测助手消息中是否包含视频,如果有则启动异步替换任务
|
||||
// 检测助手消息中是否包含视频,如果有则立即标记并归档到永久存储
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.parts) {
|
||||
const textContent = msg.parts
|
||||
@ -4511,21 +4613,32 @@ export async function POST(request: Request) {
|
||||
.map((part: any) => part.text)
|
||||
.join("");
|
||||
|
||||
// 检测是否包含视频标签
|
||||
const videoUrlMatch = textContent?.match(/<video[^>]+src="([^"]+)"/);
|
||||
if (videoUrlMatch) {
|
||||
const tempVideoUrl = videoUrlMatch[1];
|
||||
const promptMatch = textContent?.match(/\*\*提示词\*\*:\s*(.+)/);
|
||||
const prompt = promptMatch ? promptMatch[1].trim() : "生成视频";
|
||||
|
||||
console.log("[Chat API] 检测到视频消息,启动URL替换任务");
|
||||
console.log("[Chat API] 临时视频URL:", tempVideoUrl);
|
||||
|
||||
// 动态导入并启动替换任务(5分钟后执行)
|
||||
import("@/lib/ai/providers/qianwen-video").then(({ scheduleVideoUrlReplacement }) => {
|
||||
scheduleVideoUrlReplacement(id, msg.id, tempVideoUrl, prompt);
|
||||
}).catch((err) => {
|
||||
console.error("[Chat API] 启动视频替换任务失败:", err);
|
||||
const tempVideoUrl = extractVideoUrlFromText(textContent);
|
||||
if (tempVideoUrl) {
|
||||
const genericVideoMetadata = {
|
||||
provider: "stream",
|
||||
model: actualModelId,
|
||||
status: "succeeded",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await updateMessageById({
|
||||
messageId: msg.id,
|
||||
chatId: id,
|
||||
videoStorageStatus: "temporary",
|
||||
temporaryVideoUrl: tempVideoUrl,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: genericVideoMetadata as any,
|
||||
});
|
||||
|
||||
scheduleVideoArchive({
|
||||
chatId: id,
|
||||
messageId: msg.id,
|
||||
text: textContent,
|
||||
temporaryVideoUrl: tempVideoUrl,
|
||||
taskId: msg.id,
|
||||
metadata: genericVideoMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user