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'
|
2025-09-12 14:52:28 +08:00
|
|
|
|
import { t } from '@/i18n'
|
2025-08-19 14:33:59 +08:00
|
|
|
|
|
2025-09-12 14:52:28 +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)) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
throw new Error(t('字幕时间信息不完整'))
|
2025-08-19 14:33:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证字幕内容完整性
|
|
|
|
|
|
if (element.subValue == null) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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 {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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)) {
|
2025-09-12 14:52:28 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
2025-09-12 14:52:28 +08:00
|
|
|
|
throw new Error(JSON.stringify(errorInfo, null, 2))
|
2025-08-19 14:33:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 所有的草稿都添加完毕之后开始返回
|
2025-09-12 14:52:28 +08:00
|
|
|
|
return successMessage(result, t("导出剪映草稿成功!"), 'BookVideoHandle_AddJianyingDraft')
|
2025-08-19 14:33:59 +08:00
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
return errorMessage(
|
2025-09-12 14:52:28 +08:00
|
|
|
|
t('导出剪映草稿失败:{error}', {
|
|
|
|
|
|
error: error.message
|
|
|
|
|
|
}),
|
2025-08-19 14:33:59 +08:00
|
|
|
|
'BookVideoHandle_AddJianyingDraft'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|