345 lines
14 KiB
TypeScript
Raw Normal View History

2025-08-19 14:33:59 +08:00
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'
2025-08-19 14:33:59 +08:00
export class BookExportHandle extends BookBasicHandle {
2025-08-19 14:33:59 +08:00
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('背景音乐文件夹或文件不存在,请检查'))
2025-08-19 14:33:59 +08:00
}
// 判断背景音乐是文件还是文件夹
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('背景音乐文件夹下面未存在有效的音频文件'))
2025-08-19 14:33:59 +08:00
} 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('字幕时间信息不完整'))
2025-08-19 14:33:59 +08:00
}
// 验证字幕内容完整性
if (element.subValue == null) {
throw new Error(t('字幕内容信息不完整'))
2025-08-19 14:33:59 +08:00
}
// 处理字幕内容如果是字符串格式的JSON则解析为对象
if (typeof element.subValue === 'string') {
if (ValidateJson(element.subValue)) {
element.subValue = JSON.parse(element.subValue)
} else {
throw new Error(t('字幕内容信息不完整'))
2025-08-19 14:33:59 +08:00
}
}
// 构建单帧数据对象
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')
2025-08-19 14:33:59 +08:00
}
let book = await this.bookService.GetBookDataById(bookTask.bookId as string)
if (book == null) {
return errorMessage(t("未找到指定ID的小说数据"), 'BookVideoHandle_AddJianyingDraft')
2025-08-19 14:33:59 +08:00
}
if (!isEmpty(bookTask.draftDepend) && bookTask.draftDepend != null) {
if (bookTask.name == bookTask.draftDepend) {
throw new Error(t('草稿名称不能和任务名称相同,请修改任务名称'))
2025-08-19 14:33:59 +08:00
}
await this.jianyingService.GenerateDraftFromDepend(
bookTask.draftDepend as string,
bookTask.imageFolder as string,
bookTask.name as string
)
return successMessage(
bookTask.name,
t("导出剪映草稿成功!"),
'BookVideoHandle_AddJianyingDraft'
)
2025-08-19 14:33:59 +08:00
}
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)
2025-08-19 14:33:59 +08:00
}
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))
2025-08-19 14:33:59 +08:00
}
// 所有的草稿都添加完毕之后开始返回
return successMessage(result, t("导出剪映草稿成功!"), 'BookVideoHandle_AddJianyingDraft')
2025-08-19 14:33:59 +08:00
} catch (error: any) {
return errorMessage(
t('导出剪映草稿失败:{error}', {
error: error.message
}),
2025-08-19 14:33:59 +08:00
'BookVideoHandle_AddJianyingDraft'
)
}
}
}