| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- "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;
- }
|