ai-chatbot/lib/storage/video-archiver.ts
Your Name 1967992de7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(video): storyboard task tables and delayed archive flow
2026-03-05 15:58:43 +08:00

226 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 视频归档服务
* 功能:下载临时视频URL,上传到S3,更新数据库记录
*/
import { db } from "@/lib/db";
import { message } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import {
toDbS3UploadConfig,
uploadFile,
uploadFileWithS3Config,
} from "@/lib/storage/service";
import type { VideoMetadata } from "@/types/ai";
interface VideoArchiveOptions {
messageId: string;
temporaryUrl: string;
taskId: string;
metadata?: {
duration?: number;
resolution?: string;
ratio?: string;
};
}
export class VideoArchiver {
/**
* 归档视频:下载 → 上传S3 → 更新数据库
*/
async archiveVideo(options: VideoArchiveOptions): Promise<string> {
const { messageId, temporaryUrl, taskId, metadata } = options;
try {
console.log(`[VideoArchiver] 📹 开始归档视频:${messageId}`);
// 1. 更新状态为"归档中"
await this.updateVideoStatus(messageId, "archiving");
// 2. 下载视频
console.log(`[VideoArchiver] ⬇️ 下载视频:${temporaryUrl}`);
const videoBuffer = await this.downloadVideo(temporaryUrl);
if (!videoBuffer) {
throw new Error("Failed to download video from temporary URL");
}
// 3. 生成文件名
const filename = this.generateVideoFilename(messageId, taskId);
// 4. 上传到S3(使用项目统一存储服务)
console.log(`[VideoArchiver] ⬆️ 上传视频到S3:${filename}`);
const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename);
// 5. 更新数据库
await this.updateVideoUrls(messageId, {
permanentUrl,
temporaryUrl,
fileSize: videoBuffer.length,
...metadata,
});
console.log(`[VideoArchiver] ✅ 视频归档完成:${permanentUrl}`);
return permanentUrl;
} catch (error) {
console.error(`[VideoArchiver] ❌ 归档失败:`, error);
// 更新状态为"失败"
await this.updateVideoStatus(messageId, "failed");
throw error;
}
}
/**
* 下载视频(带重试机制)
*/
private async downloadVideo(
url: string,
retries = 3
): Promise<Buffer | null> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
headers: {
"User-Agent": "AI-Chatbot-Video-Archiver/1.0",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error(
`[VideoArchiver] 下载失败 (尝试 ${i + 1}/${retries}):`,
error
);
if (i === retries - 1) {
return null;
}
// 等待后重试(指数退避)
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, i) * 1000)
);
}
}
return null;
}
/**
* 上传视频到S3(使用项目统一存储服务)
*/
private async uploadVideoToS3(
buffer: Buffer,
filename: string
): Promise<string> {
const defaultStorageConfig = await getDefaultStorageConfig().catch(() => undefined);
const defaultS3Config = toDbS3UploadConfig(defaultStorageConfig);
if (defaultS3Config) {
const uploaded = await uploadFileWithS3Config(
filename,
buffer as any,
defaultS3Config
);
console.log(
`[VideoArchiver] 视频已上传到默认存储:${uploaded.url}`,
defaultStorageConfig?.name ? `(config=${defaultStorageConfig.name})` : ""
);
return uploaded.url;
}
console.warn("[VideoArchiver] 默认存储不可用,回退到通用上传策略");
const fallback = await uploadFile(filename, buffer as any);
console.log(`[VideoArchiver] 视频已上传(回退路径):${fallback.url}`);
return fallback.url;
}
/**
* 生成视频文件名
* 项目的 uploadFile 会自动生成 uploads/timestamp-random.ext 格式
*/
private generateVideoFilename(messageId: string, taskId: string): string {
// 使用唯一标识作为文件名
return `video-${messageId}-${taskId}.mp4`;
}
/**
* 更新视频状态
*/
private async updateVideoStatus(
messageId: string,
status: "temporary" | "archiving" | "archived" | "failed"
) {
await db
.update(message)
.set({ videoStorageStatus: status })
.where(eq(message.id, messageId));
}
/**
* 更新视频URL和元数据
*/
private async updateVideoUrls(
messageId: string,
data: {
permanentUrl: string;
temporaryUrl: string;
fileSize: number;
duration?: number;
resolution?: string;
ratio?: string;
}
) {
// 获取现有的 videoMetadata
const existing = await db.query.message.findFirst({
where: eq(message.id, messageId),
});
const existingMetadata = (existing?.videoMetadata as VideoMetadata) || {};
const existingParts = Array.isArray(existing?.parts) ? existing.parts : [];
const nextParts = existingParts.map((part: any) => {
if (!part || part.type !== "text" || typeof part.text !== "string") {
return part;
}
return {
...part,
text: part.text.split(data.temporaryUrl).join(data.permanentUrl),
};
});
await db
.update(message)
.set({
parts: nextParts as any,
permanentVideoUrl: data.permanentUrl,
videoStorageStatus: "archived",
archivedAt: new Date(),
videoMetadata: {
...existingMetadata,
temporaryVideoUrl: data.temporaryUrl,
permanentVideoUrl: data.permanentUrl,
storageStatus: "archived",
archivedAt: Date.now(),
updatedAt: Date.now(),
fileSize: data.fileSize,
duration: data.duration,
resolution: data.resolution,
ratio: data.ratio,
} as any,
})
.where(eq(message.id, messageId));
}
}
// 单例模式
export const videoArchiver = new VideoArchiver();