import { ErrorItem, SuccessItem } from '@/define/model/generalResponse' import { BookBasicHandle } from './bookBasicHandle' import { Book } from '@/define/model/book/book' import path from 'path' import compressing from 'compressing' import fs from 'fs' import { CheckFileOrDirExist, CheckFolderExistsOrCreate, CopyFileOrFolder, GetFilesWithExtensions } from '@/define/Tools/file' import { OptionKeyName } from '@/define/enum/option' import { optionSerialization } from '../../option/optionSerialization' import { SettingModal } from '@/define/model/setting' import { errorMessage, successMessage } from '@/public/generalTools' import { isEmpty } from 'lodash' import { define } from '@/define/define' import util from 'util' import { exec } from 'child_process' import { ValidateJson } from '@/define/Tools/validate' const execAsync = util.promisify(exec) import JianyingService from '../../jianying/jianyingService' import { t } from '@/i18n' export class BookExportHandle extends BookBasicHandle { jianyingService: JianyingService constructor() { super() this.jianyingService = new JianyingService() } /** * 生成剪映草稿配置文件 * @description 根据小说任务信息生成剪映视频编辑所需的配置文件,包含字幕信息、背景音乐、关键帧设置等 * @param book 小说基本信息对象,包含小说文件夹路径等 * @param bookTask 小说任务对象,包含音频路径、字幕路径、背景音乐等配置信息 * @returns Promise<{draftName: string, configJsonPath: string}> 返回草稿名称和配置文件路径 * @throws {Error} 当背景音乐文件不存在、字幕时间信息不完整等情况时抛出错误 * @example * ```typescript * const result = await this.GenerateConfigFile(bookInfo, taskInfo); * console.log('草稿名称:', result.draftName); * console.log('配置文件路径:', result.configJsonPath); * ``` */ private async GenerateConfigFile( book: Book.SelectBook, bookTask: Book.SelectBookTask ): Promise<{ draftName: string configJsonPath: string }> { try { // 构建配置文件保存路径,格式: 小说文件夹/scripts/任务名_config.json let configPath = path.join( book.bookFolderPath as string, `scripts/${bookTask.name}_config.json` ) // 确保目录存在,不存在则创建 await CheckFolderExistsOrCreate(path.dirname(configPath)) // 获取任务详细信息(包含所有帧的字幕、图片、时间信息等) let bookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({ bookTaskId: bookTask.id }) // ========== 处理背景音乐配置 ========== let musicPath: string | undefined = undefined if (!isEmpty(bookTask.backgroundMusic) && bookTask.backgroundMusic != null) { // 检查背景音乐文件或文件夹是否存在 if (!CheckFileOrDirExist(bookTask.backgroundMusic)) { throw new Error(t('背景音乐文件夹或文件不存在,请检查')) } // 判断背景音乐是文件还是文件夹 let isFolder = await fs.promises .stat(bookTask.backgroundMusic as string) .then((stat) => stat.isDirectory()) .catch(() => false) if (!isFolder) { // 如果是文件,直接使用该文件路径 musicPath = bookTask.backgroundMusic } else { // 如果是文件夹,从中随机选择一个音频文件 let files = await GetFilesWithExtensions(bookTask.backgroundMusic, ['.mp3', '.wav']) if (files.length <= 0) { throw new Error(t('背景音乐文件夹下面未存在有效的音频文件')) } else { const randomIndex = Math.floor(Math.random() * files.length) musicPath = files[randomIndex] } } } // ========== 获取用户配置设置 ========== // 获取剪映关键帧动画设置(上下移动、左右移动、缩放等动画参数) let keyFrameSettingOption = this.optionRealmService.GetOptionByKey( OptionKeyName.Software.JianyingKeyFrameSetting ) let keyFrameSetting = optionSerialization(keyFrameSettingOption) // 获取通用设置(包含剪映草稿文件夹路径等) let generalSettingOption = this.optionRealmService.GetOptionByKey( OptionKeyName.Software.GeneralSetting ) let generalSetting = optionSerialization(generalSettingOption) // ========== 准备剪映草稿文件 ========== let draft_name = bookTask.name as string let draft_path = path.join(generalSetting.draftPath, draft_name) // 清理已存在的草稿文件夹 await fs.promises.rm(draft_path, { recursive: true, force: true }) // 从模板压缩包中解压出草稿文件夹 await compressing.zip.uncompress(define.draft_temp_path, draft_path) let draftPath = path.join(draft_path, 'draft_content.json') // ========== 构建配置数据对象 ========== let configData = { srt_time_information: [] as any[], // 存储所有帧的字幕时间信息 video_config: { srt_path: bookTask.srtPath, // 字幕文件路径 audio_path: bookTask.audioPath, // 音频文件路径 draft_srt_style: bookTask.draftSrtStyle ? bookTask.draftSrtStyle : '0', // 字幕样式 background_music: musicPath, // 背景音乐路径 friendly_reminder: bookTask.friendlyReminder ? bookTask.friendlyReminder : '0', // 友好提醒设置 draft_content_json_path: draftPath, // 剪映草稿内容文件路径 key_frame_info: { key_frame: keyFrameSetting.keyFrame, // 关键帧类型 isFixedSpeed: keyFrameSetting.isFixedSpeed, // 是否固定速度 key_frame_time: keyFrameSetting.keyFrameTime, // 关键帧时间 // 上下移动动画配置 up_down_key_frame: { default_scale: keyFrameSetting.upDownKeyFrame.defaultScale, start_position: keyFrameSetting.upDownKeyFrame.startPosition, end_position: keyFrameSetting.upDownKeyFrame.endPosition }, // 左右移动动画配置 left_right_key_frame: { default_scale: keyFrameSetting.leftRightKeyFrame.defaultScale, start_position: keyFrameSetting.leftRightKeyFrame.startPosition, end_position: keyFrameSetting.leftRightKeyFrame.endPosition }, // 缩放动画配置 scale_key_frame: { default_scale: keyFrameSetting.scaleKeyFrame.defaultScale, start_position: keyFrameSetting.scaleKeyFrame.startPosition, end_position: keyFrameSetting.scaleKeyFrame.endPosition }, is_fixed_speed: keyFrameSetting.isFixedSpeed ? keyFrameSetting.isFixedSpeed : false } } } // 判断是否开启视频生成功能 let openVideo = bookTask.openVideoGenerate ?? false // ========== 处理每一帧的详细信息 ========== for (let i = 0; i < bookTaskDetail.length; i++) { const element = bookTaskDetail[i] // 验证时间信息完整性 if (element.startTime == null || element.endTime == null) { throw new Error(t('字幕时间信息不完整')) } // 验证字幕内容完整性 if (element.subValue == null) { throw new Error(t('字幕内容信息不完整')) } // 处理字幕内容:如果是字符串格式的JSON,则解析为对象 if (typeof element.subValue === 'string') { if (ValidateJson(element.subValue)) { element.subValue = JSON.parse(element.subValue) } else { throw new Error(t('字幕内容信息不完整')) } } // 构建单帧数据对象 let frameData = { no: element.no, // 帧序号 id: element.id, // 帧ID lastId: i == 0 ? '' : bookTaskDetail[i - 1].id, // 上一帧ID word: element.word, // 文字内容 old_image: element.oldImage, // 原始图片 after_gpt: element.afterGpt, // GPT处理后的内容 start_time: element.startTime, // 开始时间(秒) end_time: element.endTime, // 结束时间(秒) timeLimit: `${element.startTime} -- ${element.endTime}`, // 时间范围字符串 subValue: element.subValue, // 子字幕数组 character_tags: [], // 角色标签(预留) gpt_prompt: element.gptPrompt, // GPT提示词 mjMessage: element.mjMessage, // MJ消息 prompt_json: '', // 提示词JSON(预留) name: element.name + '.png', // 图片文件名 outImagePath: element.outImagePath, // 输出图片路径 generateVideoPath: openVideo ? element.generateVideoPath : null, // 生成视频路径(可选) subImagePath: element.subImagePath, // 子图片路径 scene_tags: [], // 场景标签(预留) imageLock: element.imageLock, // 图片锁定状态 prompt: element.prompt // 提示词 } configData.srt_time_information.push(frameData as any) } // ========== 保存配置文件 ========== // 将配置数据写入到指定路径的JSON文件中 await fs.promises.writeFile(configPath, JSON.stringify(configData), 'utf-8') // 同时复制一份到通用的config.json文件中,供其他程序使用 let configJsonPath = path.join(book.bookFolderPath as string, 'scripts/config.json') await CopyFileOrFolder(configPath, configJsonPath) return { draftName: draft_name, configJsonPath: configJsonPath } } catch (error) { throw error } } /** * 添加剪映草稿 * @param id * @param operateBookType * @returns */ async AddJianyingDraft(bookTaskId: string): Promise { try { await this.InitBookBasicHandle() let bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId) if (bookTask == null) { return errorMessage(t("未找到对应的小说批次任务"), 'BookVideoHandle_AddJianyingDraft') } let book = await this.bookService.GetBookDataById(bookTask.bookId as string) if (book == null) { return errorMessage(t("未找到指定ID的小说数据"), 'BookVideoHandle_AddJianyingDraft') } if (!isEmpty(bookTask.draftDepend) && bookTask.draftDepend != null) { if (bookTask.name == bookTask.draftDepend) { throw new Error(t('草稿名称不能和任务名称相同,请修改任务名称')) } await this.jianyingService.GenerateDraftFromDepend( bookTask.draftDepend as string, bookTask.imageFolder as string, bookTask.name as string ) return successMessage( bookTask.name, t("导出剪映草稿成功!"), 'BookVideoHandle_AddJianyingDraft' ) } let { draftName, configJsonPath } = await this.GenerateConfigFile(book, bookTask) // 开始调用 exe 执行 草稿的导出 let jianyingExePath = path.join(define.scripts_path, 'xiangbei_jianying_main.exe') if (!CheckFileOrDirExist(jianyingExePath)) { throw new Error(t('没有找到导出剪映的执行文件,请检查') + ':' + jianyingExePath) } let result: string = '' try { const env = { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONLEGACYWINDOWSSTDIO: 'utf-8', LANG: 'zh_CN.UTF-8', PYTHONUTF8: '1' } // 尝试最简单的执行方式,不使用chcp const simpleCommand = `"${jianyingExePath}" "${configJsonPath}"` const output = await execAsync(simpleCommand, { maxBuffer: 1024 * 1024 * 10, encoding: 'utf-8', env: env, cwd: path.dirname(jianyingExePath), timeout: 300000 }) if ( output.stderr && (output.stderr.includes('Error') || output.stderr.includes('failed') || output.stderr.includes('UnicodeEncodeError')) ) { throw new Error(output.stderr) } // 导出成功 let stdout = output.stdout // 将导出的日志写道文件里面 let exportLogPath = path.join( book.bookFolderPath as string, `scripts/JianYingExportLog/${draftName}_export_log_${new Date().getTime()}.txt` ) await CheckFolderExistsOrCreate(path.dirname(exportLogPath)) await fs.promises.writeFile(exportLogPath, stdout, 'utf-8') // 导出成功 将草稿名字返回 result = draftName } catch (fallbackError: any) { // 记录详细的错误信息到文件 const errorLogPath = path.join( book.bookFolderPath as string, `scripts/JianYingExportLog/error_${draftName}_${new Date().getTime()}.txt` ) await CheckFolderExistsOrCreate(path.dirname(errorLogPath)) const errorInfo = { execAsyncError: fallbackError.message, scriptPath: jianyingExePath, configPath: configJsonPath, timestamp: new Date().toISOString() } await fs.promises.writeFile(errorLogPath, JSON.stringify(errorInfo, null, 2), 'utf-8') throw new Error(JSON.stringify(errorInfo, null, 2)) } // 所有的草稿都添加完毕之后开始返回 return successMessage(result, t("导出剪映草稿成功!"), 'BookVideoHandle_AddJianyingDraft') } catch (error: any) { return errorMessage( t('导出剪映草稿失败:{error}', { error: error.message }), 'BookVideoHandle_AddJianyingDraft' ) } } }