345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
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'
|
||
)
|
||
}
|
||
}
|
||
}
|