345 lines
14 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.

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<SettingModal.JianyingKeyFrameSettings>(keyFrameSettingOption)
// 获取通用设置(包含剪映草稿文件夹路径等)
let generalSettingOption = this.optionRealmService.GetOptionByKey(
OptionKeyName.Software.GeneralSetting
)
let generalSetting = optionSerialization<SettingModal.GeneralSettings>(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<ErrorItem | SuccessItem> {
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'
)
}
}
}