"use strict"; /** * 视频生成模块 - 业务逻辑 * 处理视频项目的增删改查和生成逻辑 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createVideoProject = createVideoProject; exports.getVideoProjects = getVideoProjects; exports.getVideoProject = getVideoProject; exports.updateVideoProject = updateVideoProject; exports.deleteVideoProject = deleteVideoProject; exports.generateVideoForProject = generateVideoForProject; exports.getGenerateProgress = getGenerateProgress; exports.getMaterials = getMaterials; exports.uploadMaterial = uploadMaterial; exports.deleteMaterial = deleteMaterial; exports.createVideoProjectFromBook = createVideoProjectFromBook; const client_1 = require("@prisma/client"); const path_1 = __importDefault(require("path")); const uuid_1 = require("uuid"); const video_generator_types_1 = require("./video-generator.types"); const video_generator_ffmpeg_1 = require("./video-generator.ffmpeg"); const prisma = new client_1.PrismaClient(); // ============ 视频项目管理 ============ /** * 创建视频项目 */ async function createVideoProject(data, userId) { const config = data.config || video_generator_types_1.PRESET_VIDEO_CONFIGS.portrait; const project = await prisma.videoProject.create({ data: { userId: userId, title: data.title, description: data.description, coverUrl: data.coverUrl, configJson: (0, video_generator_types_1.serializeConfig)(config), bookId: data.bookId, chapterId: data.chapterId, // 直接关联章节 status: 'draft', progress: 0, }, }); return { ...project, config, }; } /** * 获取视频项目列表 */ async function getVideoProjects(query) { const page = query.page || 1; const pageSize = query.pageSize || 10; const skip = (page - 1) * pageSize; const where = {}; if (query.userId) where.userId = query.userId; if (query.status) where.status = query.status; const [items, total] = await Promise.all([ prisma.videoProject.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: pageSize, }), prisma.videoProject.count({ where }), ]); return { items: items.map((item) => ({ ...item, config: (0, video_generator_types_1.parseConfig)(item.configJson), })), total, page, pageSize, }; } /** * 获取视频项目详情 */ async function getVideoProject(id) { const project = await prisma.videoProject.findUnique({ where: { id }, }); if (!project) return null; return { ...project, config: (0, video_generator_types_1.parseConfig)(project.configJson), }; } /** * 更新视频项目 */ async function updateVideoProject(id, data) { const updateData = {}; if (data.title !== undefined) updateData.title = data.title; if (data.description !== undefined) updateData.description = data.description; if (data.coverUrl !== undefined) updateData.coverUrl = data.coverUrl; if (data.config !== undefined) updateData.configJson = (0, video_generator_types_1.serializeConfig)(data.config); const project = await prisma.videoProject.update({ where: { id }, data: updateData, }); return { ...project, config: (0, video_generator_types_1.parseConfig)(project.configJson), }; } /** * 删除视频项目 */ async function deleteVideoProject(id) { try { await prisma.videoProject.delete({ where: { id }, }); return true; } catch { return false; } } // ============ 视频生成 ============ /** * 生成视频 */ async function generateVideoForProject(projectId) { // 1. 获取项目 const project = await getVideoProject(projectId); if (!project) { return { success: false, error: '项目不存在' }; } // 2. 检查状态 if (project.status === 'processing') { return { success: false, error: '项目正在生成中' }; } // 3. 更新状态为处理中 await prisma.videoProject.update({ where: { id: projectId }, data: { status: 'processing', progress: 0 }, }); try { // 4. 获取配置 const config = project.config; if (!config) { throw new Error('缺少视频配置'); } // 5. 如果有 chapterId,优先从章节获取音频 let audioPath = config.audio?.url; let textContent = ''; if (project.chapterId) { const chapter = await prisma.bookChapter.findUnique({ where: { id: project.chapterId }, }); if (chapter?.audioUrl) { audioPath = chapter.audioUrl; } if (chapter?.content) { textContent = chapter.content; } } if (!audioPath || !config.images?.[0]?.url) { throw new Error('缺少必要的素材:音频或图片'); } // 转换URL为本地文件路径 const fs = await import('fs'); // 获取 server 目录路径 const serverDir = process.cwd(); // 处理图片路径 let imagePath = config.images[0].url; // 统一处理:所有非绝对路径都加上 server 目录和 public 前缀 if (!imagePath.match(/^[A-Za-z]:/)) { // Windows 或 Unix 相对路径 imagePath = path_1.default.join(serverDir, 'public', imagePath.replace(/^\//, '')); } // 处理音频路径 if (!audioPath.match(/^[A-Za-z]:/)) { audioPath = path_1.default.join(serverDir, audioPath.replace(/^\//, '')); } // 检查文件是否存在 if (!fs.existsSync(imagePath)) { throw new Error('图片文件不存在: ' + imagePath); } if (!fs.existsSync(audioPath)) { throw new Error('音频文件不存在: ' + audioPath); } // 6. 生成输出路径 const outputFileName = `video_${projectId}_${(0, uuid_1.v4)()}.mp4`; const outputPath = path_1.default.join(process.cwd(), 'public', 'videos', outputFileName); // 7. 更新进度 await prisma.videoProject.update({ where: { id: projectId }, data: { progress: 30 }, }); // 8. 生成视频 let result; if (config.bgm?.url) { // 带背景音乐 const bgmPath = await downloadFile(config.bgm.url, 'audio'); result = await (0, video_generator_ffmpeg_1.generateVideoWithBgm)(imagePath, audioPath, bgmPath, outputPath, config); } else { // 不带背景音乐 result = await (0, video_generator_ffmpeg_1.generateVideo)(imagePath, audioPath, outputPath, config); } // 9. 更新项目状态 const outputUrl = `/videos/${outputFileName}`; await prisma.videoProject.update({ where: { id: projectId }, data: { status: 'completed', progress: 100, outputUrl, duration: result.duration, fileSize: result.fileSize, }, }); // 10. 如果有章节关联,更新章节的视频URL if (project.chapterId) { await prisma.bookChapter.update({ where: { id: project.chapterId }, data: { videoUrl: outputUrl, videoDuration: result.duration, }, }); } return { success: true, outputUrl, duration: result.duration, fileSize: result.fileSize, }; } catch (error) { // 生成失败,更新状态 await prisma.videoProject.update({ where: { id: projectId }, data: { status: 'failed', errorMsg: error.message, }, }); return { success: false, error: error.message, }; } } /** * 获取生成进度 */ async function getGenerateProgress(projectId) { const project = await prisma.videoProject.findUnique({ where: { id: projectId }, select: { status: true, progress: true, outputUrl: true, duration: true, fileSize: true, errorMsg: true, }, }); if (!project) { return { status: 'failed', progress: 0, errorMsg: '项目不存在' }; } return project; } // ============ 素材管理 ============ /** * 获取素材列表 */ async function getMaterials(query) { const page = query.page || 1; const pageSize = query.pageSize || 20; const skip = (page - 1) * pageSize; const where = { OR: [{ userId: null }, { userId: query.userId || 0 }], }; if (query.type) where.type = query.type; if (query.category) where.category = query.category; const [items, total] = await Promise.all([ prisma.videoMaterial.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: pageSize, }), prisma.videoMaterial.count({ where }), ]); return { items: items.map((item) => ({ ...item, tags: (0, video_generator_types_1.parseTags)(item.tags), })), total, }; } /** * 上传素材 */ async function uploadMaterial(data, userId) { const material = await prisma.videoMaterial.create({ data: { userId: userId, type: data.type, name: data.name, url: data.url, thumbnail: data.thumbnail, tags: (0, video_generator_types_1.serializeTags)(data.tags || []), category: data.category, duration: data.duration, size: data.size, width: data.width, height: data.height, }, }); return { ...material, tags: (0, video_generator_types_1.parseTags)(material.tags), }; } /** * 删除素材 */ async function deleteMaterial(id) { try { await prisma.videoMaterial.delete({ where: { id }, }); return true; } catch { return false; } } // ============ 辅助函数 ============ /** * 下载文件到临时目录 */ async function downloadFile(url, type) { const https = await import('https'); const http = await import('http'); const fs = await import('fs'); const tempDir = path_1.default.join(process.cwd(), 'temp', type); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } const ext = path_1.default.extname(url) || (type === 'images' ? '.jpg' : '.mp3'); const filePath = path_1.default.join(tempDir, `${(0, uuid_1.v4)()}${ext}`); return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; const file = fs.createWriteStream(filePath); protocol.get(url, (response) => { if (response.statusCode === 301 || response.statusCode === 302) { // 重定向 const redirectUrl = response.headers.location; if (!redirectUrl) { file.close(); reject(new Error('重定向但没有 location header')); return; } const redirectProtocol = redirectUrl.startsWith('https') ? https : http; redirectProtocol.get(redirectUrl, (redirectResponse) => { redirectResponse.pipe(file); file.on('finish', () => resolve(filePath)); }); } else { response.pipe(file); file.on('finish', () => resolve(filePath)); } }).on('error', reject); }); } /** * 清理临时文件 */ async function cleanupTempFiles(...filePaths) { const fs = await import('fs'); for (const filePath of filePaths) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (error) { console.error(`清理文件失败: ${filePath}`, error); } } } /** * 从书籍生成视频项目(基于指定章节) */ async function createVideoProjectFromBook(bookId, chapterId, userId) { // 获取书籍信息 const book = await prisma.book.findUnique({ where: { id: bookId }, include: { chapters: { orderBy: { number: 'asc' } } }, }); if (!book) return null; // 如果没有指定章节,使用第一个有音频的章节 let targetChapter = book.chapters.find(c => c.audioUrl) || book.chapters[0]; if (chapterId) { const found = book.chapters.find(c => c.id === chapterId); if (found) targetChapter = found; } if (!targetChapter) return null; // 如果章节没有音频,返回错误 if (!targetChapter.audioUrl) { console.error(`章节 ${targetChapter.number} 没有音频`); return null; } // 创建视频项目,直接关联到章节 const project = await createVideoProject({ title: `《${book.title}》第${targetChapter.number}章 视频`, description: targetChapter.summary || book.description, bookId: book.id, chapterId: targetChapter.id, config: { ...video_generator_types_1.PRESET_VIDEO_CONFIGS.portrait, audio: { url: targetChapter.audioUrl, volume: 1, }, images: [ { url: '/images/default-cover.jpg', duration: 5, transition: 'fade', }, ], subtitle: { text: targetChapter.title, position: 'bottom', fontSize: 28, fontColor: 'white', }, }, }, userId); return project; }