From ea2885769cde4bb613efe4b91cb0ce75ffa0c762 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 21:26:38 +0800 Subject: [PATCH] fix: archive generated videos to S3 immediately --- app/(chat)/api/chat/route.ts | 199 +++++++++++++++++++++++++++-------- 1 file changed, 156 insertions(+), 43 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index d6129be50..434feed7d 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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(/]+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 | 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(/ part.text) .join(""); - // 检测是否包含视频标签 - const videoUrlMatch = textContent?.match(/]+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, }); } }