1. 新增 图/文转视频 菜单界面,专注实现图/文转视频(目前只集成了 MJ VIDEO)
  1. 全新的界面排列,小说列表和批次任务更加分明
  2. 添加转视频进度,在主界面即可看到转视频的比例
  3. 单独的界面去处理图转视频,避免表格数据过多繁琐
  4. 新增分页显示,界面加载更快,也可切换不分页,需要更多的事件等待加载
  5. 单独操作面板,参数修改处理更加清晰,支持多种模式显示,右侧固定或抽屉模式
  6. 批量设置转视频配置,可以批量修改分类
  7. 友好的选择视频界面
2. 重写 软件导出剪映,修复若干草稿导出问题
  1. 修复导出剪映文案和图片对齐会有些许对不上,时长越长越明显
  2. 修复导出草稿关键帧部分问题
  3. 导出的文案通过分镜自动导入,不再需要手动选择SRT
3. 美化 生成草稿界面 弹窗,优化部分逻辑
  1. 删除选择SRT文件,SRT根据聚合推文中导入的SRT自动生成草稿
  2. 只需选择配音文件即可,配音文件和导入的SRT请自行对应
  3. 背景音乐不在内部设置,自行选择文件夹或者是MP3、WAV文件
  4. 背景音乐选择文件夹则读取文件夹,随机获取一个
  5. 背景音乐选择指定的音乐文件则使用选择的
This commit is contained in:
lq1405 2025-08-09 18:46:07 +08:00
parent c1d6fe181d
commit 82ec437b5d
81 changed files with 6800 additions and 850 deletions

View File

@ -1,6 +1,6 @@
{
"name": "laitool",
"version": "3.4.1",
"version": "3.4.2",
"description": "An AI tool for image processing, video processing, and other functions.",
"main": "./out/main/index.js",
"author": "laitool.cn",
@ -89,6 +89,7 @@
"resources/image/zhanwei.png",
"resources/scripts/model/**",
"resources/scripts/Lai.exe",
"resources/scripts/xiangbei_jianying_main.exe",
"resources/scripts/discordScript.js",
"resources/tmp/**",
"resources/icon.ico"

View File

@ -121,6 +121,10 @@ elif sys.argv[1] == "-ka":
shotSplit.get_fram(sys.argv[2], sys.argv[3], sys.argv[4])
pass
elif sys.argv[1] == "-df":
shotSplit.get_fram(sys.argv[2], sys.argv[3], sys.argv[4])
pass
# # 智能分镜。字幕识别
# elif sys.argv[1] == "-a":
# print("开始算法分镜:" + sys.argv[2] + " -- 输出文件夹:" + sys.argv[3])

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@ let apiUrl = [
isPackage: false,
mj_url: {
imagine: 'https://api.laitool.cc/mj/submit/imagine',
video: 'https://api.laitool.cc/mj/submit/video',
describe: 'https://api.laitool.cc/mj/submit/describe',
update_file: 'https://api.laitool.cc/mj/submit/upload-discord-images',
once_get_task: 'https://api.laitool.cc/mj/task/${id}/fetch'
@ -22,6 +23,7 @@ let apiUrl = [
isPackage: false,
mj_url: {
imagine: 'https://laitool.net/mj/submit/imagine',
video: 'https://laitool.net/mj/submit/video',
describe: 'https://laitool.net/mj/submit/describe',
update_file: 'https://laitool.net/mj/submit/upload-discord-images',
once_get_task: 'https://laitool.net/mj/task/${id}/fetch'

View File

@ -1,10 +1,24 @@
export const SoftwareData = {
"version": "V3.4.1",
"date": "2025-07-08",
"version": "V3.4.2",
"date": "2025-08-08",
"notes": [
"1. 适配 MJ V7 版本的 oref 参数",
"2. 恢复超级单证中文版推理模式",
"3. 出图进度添加本地图片文件是否存在的判断",
"4. 新增 推理模式 Laitool提示词专家-全能优化版"
"1. 新增图/文转视频菜单界面,专注实现图/文转视频(目前只集成了 MJ VIDEO",
" • 全新的界面排列,小说列表和批次任务更加分明",
" • 添加转视频进度,在主界面即可看到转视频的比例",
" • 单独的界面去处理图转视频,避免表格数据过多繁琐",
" • 新增分页显示,界面加载更快,也可切换不分页",
" • 单独操作面板,参数修改处理更加清晰,支持多种模式显示",
" • 批量设置转视频配置,可以批量修改分类",
" • 友好的选择视频界面",
"2. 重写软件导出剪映,修复若干草稿导出问题",
" • 修复导出剪映文案和图片对齐问题,解决时长越长越明显的对不上问题",
" • 修复导出草稿关键帧部分问题",
" • 导出的文案通过分镜自动导入不再需要手动选择SRT",
"3. 美化生成草稿界面弹窗,优化部分逻辑",
" • 删除选择SRT文件SRT根据聚合推文中导入的SRT自动生成草稿",
" • 只需选择配音文件即可配音文件和导入的SRT请自行对应",
" • 背景音乐不在内部设置自行选择文件夹或者是MP3、WAV文件",
" • 背景音乐选择文件夹则读取文件夹,随机获取一个",
" • 背景音乐选择指定的音乐文件则使用选择的"
]
}

View File

@ -16,6 +16,8 @@ export class BookBackTaskList extends Realm.Object<BookBackTaskList> {
startTime: number
endTime: number
messageName?: string
taskId?: string // 任务ID可能是视频生成任务的ID
taskMessage?: string // 任务消息,可能是视频生成任务的消息
static schema: ObjectSchema = {
name: 'BookBackTaskList',
@ -33,7 +35,9 @@ export class BookBackTaskList extends Realm.Object<BookBackTaskList> {
updateTime: 'date',
startTime: 'int',
endTime: 'int',
messageName: 'string?'
messageName: 'string?',
taskId: 'string?', // 任务ID可能是视频生成任务的ID
taskMessage: 'string?', // 任务消息,可能是视频生成任务的消息
},
primaryKey: 'id'
}

View File

@ -260,6 +260,32 @@ export class BookBackTaskListService extends BaseRealmService {
}
}
/**
*
* @param taskId ID
* @param backTaskParam
*/
UpdateBackTaskData(taskId: string, backTaskParam: Partial<TaskModal.Task>): void {
this.transaction(() => {
// 根据ID获取后台任务
let backTask = this.realm.objectForPrimaryKey('BookBackTaskList', taskId)
// 检查任务是否存在
if (backTask == null) {
throw new Error('更新后台任务数据失败,未找到对应的任务')
}
// 遍历需要更新的字段
for (const key in backTaskParam) {
// 跳过ID字段防止主键被修改
if (key == "id") {
continue;
}
// 更新对应字段的值
backTask[key] = backTaskParam[key]
}
})
}
/**
* idbookIdbookTaskId
*

View File

@ -281,6 +281,15 @@ const migration = (oldRealm: Realm, newRealm: Realm) => {
}
}
if (oldRealm.schemaVersion < 44) {
const oldBookTask = oldRealm.objects('BookBackTaskList')
const newBookTask = newRealm.objects('BookBackTaskList')
for (let i = 0; i < oldBookTask.length; i++) {
newBookTask[i].taskId = undefined;
newBookTask[i].taskMessage = undefined;
}
}
}
export class BaseRealmService extends BaseService {
@ -323,7 +332,7 @@ export class BaseRealmService extends BaseService {
VideoMessage
],
path: this.dbpath,
schemaVersion: 43,
schemaVersion: 44,
migration: migration
}
this.realm = await Realm.open(config)

View File

@ -12,6 +12,7 @@ import { FfmpegOptions } from '../../../../main/Service/ffmpegOptions.js'
import { version } from '../../../../../package.json'
import { Book } from '../../../../model/book/book.js'
import { GeneralResponse } from '../../../../model/generalResponse.js'
import { ImageToVideoModels } from '@/define/enum/video.js'
export class BookService extends BaseRealmService {
static instance: BookService | null = null
@ -210,6 +211,8 @@ export class BookService extends BaseRealmService {
throw new Error('未知的小说类型')
}
let videoCategory = ImageToVideoModels.MJ_VIDEO;
this.realm.write(() => {
book.version = version
this.realm.create('Book', book)
@ -233,6 +236,7 @@ export class BookService extends BaseRealmService {
createTime: new Date(),
version: version,
imageCategory: imageCategory,
videoCategory: videoCategory,
openVideoGenerate: false
}

View File

@ -11,6 +11,7 @@ const { v4: uuidv4 } = require('uuid')
import { Book } from "../../../../model/book/book"
import { GeneralResponse } from '../../../../model/generalResponse.js'
import { BookTaskDetail } from '@/model/book/bookTaskDetail'
import { ValidateJson } from '@/define/Tools/validate'
let dbPath = path.resolve(define.db_path, 'book.realm')
@ -73,8 +74,21 @@ export class BookTaskDetailService extends BaseRealmService {
subImagePath: (item.subImagePath as string[])?.map((subImage) => {
return JoinPath(define.project_path, subImage)
}),
subVideoPath: (item.subVideoPath as string[])?.map((subVideo) => {
return JoinPath(define.project_path, subVideo)
subVideoPath: (item.subVideoPath as string[]).map((subVideo) => subVideo.toString()),
subVideoPathObject: (item.subVideoPath as string[])?.map((subVideo) => {
if (isEmpty(subVideo)) {
return {};
} else {
if (!ValidateJson(subVideo)) {
return {};
} else {
let obj = JSON.parse(subVideo);
if (!isEmpty(obj.localPath)) {
obj.localPath = JoinPath(define.project_path, obj.localPath) + '?t=' + new Date().getTime();
}
return obj
}
}
}),
characterTags: item.characterTags ? item.characterTags.map((tag) => tag) : null,
sceneTags: item.sceneTags ? item.sceneTags.map((tag) => tag) : null,
@ -257,6 +271,7 @@ export class BookTaskDetailService extends BaseRealmService {
*/
UpdateBookTaskDetailVideoMessage(bookTaskDetailId: string, videoMessage: BookTaskDetail.VideoMessage): void {
this.transaction(() => {
console.log("开始更新小说分镜的视频消息", bookTaskDetailId, videoMessage)
let bookTaskDetail = this.realm.objectForPrimaryKey('BookTaskDetail', bookTaskDetailId)
let videoMessageRes = this.realm.objectForPrimaryKey('VideoMessage', bookTaskDetailId)
if (bookTaskDetail.videoMessage == null) {

View File

@ -193,14 +193,20 @@ const BOOK = {
/** 修改小说分镜的VideoMessage */
UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE: "UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE",
/** 重新下载视频任务 */
RELOAD_VIDEO_TASK_INFO: "RELOAD_VIDEO_TASK_INFO",
/** Runway图转视频返回前端数据任务 */
RUNWAY_IMAGE_TO_VIDEO_RETURN: "RUNWAY_IMAGE_TO_VIDEO_RETURN",
/** MJ VIDEO 图转视频返回前端数据任务 */
MJ_VIDEO_TO_VIDEO_RETURN: "MJ_VIDEO_TO_VIDEO_RETURN",
/** 获取指定的条件的图转视频的数据,包含字批次 */
GET_VIDEO_BOOK_INFO_LIST: "GET_VIDEO_BOOK_INFO_LIST",
/** 获取小说图片和视频生成进度 */
GET_BOOK_IMAGE_AND_VIDEO_PROGRESS : "GET_BOOK_IMAGE_AND_VIDEO_PROGRESS"
GET_BOOK_IMAGE_AND_VIDEO_PROGRESS: "GET_BOOK_IMAGE_AND_VIDEO_PROGRESS"
//#endregion

View File

@ -17,4 +17,7 @@ export const SYSTEM = {
/** 选择多个指定文件后缀的文件 */
SELECT_MULTIPLE_FILE: "SELECT_MULTIPLE_FILE",
/** 选择文件夹或指定后缀的文件 */
SELECT_FOLDER_OR_FILE: "SELECT_FOLDER_OR_FILE",
}

View File

@ -100,6 +100,10 @@ export enum BookBackTaskType {
LUMA_VIDEO = 'luma_video',
// kling 生成视频
KLING_VIDEO = 'kling_video',
// MJ Video
MJ_VIDEO = 'mj_video',
// MJ VIDEO EXTEND 视频拓展
MJ_VIDEO_EXTEND = 'mj_video_extend'
}

View File

@ -5,9 +5,10 @@ export enum OptionType {
STRING = 'string',
NUMBER = 'number',
BOOLEAN = 'boolean',
JOSN = 'json'
JSON = 'json'
}
export enum OptionKeyName {
//#region 文案处理
@ -79,7 +80,17 @@ export enum OptionKeyName {
/**
* ComfyUI
*/
ComfyUI_WorkFlowSetting = "ComfyUI_WorkFlowSetting"
ComfyUI_WorkFlowSetting = "ComfyUI_WorkFlowSetting",
//#endregion
//#region Image To Video
/** 是否显示右侧的Image To Video 操作面板 */
ImageToVideo_ShowRightPanel = 'ImageToVideo_ShowRightPanel',
/** 是否显示分页 */
ImageToVideo_ShowPagination = 'ImageToVideo_ShowPagination',
//#endregion
}

View File

@ -69,6 +69,8 @@ export enum ResponseMessageType {
RUNWAY_VIDEO = "RUNWAY_VIDEO",// Runway生成视频
LUMA_VIDEO = "LUMA_VIDEO",// Luma生成视频
KLING_VIDEO = "KLING_VIDEO",// Kling生成视频
MJ_VIDEO = "MJ_VIDEO",// MJ生成视频
MJ_VIDEO_EXTEND = "MJ_VIDEO_EXTEND",// MJ生成视频拓展
VIDEO_SUCESS = "VIDEO_SUCESS" //视频生成成功
}

View File

@ -1,6 +1,8 @@
//#region 图转视频类型
import { BookBackTaskType } from "./bookEnum";
/** 图片转视频的方式 */
export enum ImageToVideoModels {
/** runway 生成视频 */
@ -12,7 +14,27 @@ export enum ImageToVideoModels {
/** Pika 生成视频 */
PIKA = "PIKA",
/** MJ 图转视频 */
MJ_VIDEO = "MJ_VIDEO"
MJ_VIDEO = "MJ_VIDEO",
/** MJ 视频拓展 */
MJ_VIDEO_EXTEND = "MJ_VIDEO_EXTEND"
}
export const MappingTaskTypeToVideoModel = (type: BookBackTaskType | string) => {
switch (type) {
case BookBackTaskType.LUMA_VIDEO:
return ImageToVideoModels.LUMA;
case BookBackTaskType.RUNWAY_VIDEO:
return ImageToVideoModels.RUNWAY;
case BookBackTaskType.KLING_VIDEO:
return ImageToVideoModels.KLING;
case BookBackTaskType.MJ_VIDEO:
return ImageToVideoModels.MJ_VIDEO;
case BookBackTaskType.MJ_VIDEO_EXTEND:
return ImageToVideoModels.MJ_VIDEO_EXTEND;
default:
return "UNKNOWN"
}
}
/**
@ -48,11 +70,11 @@ export const GetImageToVideoModelsLabel = (model: ImageToVideoModels | string) =
*/
export const GetImageToVideoModelsOptions = () => {
return [
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.MJ_VIDEO), value: ImageToVideoModels.MJ_VIDEO },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.RUNWAY), value: ImageToVideoModels.RUNWAY },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.LUMA), value: ImageToVideoModels.LUMA },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.KLING), value: ImageToVideoModels.KLING },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.PIKA), value: ImageToVideoModels.PIKA },
{ label: GetImageToVideoModelsLabel(ImageToVideoModels.MJ_VIDEO), value: ImageToVideoModels.MJ_VIDEO }
]
}
@ -127,3 +149,61 @@ export enum KlingMode {
}
//#endregion
//#region MJ Video
/**
* indextaskId必填
*/
export enum MJVideoAction {
Extend = "extend",
}
/**
*
*/
export enum MJVideoImageType {
Base64 = "base64",
Url = "url",
}
/**
* MJ Video的动作幅度
*/
export enum MJVideoMotion {
High = "high",
Low = "low",
}
/**
* MJ视频动作幅度的标签
*
* @param model MJ视频动作幅度枚举值或字符串
* @returns
*/
export function GetMJVideoMotionLabel(model: MJVideoMotion | string) {
switch (model) {
case MJVideoMotion.High:
return "高 (High)";
case MJVideoMotion.Low:
return "低 (Low)";
default:
return "无效"
}
}
/**
* MJ视频动作幅度的选项列表
*
* @returns UI组件
*/
export function GetMJVideoMotionOptions() {
return [
{
label: GetMJVideoMotionLabel(MJVideoMotion.Low), value: MJVideoMotion.Low
}, {
label: GetMJVideoMotionLabel(MJVideoMotion.High), value: MJVideoMotion.High
}
]
}
//#endregion

View File

@ -371,6 +371,9 @@ export function BookIpc() {
/** 修改小说详细分镜的Videomessage */
ipcMain.handle(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, async (event, bookTaskDetailId, videoMessage) => await videoGlobal.UpdateBookTaskDetailVideoMessage(bookTaskDetailId, videoMessage))
/** 重新下载视频任务 */
ipcMain.handle(DEFINE_STRING.BOOK.RELOAD_VIDEO_TASK_INFO, async (_, bookTaskDetailId) => await bookImageTextToVideoIndex.ReloadVideoTaskInfo(bookTaskDetailId))
/** 获取指定的条件的图转视频的数据,包含子批次 */
ipcMain.handle(DEFINE_STRING.BOOK.GET_VIDEO_BOOK_INFO_LIST, async (event,
condition: BookVideo.BookVideoInfoListQuertCondition) => await bookImageTextToVideoIndex.GetVideoBookInfoList(condition))

View File

@ -34,5 +34,8 @@ function SystemIpc() {
/** 选择多个指定文件后缀的文件 */
ipcMain.handle(DEFINE_STRING.SYSTEM.SELECT_MULTIPLE_FILE, async (event, value: string[]) => await electronInterface.SelectMultipleFile(value))
/** 选择文件夹或指定后缀的文件 */
ipcMain.handle(DEFINE_STRING.SYSTEM.SELECT_FOLDER_OR_FILE, async (event, value?: string[]) => await electronInterface.SelectFolderOrFile(value))
}
export { SystemIpc }

View File

@ -0,0 +1,64 @@
import { errorMessage, successMessage } from "@/main/Public/generalTools";
import { BookBasicHandle } from "../bookBasicHandle";
import { ImageToVideoModels } from "@/define/enum/video";
import { MJVideoService } from "../../video/mjVideo";
import { isEmpty } from "lodash";
import { GeneralResponse } from "@/model/generalResponse";
export class BookImageTextToVideoCategory extends BookBasicHandle {
mjVideoService: MJVideoService
constructor() {
super();
this.mjVideoService = new MJVideoService();
}
/**
*
*
* ID重新获取视频任务的信息
* ID是否存在
*
*
* @param bookTaskDetailId - ID
* @returns
* @throws
*/
async ReloadVideoTaskInfo(bookTaskDetailId: string) {
try {
await this.InitBookBasicHandle()
let bookTaskDetail = this.bookTaskDetailService.GetBookTaskDetailDataById(bookTaskDetailId);
if (bookTaskDetail == null) {
return errorMessage('没有找到对应的小说分镜数据,请先添加小说分镜', 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
let videoMessage = bookTaskDetail.videoMessage;
if (videoMessage == null) {
return errorMessage('没有找到对应的小说分镜的视频消息数据,请先添加视频消息', 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
if (isEmpty(videoMessage.taskId)) {
return errorMessage('没有找到对应的小说分镜的视频任务ID请先添加视频任务', 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
let res: GeneralResponse.ErrorItem | GeneralResponse.SuccessItem;
switch (videoMessage.videoType) {
case ImageToVideoModels.MJ_VIDEO:
res = await this.mjVideoService.ReloadMJVideoTask(bookTaskDetail, videoMessage.taskId);
break;
default:
return errorMessage('不支持的视频类型,请检查视频类型', 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
// 检查返回结果
if (res.code != 1) {
return errorMessage(res.message, 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
return successMessage(res.data, res.message, 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
} catch (error) {
return errorMessage('重新下载视频任务失败,错误信息:' + error.message, 'BookImageTextToVideoCategory_ReloadVideoTaskInfo');
}
}
}

View File

@ -1,12 +1,15 @@
import { BookImageTextToVideoInfo } from "./bookImageTextToVideoInfo";
import { BookImageTextToVideoCategory } from "./bookImageTextToVideoCategory";
export class BookImageTextToVideoIndex {
bookImageTextToVideoInfo: BookImageTextToVideoInfo;
bookImageTextToVideoCategory: BookImageTextToVideoCategory
constructor() {
this.bookImageTextToVideoInfo = new BookImageTextToVideoInfo();
this.bookImageTextToVideoCategory = new BookImageTextToVideoCategory();
}
//#region Info
@ -15,8 +18,15 @@ export class BookImageTextToVideoIndex {
GetVideoBookInfoList = async (condition: BookVideo.BookVideoInfoListQuertCondition) => await this.bookImageTextToVideoInfo.GetVideoBookInfoList(condition)
/** 获取小说图片和视频生成的进度信息 根据提供的参数查询指定小说或小说任务的图片和视频生成进度 */
GetBookImageAndVideoProgress = async (bookId?: string, bookTaskId?: string) => await this.bookImageTextToVideoInfo.GetBookImageAndVideoProgress(bookId, bookTaskId);
//#endregion
//#region Category
ReloadVideoTaskInfo = async (bookTaskDetailId: string) => await this.bookImageTextToVideoCategory.ReloadVideoTaskInfo(bookTaskDetailId);
//#endregion
}

View File

@ -234,7 +234,7 @@ export class BookImageTextToVideoInfo extends BookBasicHandle {
imageProgress += 1;
}
// 检查视频信息
if (!isEmpty(bookTaskDetail.videoPath) && await CheckFileOrDirExist(bookTaskDetail.videoPath)) {
if (!isEmpty(bookTaskDetail.generateVideoPath) && await CheckFileOrDirExist(bookTaskDetail.generateVideoPath)) {
videoProgress += 1;
}
}

View File

@ -135,7 +135,6 @@ export class ReverseBook {
...item,
outImagePath: isEmpty(item.outImagePath) ? item.outImagePath : item.outImagePath + '?t=' + new Date().getTime(),
subImagePath: item.subImagePath && item.subImagePath.length > 0 ? item.subImagePath.map(it => it + '?t=' + new Date().getTime()) : item.subImagePath,
subVideoPath: item.subVideoPath && item.subVideoPath.length > 0 ? item.subVideoPath.map(it => it + '?t=' + new Date().getTime()) : item.subVideoPath,
}
})

View File

@ -2,13 +2,14 @@ import { BookService } from "@/define/db/service/Book/bookService"
import { BookTaskDetailService } from "@/define/db/service/Book/bookTaskDetailService"
import { BookTaskService } from "@/define/db/service/Book/bookTaskService"
import { OptionRealmService } from "@/define/db/service/SoftWare/optionRealmService"
import { BookBackTaskListService } from "@/define/db/service/Book/bookBackTaskListService"
export class BookBasicHandle {
bookTaskDetailService!: BookTaskDetailService
bookTaskService!: BookTaskService
optionRealmService!: OptionRealmService
bookService!: BookService
bookBackTaskListService!: BookBackTaskListService
constructor() {
// 初始化
@ -28,6 +29,9 @@ export class BookBasicHandle {
if (!this.bookService) {
this.bookService = await BookService.getInstance()
}
if (!this.bookBackTaskListService) {
this.bookBackTaskListService = await BookBackTaskListService.getInstance()
}
}
async transaction(callback: (realm: any) => void) {

View File

@ -11,6 +11,7 @@ import { BookServiceBasic } from "../ServiceBasic/bookServiceBasic";
import { ValidateJson } from "../../../define/Tools/validate";
import fs from 'fs'
import { TimeStringToMilliseconds } from "../../../define/Tools/time";
import { ImageToVideoModels } from "@/define/enum/video";
/**
*
@ -123,6 +124,7 @@ export class BookTask {
let name = 'output_' + no.toString().padStart(5, '0');
let imageFolder = path.join(define.project_path, `${bookTask.bookId}/tmp/${name}`);
let imageCategory = global.config.defaultImageMode ?? BookImageCategory.MJ;
let videoCategory = ImageToVideoModels.MJ_VIDEO;
let book = await this.bookServiceBasic.GetBookDataById(bookTask.bookId)
if (!isEmpty(bookTask.imageCategory)) {
imageCategory = bookTask.imageCategory;
@ -136,6 +138,10 @@ export class BookTask {
}
}
if(!isEmpty(bookTask.videoCategory)){
videoCategory = bookTask.videoCategory;
}
let newBookTask = {
id: uuidv4(),
bookId: bookTask.bookId,
@ -157,6 +163,7 @@ export class BookTask {
prefixPrompt: addNewBookTask.prefixPrompt ??= undefined,
suffixPrompt: addNewBookTask.suffixPrompt ?? undefined,
imageCategory: imageCategory,
videoCategory : videoCategory,
subImageFolder: [],
draftSrtStyle: undefined,
backgroundMusic: bookTask.backgroundMusic ??= undefined,

View File

@ -18,6 +18,7 @@ import util from 'util';
import { spawn, exec } from 'child_process';
import { SendMessageToRenderer } from "../globalService";
import { TaskModal } from "@/model/task";
import compressing from "compressing";
const execAsync = util.promisify(exec);
export class BookVideo {
@ -25,12 +26,68 @@ export class BookVideo {
bookServiceBasic: BookServiceBasic
jianyingService: JianyingService
bookSetting: BookSetting
constructor() {
this.setting = new Setting(global)
this.bookServiceBasic = new BookServiceBasic()
this.jianyingService = new JianyingService()
}
/**
*
* @param scriptPath
* @param configPath
* @returns Promise<{stdout: string, stderr: string}>
*/
private async executeScript(scriptPath: string, configPath: string): Promise<{ stdout: string, stderr: string }> {
return new Promise((resolve, reject) => {
// 设置环境变量
const env = {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONLEGACYWINDOWSSTDIO: 'utf-8',
LANG: 'zh_CN.UTF-8',
PYTHONUTF8: '1'
};
// 使用spawn方式执行更好地控制进程
const child = spawn(scriptPath, [configPath.replaceAll("\\", '/')], {
env: env,
cwd: path.dirname(scriptPath),
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString('utf8');
});
child.stderr?.on('data', (data) => {
stderr += data.toString('utf8');
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`脚本执行失败,退出代码: ${code}, 错误信息: ${stderr}`));
}
});
child.on('error', (error) => {
reject(new Error(`无法启动脚本: ${error.message}`));
});
// 设置超时
setTimeout(() => {
child.kill();
reject(new Error('脚本执行超时'));
}, 300000); // 5分钟超时
});
}
//#region 引用主小说相关数据
/**
@ -100,7 +157,10 @@ export class BookVideo {
* @param book
* @param bookTask
*/
private async GenerateConfigFile(book: Book.SelectBook, bookTask: Book.SelectBookTask): Promise<void> {
private async GenerateConfigFile(book: Book.SelectBook, bookTask: Book.SelectBookTask): Promise<{
draftName: string;
configJsonPath: string;
}> {
try {
// 先修改通用设置
let saveProjectRes = await this.setting.ModifySampleSetting(JSON.stringify({
@ -111,7 +171,6 @@ export class BookVideo {
throw new Error("修改通用设置失败")
}
// 开始生成配置文件
let configPath = path.join(book.bookFolderPath, `scripts/${bookTask.name}_config.json`);
await CheckFolderExistsOrCreate(path.dirname(configPath));
@ -120,17 +179,71 @@ export class BookVideo {
bookTaskId: bookTask.id
});
let musicPath: string | undefined = undefined;
// 处理背景音乐
if (!isEmpty(bookTask.backgroundMusic)) {
// 判断文件或者是文件夹是不是存在
if (!CheckFileOrDirExist(bookTask.backgroundMusic)) {
throw new Error("背景音乐文件夹或文件不存在,请检查");
}
// 判断背景音乐是文件夹还是文件 文件的话 就直接赋值 文件夹的话 随机一个文件
let isFolder = await fs.promises.stat(bookTask.backgroundMusic).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("背景音乐文件夹下面未存在有效的音频文件");
} else {
const randomIndex = Math.floor(Math.random() * files.length);
musicPath = files[randomIndex];
}
}
}
// 处理草稿文件
let draft_name = `${book.name}_${bookTask.name}`;
let draft_path = path.join(global.config.draft_path, draft_name);
await fs.promises.rm(draft_path, { recursive: true, force: true });
await compressing.zip.uncompress(define.draft_temp_path, path.join(global.config.draft_path, draft_name));
let draftPath = path.join(draft_path, "draft_content.json");
// 处理关键帧数据
let key_frame_setting_str = await fs.promises.readFile(define.clip_setting, 'utf-8');
if (!ValidateJson(key_frame_setting_str)) {
throw new Error("关键帧配置文件格式错误,请检查");
}
let key_frame_setting = JSON.parse(key_frame_setting_str)
let key_frame = key_frame_setting.key_frame;
// 判断关键帧配置是不是存在。不存在直接结束
if (key_frame == null) {
throw new Error("没有找到关键帧配置,请检查");
}
let newKeyFrame = {
...key_frame,
is_fixed_speed: key_frame.isFixedSpeed ? key_frame.isFixedSpeed : false,
}
let configData = {
srt_time_information: [],
video_config: {
srt_path: bookTask.srtPath,
audio_path: bookTask.audioPath,
draft_srt_style: bookTask.draftSrtStyle ? bookTask.draftSrtStyle : "0",
background_music: bookTask.backgroundMusic,
background_music: musicPath,
friendly_reminder: bookTask.friendlyReminder ? bookTask.friendlyReminder : "0",
draft_content_json_path: draftPath,
key_frame_info: newKeyFrame,
}
}
// 用于判断是不是开启视频合成,是不是给视频路径
let openVideo = bookTask.openVideoGenerate ?? false;
for (let i = 0; i < bookTaskDetail.length; i++) {
const element = bookTaskDetail[i];
let frameData = {
@ -150,6 +263,7 @@ export class BookVideo {
prompt_json: '',
name: element.name + '.png',
outImagePath: element.outImagePath,
generateVideoPath: openVideo ? element.generateVideoPath : null,
subImagePath: element.subImagePath,
scene_tags: [],
imageLock: element.imageLock,
@ -158,9 +272,14 @@ export class BookVideo {
configData.srt_time_information.push(frameData)
}
// 完毕,将数据写出
await fs.promises.writeFile(configPath, JSON.stringify(configData));
await fs.promises.writeFile(configPath, JSON.stringify(configData), 'utf-8');
let configJsonPath = path.join(book.bookFolderPath, 'scripts/config.json');
// 复制一个到config.json中
await CopyFileOrFolder(configPath, path.join(book.bookFolderPath, 'scripts/config.json'));
await CopyFileOrFolder(configPath, configJsonPath);
return {
draftName: draft_name,
configJsonPath: configJsonPath
}
} catch (error) {
throw error
}
@ -227,40 +346,84 @@ export class BookVideo {
element.imageFolder, draft_name);
result.push(draft_name);
} else {
await this.GenerateConfigFile(book, element);
// 数据处理完毕,开始输出
let clipDraft = new ClipDraft(global, [element.name, {
srt_path: operateBookType == OperateBookType.ASSIGNBOOKTASK ? book.srtPath : element.srtPath,
audio_path: operateBookType == OperateBookType.ASSIGNBOOKTASK ? book.audioPath : element.audioPath,
draft_srt_style: operateBookType == OperateBookType.ASSIGNBOOKTASK ? (book.draftSrtStyle ? book.draftSrtStyle : '0') : (element.draftSrtStyle ? element.draftSrtStyle : "0"),
background_music: operateBookType == OperateBookType.ASSIGNBOOKTASK ? book.backgroundMusic : element.backgroundMusic,
friendly_reminder: operateBookType == OperateBookType.ASSIGNBOOKTASK ? (book.friendlyReminder ? book.bookFolderPath : '0') : (element.friendlyReminder ? element.friendlyReminder : "0"),
}])
let res = await clipDraft.addDraft();
if (res.code == 0) {
throw new Error(res.message)
}
result.push(res.draft_name);
let { draftName, configJsonPath } = await this.GenerateConfigFile(book, element);
// 开始调用 exe 执行 草稿的导出
let jianyingExePath = path.join(define.scripts_path, "xiangbei_jianying_main.exe");
if (!CheckFileOrDirExist(jianyingExePath)) {
throw new Error("没有找到导出剪映的执行文件,请检查");
}
let bookTaskDetails = await this.bookServiceBasic.GetBookTaskDetailData({
bookTaskId: element.id
// 开始执行exe
try {
// 首先尝试使用spawn方法执行
const output = await this.executeScript(jianyingExePath, configJsonPath);
// 检查stderr是否真的是错误
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, `scripts/JianYingExportLog/${draftName}_export_log_${new Date().getTime()}.txt`);
await CheckFolderExistsOrCreate(path.dirname(exportLogPath));
await fs.promises.writeFile(exportLogPath, stdout, 'utf-8');
// 导出成功 将草稿名字返回
result.push(draftName);
} catch (execError) {
// 如果spawn方法失败尝试使用原来的execAsync方法作为备用
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
});
let repalceObject: ReplaceOnject[] = []
for (let i = 0; i < bookTaskDetails.length; i++) {
const bookTaskDetail = bookTaskDetails[i];
if (!isEmpty(bookTaskDetail.generateVideoPath) && await CheckFileOrDirExist(bookTaskDetail.generateVideoPath)) {
repalceObject.push({
materialName: path.basename(bookTaskDetail.outImagePath),
videoPath: bookTaskDetail.generateVideoPath,
imagePath: bookTaskDetail.outImagePath
})
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, `scripts/JianYingExportLog/${draftName}_export_log_${new Date().getTime()}.txt`);
await CheckFolderExistsOrCreate(path.dirname(exportLogPath));
await fs.promises.writeFile(exportLogPath, stdout, 'utf-8');
// 导出成功 将草稿名字返回
result.push(draftName);
} catch (fallbackError) {
// 记录详细的错误信息到文件
const errorLogPath = path.join(book.bookFolderPath, `scripts/JianYingExportLog/error_${draftName}_${new Date().getTime()}.txt`);
await CheckFolderExistsOrCreate(path.dirname(errorLogPath));
const errorInfo = {
spawnError: execError.message,
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(`所有执行方法都失败了。详细错误已记录到: ${errorLogPath}`);
}
}
// 这边操作草稿,修改数据(把图片替换为视频)
if (repalceObject && repalceObject.length > 0) {
await this.jianyingService.ReplaceDraftMaterialImageToVideo(book.name + "_" + element.name, repalceObject);
}
}
// 所有的草稿都添加完毕之后开始返回

View File

@ -19,6 +19,7 @@ class MJApi {
mjSimpleSetting: MJSettingModel.MjSimpleSettingModel
bootType: string
imagineUrl: string
videoUrl: string
fetchTaskUrl: string
describeUrl: string
@ -56,6 +57,51 @@ class MJApi {
return randomAccountId
}
async InitMJAPISetting() {
let defaultApiUrl = GetMJUrlOptions("api");
// 获取自定义的API的地址
let customApiUrl = await this.optionServices.GetOptionByKey(OptionKeyName.MJ_CustomAPISetting);
if (customApiUrl.code == 0) {
throw new Error("加载MJ设置失败失败原因如下" + customApiUrl.message)
}
if (!(customApiUrl.data == null || isEmpty(customApiUrl.data.value) || !ValidateJson(customApiUrl.data.value))) {
let customApiUrlData = ValidateJsonAndParse(customApiUrl.data.value) as any[];
customApiUrlData.forEach((item: any) => {
let baseUrl = item.baseUrl.replace(/\/$/, '')
defaultApiUrl.push({
label: item.name,
value: item.id,
isPackage: true,
mj_url: {
imagine: baseUrl + '/mj/submit/imagine',
video: baseUrl + '/mj/submit/video',
describe: baseUrl + '/mj/submit/describe',
update_file: baseUrl + '/mj/submit/upload-discord-images',
once_get_task: baseUrl + '/mj/task/${id}/fetch',
query_url: null
} as any,
buy_url: null
})
})
}
let apiUrlIndex = defaultApiUrl.findIndex(item => item.value == this.mj_globalSetting.mj_apiSetting.mjApiUrl);
if (apiUrlIndex == -1) {
throw new Error('没有找到MJ API对应的请求URL请检查配置');
}
let apiUrlItem = defaultApiUrl[apiUrlIndex];
if (apiUrlItem.mj_url == null) {
throw new Error('没有找到MJ API对应的请求URL请检查配置');
}
return {
imagineUrl: apiUrlItem.mj_url.imagine,
videoUrl: apiUrlItem.mj_url.video,
describeUrl: apiUrlItem.mj_url.describe,
fetchTaskUrl: apiUrlItem.mj_url.once_get_task
}
}
/**
* MJ设置
*/
@ -77,6 +123,7 @@ class MJApi {
this.bootType = this.mjSimpleSetting.selectRobot == MJRobotType.NIJI ? "NIJI_JOURNEY" : "MID_JOURNEY"
if (this.mjSimpleSetting.type == MJImageType.REMOTE_MJ) {
this.imagineUrl = define.remotemj_api + 'mj/submit/imagine'
this.videoUrl = undefined; // 远程MJ不支持视频
this.describeUrl = define.remotemj_api + 'mj/submit/describe'
this.fetchTaskUrl = define.remotemj_api + 'mj/task/${id}/fetch'
} else if (this.mjSimpleSetting.type == MJImageType.LOCAL_MJ) {
@ -90,6 +137,7 @@ class MJApi {
localRemoteBaseUrl = localRemoteBaseUrl.slice(0, -1)
}
this.imagineUrl = localRemoteBaseUrl + ":" + localRemotePort + '/mj/submit/imagine'
this.videoUrl = undefined; // 本地代理模式不支持视频
this.describeUrl = localRemoteBaseUrl + ":" + localRemotePort + '/mj/submit/describe'
this.fetchTaskUrl = localRemoteBaseUrl + ":" + localRemotePort + '/mj/task/${id}/fetch'
} else if (this.mjSimpleSetting.type == MJImageType.PACKAGE_MJ) {
@ -109,6 +157,7 @@ class MJApi {
isPackage: true,
mj_url: {
imagine: baseUrl + '/mj/submit/imagine',
video: undefined, // 生图包不支持视频
describe: baseUrl + '/mj/submit/describe',
update_file: baseUrl + '/mj/submit/upload-discord-images',
once_get_task: baseUrl + '/mj/task/${id}/fetch',
@ -128,47 +177,16 @@ class MJApi {
throw new Error('没有找到MJ API对应的请求URL请检查配置');
}
this.imagineUrl = apiUrlItem.mj_url.imagine
this.videoUrl = apiUrlItem.mj_url.video
this.describeUrl = apiUrlItem.mj_url.describe
this.fetchTaskUrl = apiUrlItem.mj_url.once_get_task
} else {
let defaultApiUrl = GetMJUrlOptions("api");
// 获取自定义的API的地址
let customApiUrl = await this.optionServices.GetOptionByKey(OptionKeyName.MJ_CustomAPISetting);
if (customApiUrl.code == 0) {
throw new Error("加载MJ设置失败失败原因如下" + customApiUrl.message)
}
if (!(customApiUrl.data == null || isEmpty(customApiUrl.data.value) || !ValidateJson(customApiUrl.data.value))) {
let customApiUrlData = ValidateJsonAndParse(customApiUrl.data.value) as any[];
customApiUrlData.forEach((item: any) => {
let baseUrl = item.baseUrl.replace(/\/$/, '')
defaultApiUrl.push({
label: item.name,
value: item.id,
isPackage: true,
mj_url: {
imagine: baseUrl + '/mj/submit/imagine',
describe: baseUrl + '/mj/submit/describe',
update_file: baseUrl + '/mj/submit/upload-discord-images',
once_get_task: baseUrl + '/mj/task/${id}/fetch',
query_url: null
},
buy_url: null
})
})
}
let apiUrlIndex = defaultApiUrl.findIndex(item => item.value == this.mj_globalSetting.mj_apiSetting.mjApiUrl);
if (apiUrlIndex == -1) {
throw new Error('没有找到MJ API对应的请求URL请检查配置');
}
let apiUrlItem = defaultApiUrl[apiUrlIndex];
if (apiUrlItem.mj_url == null) {
throw new Error('没有找到MJ API对应的请求URL请检查配置');
}
this.imagineUrl = apiUrlItem.mj_url.imagine
this.describeUrl = apiUrlItem.mj_url.describe
this.fetchTaskUrl = apiUrlItem.mj_url.once_get_task
let { imagineUrl, videoUrl, describeUrl, fetchTaskUrl } = await this.InitMJAPISetting();
this.imagineUrl = imagineUrl
this.videoUrl = videoUrl
this.describeUrl = describeUrl
this.fetchTaskUrl = fetchTaskUrl
}
}

View File

@ -0,0 +1,63 @@
import { OptionType } from '@/define/enum/option'
import { OptionModel } from '@/model/option/option'
import { isEmpty } from 'lodash'
/**
*
* @param value
* @param type ('string'|'number'|'boolean'|'json')
* @returns
*/
export function convertStringToType<T>(value: string, type: OptionType, checkString?: string): T {
let checkErrorString = '请到 ' + checkString + ' 检查设置!'
// 如果值为空,直接报错
if (value === undefined || value === null || value === '') {
throw new Error('当前值为空!' + checkString ? checkErrorString : '')
}
try {
switch (type.toLowerCase()) {
case 'string':
return value as unknown as T
case 'number':
const num = Number(value)
if (isNaN(num)) {
throw new Error(
`Cannot convert "${value}" to number ${checkString ? checkErrorString : ''}`
)
}
return num as unknown as T
case 'boolean':
return (value.toLowerCase() === 'true' || value === '1') as unknown as T
case 'json':
try {
return JSON.parse(value) as T
} catch (e) {
throw new Error(`Invalid JSON string: ${value} ${checkString ? checkErrorString : ''}`)
}
default:
throw new Error(`Unsupported type: ${type}`)
}
} catch (error) {
throw error
}
}
/**
*
* @param option
* @param defaultValue
* @returns
*/
export const optionSerialization = <T>(
option: OptionModel.OptionItem | null,
checkString?: string
): T => {
if (option == null) {
throw new Error('未找到选项对象,请检查所有的选项设置是否存在!')
}
if (option.value == null || option.value == undefined || isEmpty(option.value)) {
throw new Error('option value is null')
}
return convertStringToType<T>(option.value, option.type as OptionType, checkString)
}

View File

@ -99,7 +99,7 @@ export class OptionServices {
if (ValidateJson(CW_AISettingData)) {
return successMessage(JSON.parse(CW_AISettingData), "数据已存在,无需再次同步或初始化", "OptionOptions.InitCopyWritingAISetting")
} else {
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JOSN);
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JSON);
return successMessage(aiSetting, "数据已存在,但是数据格式不正确,已重新初始化", "OptionOptions.InitCopyWritingAISetting")
}
}
@ -112,16 +112,16 @@ export class OptionServices {
let softwareData = software.toJSON()[0]
let SynchronizeAISetting = softwareData["aiSetting"] as string
if (ValidateJson(SynchronizeAISetting)) {
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, SynchronizeAISetting, OptionType.JOSN);
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, SynchronizeAISetting, OptionType.JSON);
return successMessage(JSON.parse(SynchronizeAISetting), "同步旧文案处理AI设置数据成功", "OptionOptions.InitCopyWritingAISetting")
} else {
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JOSN);
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JSON);
return successMessage(aiSetting, "旧的文案处理AI设置无效已重新重置", "OptionOptions.InitCopyWritingAISetting")
}
}
// 新设置
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JOSN);
this.optionRealmService.ModifyOptionByKey(OptionKeyName.CW_AISetting, JSON.stringify(aiSetting), OptionType.JSON);
return successMessage(aiSetting, '初始化文案处理AI设置成功', 'OptionOptions.SynchronizeAISettingOldData')
} catch (error: any) {
return errorMessage(

View File

@ -102,4 +102,60 @@ export default class ElectronInterface {
return errorMessage('选择文件错误,错误信息如下:' + error.message, 'SystemIpc_SelectMultipleFile');
}
}
/**
*
* @param extensions
* @returns
*/
public async SelectFolderOrFile(extensions?: string[]): Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem> {
try {
// 使用消息框让用户选择类型
const choice = await dialog.showMessageBox({
type: 'question',
title: '选择类型',
message: '请选择要选择的类型:',
buttons: ['选择文件', '选择文件夹', '取消'],
defaultId: 0,
cancelId: 2
});
if (choice.response === 2) {
throw new Error('用户取消选择');
}
if (choice.response === 0) {
// 选择文件
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: extensions && extensions.length > 0 ? [
{ name: 'Audio Files', extensions },
{ name: 'All Files', extensions: ['*'] }
] : [{ name: 'All Files', extensions: ['*'] }],
title: '选择文件'
});
if (result.filePaths.length === 0) {
throw new Error('没有选择文件');
}
return successMessage(result.filePaths[0], '选择文件成功', 'SystemIpc_SelectFolderOrFile');
} else {
// 选择文件夹
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: '选择文件夹'
});
if (result.filePaths.length === 0) {
throw new Error('没有选择文件夹');
}
return successMessage(result.filePaths[0], '选择文件夹成功', 'SystemIpc_SelectFolderOrFile');
}
} catch (error) {
console.error('选择文件或文件夹错误:', error);
return errorMessage('选择文件或文件夹错误,错误信息如下:' + error.message, 'SystemIpc_SelectFolderOrFile');
}
}
}

View File

@ -379,6 +379,8 @@ export class TaskManager {
case BookBackTaskType.RUNWAY_VIDEO:
case BookBackTaskType.LUMA_VIDEO:
case BookBackTaskType.KLING_VIDEO:
case BookBackTaskType.MJ_VIDEO:
case BookBackTaskType.MJ_VIDEO_EXTEND:
this.AddImageToVideo(task);
break;

View File

@ -137,6 +137,8 @@ export class KlingService {
}
}
async FetchKlingVideoResult(bookTaskDetail: Book.SelectBookTaskDetail, task: TaskModal.Task, taskId: string, baseUrl: string, gptApiKey: string, useTransfer: boolean = false) {
while (true) {
try {
@ -243,5 +245,4 @@ export class KlingService {
}
}
}
}

View File

@ -0,0 +1,515 @@
import { TaskModal } from "@/model/task";
import { BookBasicHandle } from "../Book/bookBasicHandle";
import MJApi from "@/main/Service/MJ/mjApi"
import { ValidateJson } from "@/define/Tools/validate";
import { BookTaskDetail } from "@/model/book/bookTaskDetail";
import { ImageToVideoModels, MJVideoMotion, VideoStatus } from "@/define/enum/video";
import { GetImageBase64 } from "@/define/Tools/image";
import axios from "axios";
import { SendMessageToRenderer } from "../globalService";
import { ResponseMessageType } from "@/define/enum/softwareEnum";
import { Book } from "@/model/book/book";
import { cloneDeep, isEmpty } from "lodash";
import { BookBackTaskStatus, BookTaskStatus } from "@/define/enum/bookEnum";
import { errorMessage, successMessage } from "@/main/Public/generalTools";
import { DEFINE_STRING } from "@/define/define_string";
import path from "path";
import { CheckFolderExistsOrCreate, CopyFileOrFolder } from "@/define/Tools/file";
import { DownloadFile } from "@/define/Tools/common";
import { define } from "@/define/define";
import { c } from "naive-ui";
export class MJVideoService extends BookBasicHandle {
constructor() {
super();
}
//#region InitMJSetting
/**
* MJ设置
* @returns MJ全局设置和视频URL
*/
async InitMJSetting() {
try {
// 创建MJ API实例
let mjApi = new MJApi();
// 初始化MJ设置
await mjApi.InitMJSetting();
let { imagineUrl, videoUrl, describeUrl, fetchTaskUrl } = await mjApi.InitMJAPISetting();
// 返回全局设置和视频URL
return {
mj_globalSetting: mjApi.mj_globalSetting,
videoUrl: videoUrl,
fetchTaskUrl: fetchTaskUrl
}
} catch (error) {
// 如果初始化失败,抛出错误
throw new Error(`初始化MJ设置失败${error.message}请检查MJ配置`);
}
}
//#endregion
//#region MJImageToVideo
/**
* MJ图片转视频处理方法
* Midjourney API转换为视频
* @param task ID等信息
* @returns Promise<void>
* @throws API调用失败时抛出异常
*/
async MJImageToVideo(task: TaskModal.Task): Promise<void> {
try {
await this.InitBookBasicHandle();
let bookTaskDetail = this.bookTaskDetailService.GetBookTaskDetailDataById(task.bookTaskDetailId);
if (bookTaskDetail == null) {
throw new Error("未找到对应的小说批次任务分镜数据,请检查");
}
let videoMessage = bookTaskDetail.videoMessage;
if (videoMessage == null) {
throw new Error("小说批次任务分镜数据的转视频配置为空,请检查");
}
let mjVideoOptionsString = bookTaskDetail.videoMessage.mjVideoOptions;
if (!ValidateJson(mjVideoOptionsString)) {
throw new Error("MJ 图转视频 参数错误,请检查");
}
let mjVideoOptions: BookTaskDetail.MjVideoOptions = JSON.parse(mjVideoOptionsString);
let imageUrl = videoMessage.imageUrl?.trim() || mjVideoOptions.image?.trim() || "";
let prompt = videoMessage.prompt?.trim();
let motion: MJVideoMotion = mjVideoOptions.motion === MJVideoMotion.High
? MJVideoMotion.High
: MJVideoMotion.Low;
let raw = mjVideoOptions.raw || false;
// 判断 图片是不是网络图片,不是网络图片的话判断当前图片再本地是不是存在,存在的话讲图片转为 base64
if (!imageUrl.startsWith("http")) {
imageUrl = await GetImageBase64(imageUrl.split("?t=")[0]);
}
// 判断是不是有 有效的提示词 有的话 判断是不是视频原始 是的话 在提示词后面添加 --raw
if (!isEmpty(prompt) && raw) {
prompt = prompt + " --raw";
}
let body = {
prompt: prompt,
image: imageUrl,
motion: motion,
}
let useTransfer = false;
let { mj_globalSetting, videoUrl, fetchTaskUrl } = await this.InitMJSetting();
console.log("MJImageToVideo", mj_globalSetting, videoUrl);
let apiKey = mj_globalSetting.mj_apiSetting.apiKey;
// 开始请求
let res = await axios.post(videoUrl, body, {
headers: {
"Authorization": apiKey
}
});
console.log("MJImageToVideo response", res.data);
let resData = res.data;
let id = resData.result;
// 修改Task, 将数据写入
this.bookBackTaskListService.UpdateBackTaskData(task.id, {
taskId: id,
taskMessage: JSON.stringify(resData),
});
// 修改videoMessage数据
videoMessage.taskId = id;
videoMessage.status = VideoStatus.WAIT;
videoMessage.messageData = JSON.stringify(resData);
videoMessage.msg = "";
delete videoMessage.imageUrl;
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(task.bookTaskDetailId, videoMessage);
// 返回前端数据
SendMessageToRenderer({
code: 1,
id: task.bookTaskDetailId,
message: "MJ Video 合成任务提交成功",
type: ResponseMessageType.MJ_VIDEO,
data: JSON.stringify(videoMessage)
}, task.messageName);
await this.FetchMJVideoResult(bookTaskDetail, task, id, fetchTaskUrl, apiKey, useTransfer)
} catch (error) {
throw new Error(`MJ 图转视频 失败,失败信息入下:${error.message}`);
}
}
//#endregion
//#region FetchMJVideoResult
/**
* MJ视频生成结果
* Midjourney视频生成任务的状态
* @param bookTaskDetail
* @param task ID
* @param taskId Midjourney返回的任务ID
* @param fetchTaskUrl API地址模板
* @param apiKey API密钥
* @param useTransfer 使false
* @returns Promise<void>
* @throws API调用异常时抛出错误
*/
async FetchMJVideoResult(bookTaskDetail: Book.SelectBookTaskDetail, task: TaskModal.Task, taskId: string, fetchTaskUrl: string, apiKey: string, useTransfer: boolean = false) {
while (true) {
try {
let fetchUrl = fetchTaskUrl.replace("${id}", taskId);
let res = await axios.get(fetchUrl, {
headers: {
"Authorization": apiKey
}
})
let resData = res.data;
let status = resData.status.toLowerCase();
let code = status == 'failure' || status == 'cancel' ? 0 : 1
let progress = resData.progress && resData.progress.length > 0
? parseInt(resData.progress.slice(0, -1))
: 0;
if (code == 0) {
// 失败
let videoMessage = cloneDeep(bookTaskDetail.videoMessage);
videoMessage.status = VideoStatus.FAIL;
videoMessage.msg = resData.failReason;
videoMessage.taskId = taskId;
videoMessage.messageData = JSON.stringify(resData);
delete videoMessage.imageUrl;
// 修改 videoMessage数据
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(bookTaskDetail.id, videoMessage);
// 修改TASK
this.bookBackTaskListService.UpdateBackTaskData(task.id, {
taskId: taskId,
taskMessage: JSON.stringify(resData),
})
// 返回前端数据
SendMessageToRenderer({
code: 0,
id: bookTaskDetail.id,
message: "MJ VIDEO 合成视频失败,错误信息如下:" + resData.failReason,
type: ResponseMessageType.MJ_VIDEO,
data: JSON.stringify(videoMessage)
}, task.messageName);
throw new Error("MJ Video 合成视频失败,错误信息如下:" + resData.failReason);
}
else {
// 请求成功 但是需要判断状态和返回的进度
if (progress == 100 && status == 'success') {
let videoMessage = cloneDeep(bookTaskDetail.videoMessage);
videoMessage.status = VideoStatus.SUCCESS;
videoMessage.taskId = taskId;
if (resData.videoUrls && resData.videoUrls.length > 0) {
videoMessage.videoUrls = [];
resData.videoUrls.forEach((item: any) => {
videoMessage.videoUrls.push(item.url);
})
videoMessage.videoUrl = videoMessage.videoUrls[0];
}
videoMessage.messageData = JSON.stringify(resData);
delete videoMessage.imageUrl;
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(task.bookTaskDetailId, videoMessage);
this.bookTaskService.UpdetedBookTaskData(task.bookTaskId, {
status: BookTaskStatus.IMAGE_TO_VIDEO_SUCCESS,
})
this.bookBackTaskListService.UpdateBackTaskData(task.id, {
status: BookBackTaskStatus.DONE,
taskId: taskId,
taskMessage: JSON.stringify(resData),
})
SendMessageToRenderer({
code: 1,
id: bookTaskDetail.id,
message: "MJ VIDEO 合成视频完成",
type: ResponseMessageType.MJ_VIDEO,
data: JSON.stringify(videoMessage)
}, task.messageName);
break;
}
}
// 再执行中
let videoMessage = cloneDeep(bookTaskDetail.videoMessage);
videoMessage.status = VideoStatus.PROCESSING;
videoMessage.taskId = taskId;
videoMessage.messageData = JSON.stringify(resData);
delete videoMessage.imageUrl;
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(task.bookTaskDetailId, videoMessage);
SendMessageToRenderer({
code: 1,
id: bookTaskDetail.id,
message: "MJ VIDEO 合成任务正在合成中",
type: ResponseMessageType.MJ_VIDEO,
data: JSON.stringify(videoMessage)
}, task.messageName);
// 没有成功 等待二十秒后继续执行
await new Promise(resolve => setTimeout(resolve, 20000));
} catch (error) {
throw error;
}
}
}
//#endregion
//#region MJVideoExtend
async MJVideoExtend(task: TaskModal.Task): Promise<void> {
try {
await this.InitBookBasicHandle();
let bookTaskDetail = this.bookTaskDetailService.GetBookTaskDetailDataById(task.bookTaskDetailId);
if (bookTaskDetail == null) {
throw new Error("未找到对应的小说批次任务分镜数据,请检查");
}
let videoMessage = bookTaskDetail.videoMessage;
if (videoMessage == null) {
throw new Error("小说批次任务分镜数据的转视频配置为空,请检查");
}
let mjVideoOptionsString = bookTaskDetail.videoMessage.mjVideoOptions;
if (!ValidateJson(mjVideoOptionsString)) {
throw new Error("MJ 图转视频 参数错误,请检查");
}
let mjVideoOptions: BookTaskDetail.MjVideoOptions = JSON.parse(mjVideoOptionsString);
let { mj_globalSetting, videoUrl, fetchTaskUrl } = await this.InitMJSetting();
console.log("MJVideoExtend", mj_globalSetting, videoUrl);
let prompt = videoMessage.prompt?.trim();
let motion: MJVideoMotion = mjVideoOptions.motion === MJVideoMotion.High
? MJVideoMotion.High
: MJVideoMotion.Low;
let action = 'extend';
let index = mjVideoOptions.index;
let taskId = mjVideoOptions.taskId;
let raw = mjVideoOptions.raw || false;
if (index == undefined || index < 0 || index > 3 || index == null) {
throw new Error("MJ视频拓展参数错误index必须大于等于0且小于等于3请检查");
}
if (isEmpty(taskId)) {
throw new Error("MJ视频拓展参数错误taskId不能为空请检查");
}
if (!isEmpty(prompt)) {
if (raw) {
prompt = prompt + " --raw";
}
}
let body = {
prompt,
motion,
action,
index,
taskId,
}
let apiKey = mj_globalSetting.mj_apiSetting.apiKey;
let res = await axios.post(videoUrl, body, {
headers: {
"Authorization": apiKey
}
})
console.log("MJVideoExtend response", res.data);
let resData = res.data;
let id = resData.result;
// 修改Task, 将数据写入
this.bookBackTaskListService.UpdateBackTaskData(task.id, {
taskId: id,
taskMessage: JSON.stringify(resData),
});
// 修改videoMessage数据
videoMessage.taskId = id;
videoMessage.status = VideoStatus.WAIT;
videoMessage.messageData = JSON.stringify(resData);
videoMessage.msg = "";
delete videoMessage.imageUrl;
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(task.bookTaskDetailId, videoMessage);
// 返回前端数据
// 返回前端数据
SendMessageToRenderer({
code: 1,
id: task.bookTaskDetailId,
message: "MJ Video EXTENT 任务提交成功",
type: ResponseMessageType.MJ_VIDEO_EXTEND,
data: JSON.stringify(videoMessage)
}, task.messageName);
let useTransfer = false;
await this.FetchMJVideoResult(bookTaskDetail, task, id, fetchTaskUrl, apiKey, useTransfer)
} catch (error) {
console.error("MJVideoExtend Error:", error);
throw new Error(`MJ视频拓展初始化失败错误信息${error.message}`);
}
}
//#endregion
//#region ReloadMJVideoTask
/**
* MJ视频任务
* Midjourney视频任务的状态和结果
* @param bookTaskDetail ID等信息
* @param taskId Midjourney返回的任务ID
* @returns Promise<any>
* @throws
*
*
* 1. success且进度为100%
* 2. URL并更新数据库状态
* 3.
* 4.
* 5.
*/
async ReloadMJVideoTask(bookTaskDetail: Book.SelectBookTaskDetail, taskId: string) {
await this.InitBookBasicHandle();
let { mj_globalSetting, videoUrl, fetchTaskUrl } = await this.InitMJSetting();
let apiKey = mj_globalSetting.mj_apiSetting.apiKey;
let fetchUrl = fetchTaskUrl.replace("${id}", taskId);
let res = await axios.get(fetchUrl, {
headers: {
"Authorization": apiKey
}
})
let resData = res.data;
if (res.status == 204) {
return errorMessage("当前分镜的视频任务状态为 204没有数据返回可能是任务不存在或者已被删除请检查");
}
let status = resData.status.toLowerCase();
let progress = resData.progress && resData.progress.length > 0
? parseInt(resData.progress.slice(0, -1))
: 0;
if (status != 'success' || progress != 100) {
return errorMessage("当前分镜的视频任务状态不为 success 或者进度不是 100%,不可重新加载!");
}
// 开始处理数据返回
let videoMessage = cloneDeep(bookTaskDetail.videoMessage);
videoMessage.status = VideoStatus.SUCCESS;
videoMessage.taskId = taskId;
videoMessage.msg = "";
if (resData.videoUrls && resData.videoUrls.length > 0) {
videoMessage.videoUrls = [];
resData.videoUrls.forEach((item: any) => {
videoMessage.videoUrls.push(item.url);
})
videoMessage.videoUrl = videoMessage.videoUrls[0];
}
videoMessage.messageData = JSON.stringify(resData);
delete videoMessage.imageUrl;
this.bookTaskDetailService.UpdateBookTaskDetailVideoMessage(bookTaskDetail.id, videoMessage);
this.bookTaskService.UpdetedBookTaskData(bookTaskDetail.bookTaskId, {
status: BookTaskStatus.IMAGE_TO_VIDEO_SUCCESS,
})
// 这边开始下载视频
let book = this.bookService.GetBookDataById(bookTaskDetail.bookId);
if (book == null) {
return errorMessage("重新加载视频任务失败,未找到对应的小说数据,请检查");
}
let remoteVideoUrl = bookTaskDetail.videoMessage.videoUrl;
let remoteVideoUrls = bookTaskDetail.videoMessage.videoUrls || [];
if (isEmpty(remoteVideoUrl)) {
return errorMessage("重新加载视频任务失败,未找到对应的小说分镜视频地址,请检查");
}
// 开始下载 remoteVideoUrl 并且修改对应的数据
let videoPath = path.join(book.bookFolderPath, `data/video/temp/${bookTaskDetail.name}_${new Date().getTime()}.mp4`);
await CheckFolderExistsOrCreate(path.dirname(videoPath));
await DownloadFile(remoteVideoUrl, videoPath);
let targetPath = path.join(book.bookFolderPath, `data/video/${bookTaskDetail.name}.mp4`);
await CopyFileOrFolder(videoPath, targetPath);
// 开始修改信息
this.bookTaskDetailService.UpdateBookTaskDetail(bookTaskDetail.id, {
generateVideoPath: targetPath,
})
// 开始处理 remoteVideoUrls
if (remoteVideoUrls && remoteVideoUrls.length > 0) {
let tempVideoUrls = bookTaskDetail.subVideoPath || [];
let newVideoUrls: Array<string> = []
for (let i = 0; i < remoteVideoUrls.length; i++) {
let tempVideoUrl = remoteVideoUrls[i];
let tmepVideoPath = path.join(book.bookFolderPath, `data/video/temp/${bookTaskDetail.name}_${i}_${new Date().getTime()}.mp4`);
await CheckFolderExistsOrCreate(path.dirname(tmepVideoPath));
await DownloadFile(tempVideoUrl, tmepVideoPath);
// 开始修改信息
// 将信息添加到里面
let a = {
localPath: path.relative(define.project_path, tmepVideoPath),
remotePath: tempVideoUrl,
taskId: bookTaskDetail.videoMessage.taskId,
index: i,
type: ImageToVideoModels.MJ_VIDEO
}
newVideoUrls.push(JSON.stringify(a));
}
// 开始处理数据
// 将原有的视频路径合并到新数组中
newVideoUrls.push(...tempVideoUrls);
this.bookTaskDetailService.UpdateBookTaskDetail(bookTaskDetail.id, {
subVideoPath: newVideoUrls,
})
}
let newBookTaskDetail = this.bookTaskDetailService.GetBookTaskDetailDataById(bookTaskDetail.id);
if (newBookTaskDetail == null) {
return errorMessage("重新加载视频任务失败,未找到对应的小说批次任务分镜数据,请检查");
}
return successMessage(newBookTaskDetail, "重新加载视频任务完成!");
}
//#endregion
}

View File

@ -1,4 +1,4 @@
import { ImageToVideoModels, KlingMode, RunawayModel, RunwaySeconds, VideoModel, VideoStatus } from "@/define/enum/video";
import { ImageToVideoModels, KlingMode, MappingTaskTypeToVideoModel, MJVideoMotion, RunawayModel, RunwaySeconds, VideoModel, VideoStatus } from "@/define/enum/video";
import { DownloadFile, GetBaseUrl } from "@/define/Tools/common";
import { errorMessage, successMessage } from "@/main/Public/generalTools";
import { BookTaskDetail } from "@/model/book/bookTaskDetail";
@ -16,6 +16,7 @@ import { KlingService } from "./kling";
import { LumaService } from "./luma";
import { CheckFolderExistsOrCreate, CopyFileOrFolder } from "@/define/Tools/file";
import { ResponseMessageType } from "@/define/enum/softwareEnum";
import { MJVideoService } from "./mjVideo";
/**
*
@ -26,22 +27,21 @@ export class VideoGlobal {
runwayService: RunwayService
lumaService: LumaService
klingService: KlingService
mjVideoService: MJVideoService
constructor() {
this.gptService = new GptService();
this.bookServiceBasic = new BookServiceBasic();
this.runwayService = new RunwayService();
this.lumaService = new LumaService();
this.klingService = new KlingService();
this.mjVideoService = new MJVideoService();
}
//#region 初始化分镜的视频配置
/**
*
*/
async InitVideoMessage(bookTaskDetailId: string) {
async InitVideoMessageData(bookTaskDetailId: string) {
try {
let defaultVideoMode = global.config.defaultVideoMode ?? ImageToVideoModels.RUNWAY;
let defaultVideoMode = global.config.defaultVideoMode ?? ImageToVideoModels.MJ_VIDEO;
let { gptUrl, gptApiKey } = await this.gptService.RefreshGptSetting();
console.log("gptUrl", gptUrl, "gptApiKey", gptApiKey);
@ -59,7 +59,6 @@ export class VideoGlobal {
seconds: RunwaySeconds.FIVE,
},
};
let options = JSON.stringify(optionObject);
let lumaOptions: BookTaskDetail.lumaOptions = {
user_prompt: "",
@ -79,6 +78,19 @@ export class VideoGlobal {
duration: RunwaySeconds.FIVE,
}
let mjVideoOptions: BookTaskDetail.MjVideoOptions = {
action: undefined,
image: !isEmpty(bookTaskDetail.outImagePath) ? path.relative(define.project_path, bookTaskDetail.outImagePath) : "", // 或者根据 Image 类型的定义提供默认值
index: undefined,
motion: MJVideoMotion.High, // 根据 Motion 类型的定义提供默认值
noStorage: false,
notifyHook: undefined,
prompt: null,
state: undefined,
taskId: undefined,
raw: false
}
let videoMessage: BookTaskDetail.VideoMessage = {
id: bookTaskDetailId,
msg: "",
@ -87,13 +99,27 @@ export class VideoGlobal {
style: "",
imageUrl: !isEmpty(bookTaskDetail.outImagePath) ? path.relative(define.project_path, bookTaskDetail.outImagePath) : "",
bookTaskDetailId: bookTaskDetailId,
runwayOptions: options,
runwayOptions: JSON.stringify(optionObject),
lumaOptions: JSON.stringify(lumaOptions),
klingOptions: JSON.stringify(klingOptions),
mjVideoOptions: JSON.stringify(mjVideoOptions),
status: VideoStatus.WAIT,
model: VideoModel.IMAGE_TO_VIDEO
}
return { optionObject, lumaOptions, klingOptions, mjVideoOptions, videoMessage };
} catch (error) {
throw error;
}
}
/**
*
*/
async InitVideoMessage(bookTaskDetailId: string) {
try {
let { optionObject, lumaOptions, klingOptions, mjVideoOptions, videoMessage } = await this.InitVideoMessageData(bookTaskDetailId);
await this.bookServiceBasic.UpdateBookTaskDetail(bookTaskDetailId, {
videoMessage: videoMessage,
})
@ -107,7 +133,7 @@ export class VideoGlobal {
//#endregion
//#region 秀嘎视频消息
//#region 修改视频消息
/**
* VideoMessage
* @param bookTaskDetailId
@ -140,36 +166,79 @@ export class VideoGlobal {
await this.klingService.KlingImageToVideo(task, gptUrl, gptApiKey, useTransfer);
break;
case BookBackTaskType.MJ_VIDEO:
// MJ视频的处理
await this.mjVideoService.MJImageToVideo(task);
break;
case BookBackTaskType.MJ_VIDEO_EXTEND:
await this.mjVideoService.MJVideoExtend(task);
break;
default:
throw new Error("暂不支持的视频类型");
}
// return ;
// 执行完毕,开始下载视频
let bookTaskDetail = await this.bookServiceBasic.GetBookTaskDetailDataById(task.bookTaskDetailId);
let book = await this.bookServiceBasic.GetBookDataById(task.bookId);
let videoUrl = bookTaskDetail.videoMessage.videoUrl;
let videoUrls = bookTaskDetail.videoMessage.videoUrls || [];
if (isEmpty(videoUrl)) {
throw new Error("生成的视频地址为空,请检查");
}
// 开始下载
// 开始下载 videoUrl
let videoPath = path.join(book.bookFolderPath, `data/video/temp/${bookTaskDetail.name}_${new Date().getTime()}.mp4`);
await CheckFolderExistsOrCreate(path.dirname(videoPath));
await DownloadFile(videoUrl, videoPath);
await CopyFileOrFolder(videoPath, path.join(book.bookFolderPath, `data/video/${bookTaskDetail.name}.mp4`));
let targetPath = path.join(book.bookFolderPath, `data/video/${bookTaskDetail.name}.mp4`);
await CopyFileOrFolder(videoPath, targetPath);
// 开始修改信息
await this.bookServiceBasic.UpdateBookTaskDetail(bookTaskDetail.id, {
generateVideoPath: path.relative(define.project_path, videoPath),
generateVideoPath: targetPath,
})
// 开始下载 videoUrls
if (videoUrls.length > 0) {
let tempVideoUrls = bookTaskDetail.subVideoPath || [];
let newVideoUrls: string[] = []
for (let i = 0; i < videoUrls.length; i++) {
let tempVideoUrl = videoUrls[i];
let tmepVideoPath = path.join(book.bookFolderPath, `data/video/temp/${bookTaskDetail.name}_${i}_${new Date().getTime()}.mp4`);
await CheckFolderExistsOrCreate(path.dirname(tmepVideoPath));
await DownloadFile(tempVideoUrl, tmepVideoPath);
// 开始修改信息
// 将信息添加到里面
let a = {
localPath: path.relative(define.project_path, tmepVideoPath),
remotePath: tempVideoUrl,
taskId: bookTaskDetail.videoMessage.taskId,
index: i,
type: MappingTaskTypeToVideoModel(task.type),
}
newVideoUrls.push(JSON.stringify(a));
}
// 开始处理数据
// 将原有的视频路径合并到新数组中
newVideoUrls.push(...tempVideoUrls);
await this.bookServiceBasic.UpdateBookTaskDetail(bookTaskDetail.id, {
subVideoPath: newVideoUrls,
})
}
let newBookTaskDetail = await this.bookServiceBasic.GetBookTaskDetailDataById(task.bookTaskDetailId);
// 讲数据返回前端
SendMessageToRenderer({
code: 1,
id: task.bookTaskDetailId,
message: "视频生成成功",
type: ResponseMessageType.VIDEO_SUCESS,
data: videoPath + "?t=" + new Date().getTime()
data: JSON.stringify(newBookTaskDetail)
}, task.messageName);
console.log("视频生成成功", videoPath);
@ -190,6 +259,7 @@ export class VideoGlobal {
taskId: "",
msg: message
})
SendMessageToRenderer({
code: 0,
id: task.bookTaskDetailId,

View File

@ -21,7 +21,7 @@ import { DiscordIpc, RemoveDiscordIpc } from './IPCEvent/discordIpc.js'
import { Logger } from './logger.js'
import { RegisterIpc } from './IPCEvent/index'
import { InitRemoteMjSettingType } from './initFunc'
import { InitRemoteMjSettingType, InitData as a } from './initFunc'
let tools = new Tools()
let imageGenerate = new ImageGenerate(global)
@ -30,6 +30,7 @@ let softWareServiceBasic = new SoftWareServiceBasic()
async function InitData(gl) {
await InitRemoteMjSettingType()
await a()
let res = await setting.getSettingDafultData()
gl.config = res
return res

View File

@ -1,6 +1,8 @@
import { isEmpty } from "lodash";
import { errorMessage, successMessage } from "./Public/generalTools";
import { SoftWareServiceBasic } from "./Service/ServiceBasic/softwareServiceBasic";
import { OptionKeyName, OptionType } from "@/define/enum/option";
import { OptionServices } from "./Service/Options/optionServices";
/**
@ -23,3 +25,41 @@ export async function InitRemoteMjSettingType() {
errorMessage("初始化远程MJ的设置类型失败" + error.toString(), "InitRemoteMjSettingType")
}
}
/**
*
* @description
*/
export async function InitData() {
// 初始化 Options 数据
// 循环 initObject 进行添加,在添加之前需要判断数据是不是存在,存在的话不进行处理,直接跳过,只有当不存在的时候在添加
let optionService = new OptionServices();
// 遍历初始化对象数组
for (let i = 0; i < initObject.length; i++) {
const item = initObject[i];
// 通过键名获取选项数据
let res = await optionService.GetOptionByKey(item.key);
if (res.code == 1 && res.data == null) {
// 不存在,进行添加
await optionService.ModifyOptionByKey(item.key, item.value, item.type);
} else {
// 存在,跳过
continue;
}
}
}
const initObject = [
{
table: "Options",
key: OptionKeyName.ImageToVideo_ShowRightPanel,
value: "true",
type: OptionType.BOOLEAN,
},
{
table: "Options",
key: OptionKeyName.ImageToVideo_ShowPagination,
value: "true",
type: OptionType.BOOLEAN,
}
]

View File

@ -145,6 +145,14 @@ declare namespace Book {
isSelect?: boolean
}
interface subVideoPathModel {
localPath: string,
remotePath: string,
taskId: string,
index?: number,
type: ImageToVideoModels
}
type SelectBookTaskDetail = {
id?: string
no?: number
@ -154,6 +162,7 @@ declare namespace Book {
videoPath?: string // 视频地址
generateVideoPath?: string // 生成的视频地址
subVideoPath?: string[] // 生成的批次视频的地址
subVideoPathObject?: subVideoPath[] //生成视频的完成结构显示
audioPath?: string // 音频地址
draftDepend?: string // 草稿依赖
word?: string // 文案

View File

@ -1,4 +1,4 @@
import { ImageToVideoModels, KlingMode, RunawayModel, RunwaySeconds, VideoModel, VideoStatus } from "@/define/enum/video";
import { ImageToVideoModels, KlingMode, MJVideoAction, MJVideoMotion, RunawayModel, RunwaySeconds, VideoModel, VideoStatus } from "@/define/enum/video";
declare namespace BookTaskDetail {
@ -73,5 +73,45 @@ declare namespace BookTaskDetail {
callback_url?: string; // 回调地址,可选,生成视频完成后,会向该地址发送通知
}
interface MjVideoOptions {
/**
* indextaskId必填
*/
action?: MJVideoAction;
/**
*
*/
image: string;
/**
*
*/
index?: number;
/**
*
*/
motion: MJVideoMotion;
/**
* True时
*/
noStorage?: boolean;
/**
*
*/
notifyHook?: string;
/** 提示词 */
prompt?: null | string;
state?: string;
/**
* ID
*/
taskId?: string;
/**
*
*/
raw?: boolean;
}
//#endregion
}

2
src/model/task.d.ts vendored
View File

@ -16,6 +16,8 @@ declare namespace TaskModal {
startTime?: number
endTime?: number,
messageName?: string
taskId?: string // 任务ID可能是第三方服务的任务ID
taskMessage?: string // 任务消息,可能是第三方服务的任务消息
}
interface TaskCondition {

View File

@ -13,6 +13,11 @@ const Video = {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.UPDATE_BOOK_TASK_DETAIL_VIDEO_MESSAGE, bookTaskDetailId, videoMessage)
},
/** 重新下载视频任务 */
ReloadVideoTaskInfo: async (bookTaskDetailId: string) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.RELOAD_VIDEO_TASK_INFO, bookTaskDetailId)
},
/** 获取指定条件的小说图转视频数据,包含子批次 */
GetVideoBookInfoList: async (condition: BookVideo.BookVideoInfoListQuertCondition) => {
return await ipcRenderer.invoke(DEFINE_STRING.BOOK.GET_VIDEO_BOOK_INFO_LIST, condition)

View File

@ -22,5 +22,8 @@ const system = {
/** 选择多个指定文件后缀的文件 */
SelectMultipleFile: (value: string[]) => ipcRenderer.invoke(DEFINE_STRING.SYSTEM.SELECT_MULTIPLE_FILE, value),
/** 选择文件夹或指定后缀的文件 */
SelectFolderOrFile: (value?: string[]) => ipcRenderer.invoke(DEFINE_STRING.SYSTEM.SELECT_FOLDER_OR_FILE, value),
}
export { system }

View File

@ -9,7 +9,6 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
@ -17,16 +16,12 @@ declare module 'vue' {
NColorPicker: typeof import('naive-ui')['NColorPicker']
NDataTable: typeof import('naive-ui')['NDataTable']
NDivider: typeof import('naive-ui')['NDivider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
@ -34,14 +29,10 @@ declare module 'vue' {
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLog: typeof import('naive-ui')['NLog']
NMenu: typeof import('naive-ui')['NMenu']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
@ -52,7 +43,6 @@ declare module 'vue' {
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NUpload: typeof import('naive-ui')['NUpload']

View File

@ -9,7 +9,7 @@ import TextCommon from "./text";
*/
async function SaveCWAISimpleSetting() {
let optionStore = useOptionStore();
let saveRes = await window.options.ModifyOptionByKey(OptionKeyName.CW_AISimpleSetting, JSON.stringify(optionStore.CW_AISimpleSetting), OptionType.JOSN);
let saveRes = await window.options.ModifyOptionByKey(OptionKeyName.CW_AISimpleSetting, JSON.stringify(optionStore.CW_AISimpleSetting), OptionType.JSON);
if (saveRes.code == 0) {
throw new Error(saveRes.message);
}

View File

@ -78,7 +78,7 @@ async function InitTTSGlobalSetting() {
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.TTS_GlobalSetting,
JSON.stringify(initData),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
window.api.showGlobalMessageDialog(saveRes)
@ -108,7 +108,7 @@ async function InitFluxModelList(isMust: boolean = false): Promise<Array<{ label
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.FLUX_APIModelList,
JSON.stringify(initData),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
window.api.showGlobalMessageDialog(saveRes)
@ -124,7 +124,7 @@ async function InitFluxModelList(isMust: boolean = false): Promise<Array<{ label
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.FLUX_APIModelList,
JSON.stringify(initData),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
window.api.showGlobalMessageDialog(saveRes)
@ -163,7 +163,7 @@ async function InitComfyUISetting() {
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_SimpleSetting,
JSON.stringify(initSimpleSetting),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
window.api.showGlobalMessageDialog(saveRes)
@ -190,7 +190,7 @@ async function InitComfyUISetting() {
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_WorkFlowSetting,
JSON.stringify(initWorkFlowSetting),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
window.api.showGlobalMessageDialog(saveRes)

View File

@ -7,7 +7,7 @@ import { useOptionStore } from "@/stores/option";
*/
async function SaveTTSGlobalSetting() {
let optionStore = useOptionStore();
let saveRes = await window.options.ModifyOptionByKey(OptionKeyName.TTS_GlobalSetting, JSON.stringify(optionStore.TTS_GlobalSetting), OptionType.JOSN);
let saveRes = await window.options.ModifyOptionByKey(OptionKeyName.TTS_GlobalSetting, JSON.stringify(optionStore.TTS_GlobalSetting), OptionType.JSON);
if (saveRes.code == 0) {
throw new Error(saveRes.message);
}

View File

@ -9,7 +9,11 @@
require-mark-placement="right-hanging"
>
<n-form-item label="书名" path="name">
<n-input v-model:value="reverseManageStore.selectBook.name" placeholder="请输入书名" />
<n-input
v-model:value="reverseManageStore.selectBook.name"
placeholder="请输入书名"
:disabled="!reverseManageStore.selectBook"
/>
</n-form-item>
<n-form-item label="视频文件" path="oldVideoPath">
<n-input

View File

@ -80,8 +80,10 @@ import {
NSelect
} from 'naive-ui'
import { useReverseManageStore } from '../../../../../../stores/reverseManage'
import { useSoftwareStore } from '../../../../../../stores/software'
import { AddBookTaskCopyData } from '../../../../../../define/enum/bookEnum'
let reverseManageStore = useReverseManageStore()
let softwareStore = useSoftwareStore()
let message = useMessage()
let formRef = ref(null)
let creatObj = ref({

View File

@ -184,6 +184,8 @@ async function ClipDraft(e) {
closeOnEsc: false,
title: `生成草稿前检查 ${bookTask.value.name}`,
maskClosable: false,
showIcon: false,
style: 'width: 800px; max-width: 90vw',
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: bookTask.value,
@ -202,6 +204,8 @@ async function GenerateVideo(e) {
closeOnEsc: false,
title: `合成视频前检查 ${bookTask.value.name}`,
maskClosable: false,
showIcon: false,
style: 'width: 800px; max-width: 90vw',
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: bookTask.value,

View File

@ -1,116 +1,230 @@
<template>
<n-card class="form-card" size="large">
<n-form
ref="formRef"
:model="bookTask"
:rules="rules"
label-width="auto"
require-mark-placement="right-hanging"
:size="size"
:style="{
maxWidth: '640px'
}"
:size="'medium'"
label-placement="top"
class="generate-form"
>
<n-form-item label="选择srt地址" path="srtPath">
<!-- 音频文件选择 -->
<n-form-item label="配音文件 (MP3/WAV)" path="audioPath">
<div class="file-input-group">
<n-input
style="width: 300px; margin-right: 5px"
v-model:value="bookTask.srtPath"
type="text"
placeholder="SRT字幕地址"
/>
<n-button color="#e5a84b" @click="SelectSrtFile">
<n-icon :size="20">
<folder-open />
</n-icon>
</n-button>
</n-form-item>
<n-form-item label="选择配音" path="audioPath">
<n-input
style="width: 300px; margin-right: 5px"
v-model:value="bookTask.audioPath"
type="text"
placeholder="配音地址mp3或wav"
placeholder="请选择配音文件..."
readonly
class="file-input"
/>
<n-button color="#e5a84b" @click="SelectMusicFile">
<n-icon :size="20">
<folder-open />
<n-button type="primary" @click="SelectMusicFile" class="file-button">
<template #icon>
<n-icon size="18">
<FolderOpen />
</n-icon>
</template>
选择文件
</n-button>
</n-form-item>
<n-form-item label="选择背景音乐文件夹(随机匹配,自动合成视频必选)" path="backgroundMusic">
<n-select
style="width: 300px"
v-model:value="bookTask.backgroundMusic"
filterable
placeholder="选择样式"
:options="backgroundMusicOptions"
clearable
/>
</n-form-item>
<n-form-item label="选择剪映草稿" path="backgroundMusic" v-if="optionType != 'video'">
<div>
<div style="color: red">
注意选择的草稿主轨道的图片数量要和当前的相同会生成新的草稿并且只会替换图片保留其余的数据
</div>
</n-form-item>
<!-- 背景音乐选择 -->
<n-form-item label="背景音乐" path="backgroundMusic">
<div class="file-input-group">
<n-input
v-model:value="bookTask.backgroundMusic"
type="text"
placeholder="请选择背景音乐文件夹或音频文件..."
readonly
class="file-input"
/>
<n-button type="primary" @click="SelectBackgroundMusic" class="file-button">
<template #icon>
<n-icon size="18">
<FolderOpen />
</n-icon>
</template>
选择文件/文件夹
</n-button>
</div>
</n-form-item>
<!-- 剪映草稿选择 -->
<n-form-item
label="剪映草稿模板"
path="draftDepend"
v-if="optionType != 'video'"
:show-feedback="false"
>
<div class="draft-section">
<n-select
style="width: 300px"
v-model:value="bookTask.draftDepend"
filterable
placeholder="选择样式"
placeholder="选择剪映草稿模板"
:options="draftSelect"
clearable
class="select-input"
/>
<n-alert type="warning" :show-icon="true" class="draft-warning">
注意选择的草稿主轨道的图片数量要和当前的相同会生成新的草稿并且只会替换图片保留其余的数据依赖的草稿必须是剪映
5.9 级以下版本不支持 6.0 及以上版本
</n-alert>
</div>
</n-form-item>
<n-form-item path="backgroundMusic">
<n-button :disabled="type == 'book'" type="info" @click="UseBookVideoDataToBookTask"
>应用主小说相关数据</n-button
<!-- 操作按钮组 -->
<n-form-item>
<n-space size="medium" class="button-group">
<n-button
:disabled="type == 'book'"
type="info"
@click="UseBookVideoDataToBookTask"
class="action-button"
>
<n-button type="info" style="margin-left: 10px" @click="SaveVideoData">保存数据</n-button>
<template #icon>
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
</template>
应用主小说数据
</n-button>
<n-button type="primary" @click="SaveVideoData" class="action-button save-button">
<template #icon>
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"
/>
</svg>
</n-icon>
</template>
保存配置
</n-button>
</n-space>
</n-form-item>
<div v-if="type == 'bookTask'" style="color: red">
注意 生成草稿/合成视频 前要先保存数据
<!-- 提示信息 -->
<n-alert
:type="type == 'bookTask' ? 'info' : 'warning'"
:show-icon="true"
class="notice-alert"
>
<template #header>
<span class="alert-title">重要提示</span>
</template>
<div v-if="type == 'bookTask'">在生成草稿/合成视频前要先保存数据</div>
<div v-else>
在生成草稿/合成视频前要先保存数据当前会生成选择的草稿/视频全部会使用上面的参数
</div>
<div v-else style="color: red">
注意 生成草稿/合成视频 前要先保存数据当前会生成选择的 草稿/视频全部会使用上面的参数
</div>
<n-form-item style="display: flex; justify-content: flex-end">
</n-alert>
<!-- 主要操作按钮 -->
<n-form-item class="main-action-item">
<div class="main-action-container">
<n-button
v-if="optionType == 'video'"
type="info"
style="margin-left: 10px"
type="success"
size="large"
@click="AddGenerateVideoTask"
>添加合成视频任务</n-button
class="main-action-button"
>
<n-button v-else type="info" style="margin-left: 10px" @click="AddJianyingDraft"
>生成草稿</n-button
<template #icon>
<n-icon size="20">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z"
/>
</svg>
</n-icon>
</template>
添加合成视频任务
</n-button>
<n-button
v-else
type="success"
size="large"
@click="AddJianyingDraft"
class="main-action-button"
>
<template #icon>
<n-icon size="20">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
/>
</svg>
</n-icon>
</template>
生成剪映草稿
</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage, NForm, NFormItem, NButton, NIcon, NInput, NSelect } from 'naive-ui'
import {
useMessage,
useDialog,
NForm,
NFormItem,
NButton,
NIcon,
NInput,
NSelect,
NCard,
NSpace,
NAlert
} from 'naive-ui'
import { FolderOpen } from '@vicons/ionicons5'
import { BookTaskStatus, OperateBookType } from '../../../../../../define/enum/bookEnum'
import { useReverseManageStore } from '../../../../../../stores/reverseManage'
import { useSoftwareStore } from '@/stores/software'
let props = defineProps({
bookTask: undefined,
type: undefined,
selectBookTask: [],
optionType: undefined
const props = defineProps({
bookTask: {
type: Object,
required: true
},
type: {
type: String,
required: true
},
selectBookTask: {
type: Array,
default: () => []
},
optionType: {
type: String,
default: 'draft'
}
})
let bookTask = ref(props.bookTask)
let type = ref(props.type)
const bookTask = ref(props.bookTask)
const type = ref(props.type)
const optionType = ref(props.optionType)
let optionType = ref(props.optionType)
const message = useMessage()
const draftSelect = ref([])
const reverseManageStore = useReverseManageStore()
const softwareStore = useSoftwareStore()
let backgroundMusicOptions = ref([])
let message = useMessage()
let draftSelect = ref([])
let reverseManageStore = useReverseManageStore()
const dialog = useDialog()
onMounted(async () => {
// 稿
@ -123,21 +237,6 @@ onMounted(async () => {
draftSelect.value.push(obj)
})
})
//
await window.api.GetBackgroundMusicConfigList((value) => {
if (value.code == 0) {
message.error(value.message)
return
}
for (let i = 0; i < value.value.length; i++) {
const element = value.value[i]
let obj = {
label: element.name,
value: element.id
}
backgroundMusicOptions.value.push(obj)
}
})
})
/**
@ -166,6 +265,21 @@ async function SelectMusicFile() {
})
}
/**
* 选择背景音乐支持文件夹MP3WAV文件
*/
async function SelectBackgroundMusic() {
//
let res = await window.system.SelectFolderOrFile(['mp3', 'wav'])
console.log('选择的背景音乐:', res)
if (res.code == 0) {
message.error(res.message)
return
}
bookTask.value.backgroundMusic = res.data
}
/**
* 应用主小说相关数据
*/
@ -273,6 +387,10 @@ async function AddGenerateVideoTask() {
* 添加草稿
*/
async function AddJianyingDraft() {
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在生成剪映草稿。。。'
try {
let res = undefined
if (props.type == 'book') {
res = await window.book.AddJianyingDraft(props.selectBookTask, OperateBookType.ASSIGNBOOKTASK)
@ -303,9 +421,173 @@ async function AddJianyingDraft() {
return
}
window.api.showGlobalMessageDialog(res)
} finally {
softwareStore.spin.spinning = false
}
}
let rules = ref({
srtPath: [{ required: true, message: '请选择背景音乐', trigger: ['input', 'blur', 'change'] }],
const rules = ref({
srtPath: [{ required: true, message: '请选择字幕文件', trigger: ['input', 'blur', 'change'] }],
audioPath: [{ required: true, message: '请选择音频文件', trigger: ['input', 'blur', 'change'] }]
})
</script>
<style scoped>
.form-card {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
border-radius: 16px;
border: none;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
animation: fadeInUp 0.6s ease-out;
}
.generate-form {
padding: 8px;
}
.file-input-group {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
}
.file-input {
flex: 1;
min-width: 0;
}
.file-button {
flex-shrink: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2);
transition: all 0.3s ease;
}
.file-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.select-input {
width: 100%;
border-radius: 8px;
}
.draft-section {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.draft-warning {
border-radius: 8px;
border: none;
box-shadow: 0 2px 8px rgba(230, 126, 34, 0.1);
}
.button-group {
width: 100%;
justify-content: center;
}
.action-button {
border-radius: 8px;
padding: 8px 20px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.action-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.save-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.notice-alert {
margin-bottom: 24px;
border-radius: 12px;
border: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.alert-title {
font-weight: 600;
font-size: 15px;
}
.main-action-item {
margin-bottom: 0;
}
.main-action-container {
display: flex;
justify-content: center;
width: 100%;
}
.main-action-button {
padding: 12px 32px;
font-size: 16px;
font-weight: 600;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
min-width: 200px;
}
.main-action-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}
.main-action-button:active {
transform: translateY(-1px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-input-group {
flex-direction: column;
gap: 8px;
}
.file-button {
width: 100%;
}
.button-group {
flex-direction: column;
gap: 12px;
}
.action-button {
width: 100%;
}
.main-action-button {
width: 100%;
min-width: unset;
}
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -62,7 +62,6 @@ let props = defineProps({
let videoType = ref(ImageToVideoModels)
let reverseManageStore = useReverseManageStore()
let message = useMessage()
let softwareStore = useSoftwareStore()
let runwayRef = ref(null)
let videoMessage = ref({})
@ -71,7 +70,6 @@ let lumaOptions = ref({})
let klingOptions = ref({})
async function GetBookTaskDetailOption() {
debugger
let res = await window.db.GetBookTaskDetailProperty(props.bookTaskDetailId, 'videoMessage')
if (res.code != 1) {
message.error(res.message)
@ -184,7 +182,6 @@ async function AddImageToVideoTask() {
} else if (videoMessage.value.videoType == ImageToVideoModels.KLING) {
type = BookBackTaskType.KLING_VIDEO
}
debugger
//
let res = await window.task.AddBookBackTask(
reverseManageStore.selectBook.id,

View File

@ -68,10 +68,16 @@ export default defineComponent({
async function GetBookByCondition() {
try {
//
if (!reverseManageStore.selectBook || !reverseManageStore.selectBook.id) {
reverseManageStore.resetSelectBook()
}
let res = await reverseManageStore.GetBookDataFromDB({
page: paginationReactive.page,
pageSize: paginationReactive.pageSize
})
console.log('获取小说任务数据:', res)
if (res.code == 0) {
message.error(res.message)
return
@ -82,8 +88,8 @@ export default defineComponent({
}
pagination.value.pageCount = res_count
//
// -
if (reverseManageStore.selectBook && reverseManageStore.selectBook.id) {
let bookTask = await reverseManageStore.GetBookTaskDataFromDB({
bookId: reverseManageStore.selectBook.id
})
@ -91,6 +97,7 @@ export default defineComponent({
message.error(bookTask.message)
return
}
}
} catch (error) {
message.error('获取小说数据失败')
}

View File

@ -1,7 +1,7 @@
<template>
<div style="margin-bottom: 5px; margin-left: 5px; display: flex; align-items: center">
<n-button size="small" strong secondary type="default">{{
reverseManageStore.selectBook.name
reverseManageStore.selectBook?.name || '未选择小说'
}}</n-button>
<n-button
style="margin-left: 5px"
@ -76,6 +76,7 @@ import { ResponseMessageType } from '../../../../define/enum/softwareEnum'
import AddBookTask from './Components/ManageBook/AddBookTask.vue'
import ManageBookTaskGenerateInformation from './Components/ManageBook/ManageBookTaskGenerateInformation.vue'
import { isEmpty } from 'lodash'
import { TimeDelay } from '@/define/Tools/time'
let reverseManageStore = useReverseManageStore()
let softwareStore = useSoftwareStore()
@ -174,6 +175,10 @@ function createOptions(row) {
label: `高清 ${row.name}`,
key: 'hd'
},
{
label: `转视频 ${row.name}`,
key: 'image_to_video'
},
{
label: `生成草稿 ${row.name}`,
key: 'draft'
@ -262,6 +267,36 @@ async function hdImageFunc(id, operateBookType) {
})
}
//
async function ImageToVideo(value) {
dialog.warning({
title: '开启图转视频任务',
content: `确定要将图像转换为视频吗?开启之后会在 图/文转视频 任务列表 中显示该任务,若是已经开启的,将直接跳转到图转视频界面,是否继续?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
let res = await window.db.UpdateBookTaskData(value.id, {
openVideoGenerate: true
})
if (res.code == 0) {
message.error(res.message)
return
}
//
message.success('图像转换为视频任务开启成功,即将跳转到图转视频界面!')
await TimeDelay(1000)
reverseManageStore.selectBookTask = value
//
router.push({
name: 'image_text_video'
})
},
onNegativeClick: () => {
message.info('已取消图像转换为视频任务')
}
})
}
/**
* 生成草稿
*/
@ -270,6 +305,8 @@ async function ClipDraft(value) {
closeOnEsc: false,
title: `生成草稿前检查 ${value.name}`,
maskClosable: false,
style: 'width: 800px; max-width: 90vw',
showIcon: false,
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: value,
@ -287,6 +324,8 @@ async function GenerateVideo(value) {
closeOnEsc: false,
title: `合成视频前检查 ${value.name}`,
maskClosable: false,
showIcon: false,
style: 'width: 800px; max-width: 90vw',
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: value,
@ -372,6 +411,9 @@ async function handleSelect(key) {
case 'hd':
await HDImage(selectRow.value)
break
case 'image_to_video':
await ImageToVideo(selectRow.value)
break
case 'draft':
await ClipDraft(selectRow.value)
break
@ -466,7 +508,7 @@ async function HDImageAll() {
}
async function AddBookDialog() {
message.info('新增' + reverseManageStore.selectBook.id)
message.info('新增' + (reverseManageStore.selectBook?.id || '未知小说'))
dialog.create({
title: '新增小说批次任务',
showIcon: false,
@ -487,8 +529,10 @@ async function DraftAll() {
// 稿
dialog.info({
closeOnEsc: false,
title: `生成草稿前检查 ${reverseManageStore.selectBook.name}`,
title: `生成草稿前检查 ${reverseManageStore.selectBook?.name || '未知小说'}`,
maskClosable: false,
showIcon: false,
style: 'width: 800px; max-width: 90vw',
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: reverseManageStore.selectBook,
@ -509,8 +553,10 @@ async function VideoAll() {
// 稿
dialog.info({
closeOnEsc: false,
title: `生成草稿前检查 ${reverseManageStore.selectBook.name}`,
title: `生成草稿前检查 ${reverseManageStore.selectBook?.name || '未知小说'}`,
maskClosable: false,
showIcon: false,
style: 'width: 800px; max-width: 90vw',
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: reverseManageStore.selectBook,

View File

@ -0,0 +1,234 @@
# TextEllipsis 通用文本省略组件
一个智能的文本省略显示组件当内容超出指定宽度时自动显示tooltip否则直接显示完整内容。
## 功能特性
- 🎯 **智能检测**:自动检测内容是否超出最大宽度
- 🏷️ **多组件支持**:支持 `n-text``n-tag` 组件
- 📱 **响应式**:支持动态宽度调整
- 🎨 **高度可定制**支持自定义样式、属性和tooltip配置
- ⚡ **性能优化**只在必要时显示tooltip
## 基本用法
### 1. 文本组件 (n-text)
```vue
<template>
<!-- 基础用法 -->
<TextEllipsis
text="这是一段很长的文本内容,可能会超出指定的宽度"
max-width="150px"
/>
<!-- 自定义文本属性 -->
<TextEllipsis
text="重要信息"
component="n-text"
:component-props="{ strong: true, depth: '1' }"
max-width="100px"
/>
</template>
```
### 2. 标签组件 (n-tag)
```vue
<template>
<!-- 基础标签 -->
<TextEllipsis
text="very-long-task-id-12345678"
component="n-tag"
:component-props="{ type: 'primary', size: 'small' }"
max-width="120px"
/>
<!-- 圆角标签 -->
<TextEllipsis
text="状态标签"
component="n-tag"
:component-props="{ type: 'success', size: 'small', round: true }"
max-width="80px"
/>
</template>
```
## API 参数
### Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `text` | `String` | `''` | 显示的文本内容(必填) |
| `maxWidth` | `String` | `'120px'` | 最大宽度 |
| `component` | `String` | `'n-text'` | 组件类型:`'n-text'``'n-tag'` |
| `componentProps` | `Object` | `{}` | 传递给组件的属性 |
| `customClass` | `String` | `''` | 自定义CSS类名 |
| `showArrow` | `Boolean` | `false` | tooltip是否显示箭头 |
| `trigger` | `String` | `'hover'` | tooltip触发方式 |
| `placement` | `String` | `'top'` | tooltip显示位置 |
| `tooltipMaxWidth` | `String` | `'300px'` | tooltip最大宽度 |
| `forceTooltip` | `Boolean` | `false` | 强制显示tooltip |
| `disableTooltip` | `Boolean` | `false` | 禁用tooltip |
### 暴露的方法
| 方法 | 说明 |
|------|------|
| `checkOverflow()` | 手动检查内容是否溢出 |
| `isOverflow` | 获取当前溢出状态 |
## 高级用法
### 1. 响应式宽度
```vue
<template>
<TextEllipsis
:text="dynamicText"
:max-width="windowWidth < 768 ? '100px' : '200px'"
component="n-tag"
:component-props="{ type: 'info' }"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const windowWidth = ref(window.innerWidth)
const dynamicText = ref('动态内容')
onMounted(() => {
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
</script>
```
### 2. 强制tooltip模式
```vue
<template>
<!-- 总是显示tooltip即使内容不超出 -->
<TextEllipsis
text="短文本"
max-width="200px"
:force-tooltip="true"
component="n-tag"
:component-props="{ type: 'warning' }"
/>
</template>
```
### 3. 禁用tooltip
```vue
<template>
<!-- 只显示省略号不显示tooltip -->
<TextEllipsis
text="很长的文本内容"
max-width="100px"
:disable-tooltip="true"
/>
</template>
```
### 4. 自定义tooltip配置
```vue
<template>
<TextEllipsis
text="自定义tooltip配置的文本"
max-width="120px"
:show-arrow="true"
trigger="click"
placement="bottom"
tooltip-max-width="400px"
/>
</template>
```
## 实际应用场景
### 1. 任务ID显示
```vue
<TextEllipsis
:text="task.id"
component="n-tag"
:component-props="{ type: 'primary', size: 'small' }"
max-width="120px"
custom-class="task-id-tag"
/>
```
### 2. 文件路径显示
```vue
<TextEllipsis
:text="file.path"
component="n-tag"
:component-props="{ type: 'default', size: 'small' }"
max-width="200px"
custom-class="file-path-tag"
/>
```
### 3. 用户名显示
```vue
<TextEllipsis
:text="user.name"
component="n-text"
:component-props="{ strong: true }"
max-width="150px"
/>
```
### 4. 状态标签
```vue
<TextEllipsis
:text="status.message"
component="n-tag"
:component-props="{
type: status.type,
size: 'small',
round: true
}"
max-width="100px"
/>
```
## 注意事项
1. **性能优化**:组件会自动检测内容溢出,但频繁的文本变化可能影响性能
2. **样式继承**:组件会继承父容器的字体样式来准确计算宽度
3. **响应式**:当容器宽度变化时,需要手动调用 `checkOverflow()` 方法
4. **浏览器兼容**:使用了现代浏览器的 API确保目标浏览器支持
## 样式定制
```vue
<style scoped>
/* 自定义样式 */
.my-custom-text {
font-weight: bold;
color: #007bff;
}
.my-custom-tag :deep(.n-tag) {
border-radius: 12px;
}
</style>
<template>
<TextEllipsis
text="自定义样式文本"
max-width="120px"
custom-class="my-custom-text"
/>
</template>
```

View File

@ -0,0 +1,211 @@
<template>
<div class="text-ellipsis-wrapper" :style="{ maxWidth: maxWidth }">
<!-- 如果内容超出限制则显示 tooltip -->
<n-tooltip v-if="isOverflow" :show-arrow="showArrow" :trigger="trigger" :placement="placement">
<template #trigger>
<n-tag
v-if="component === 'n-tag'"
v-bind="componentProps"
:class="['text-ellipsis-content', customClass]"
:style="ellipsisStyle"
>
{{ text }}
</n-tag>
<n-text
v-else
v-bind="componentProps"
:class="['text-ellipsis-content', customClass]"
:style="ellipsisStyle"
>
{{ text }}
</n-text>
</template>
<!-- tooltip 内容 -->
<div class="tooltip-content" :style="{ maxWidth: tooltipMaxWidth }">
{{ text }}
</div>
</n-tooltip>
<!-- 如果内容未超出限制则直接显示 -->
<n-tag
v-else-if="component === 'n-tag'"
v-bind="componentProps"
:class="['text-ellipsis-content', customClass]"
>
{{ text }}
</n-tag>
<n-text
v-else
v-bind="componentProps"
:class="['text-ellipsis-content', customClass]"
>
{{ text }}
</n-text>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { NTooltip, NText, NTag } from 'naive-ui'
// props
const props = defineProps({
//
text: {
type: String,
required: true,
default: ''
},
//
maxWidth: {
type: String,
default: '120px'
},
// 'n-text' 'n-tag'
component: {
type: String,
default: 'n-text',
validator: (value) => ['n-text', 'n-tag'].includes(value)
},
//
componentProps: {
type: Object,
default: () => ({})
},
// CSS
customClass: {
type: String,
default: ''
},
// tooltip
showArrow: {
type: Boolean,
default: false
},
// tooltip
trigger: {
type: String,
default: 'hover',
validator: (value) => ['hover', 'click', 'focus', 'manual'].includes(value)
},
// tooltip
placement: {
type: String,
default: 'top',
validator: (value) => [
'top', 'top-start', 'top-end',
'right', 'right-start', 'right-end',
'bottom', 'bottom-start', 'bottom-end',
'left', 'left-start', 'left-end'
].includes(value)
},
// tooltip
tooltipMaxWidth: {
type: String,
default: '300px'
},
// tooltip
forceTooltip: {
type: Boolean,
default: false
},
// tooltip
disableTooltip: {
type: Boolean,
default: false
}
})
//
const isOverflow = ref(false)
//
const ellipsisStyle = computed(() => ({
maxWidth: props.maxWidth,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle'
}))
//
const checkOverflow = async () => {
if (props.disableTooltip) {
isOverflow.value = false
return
}
if (props.forceTooltip) {
isOverflow.value = true
return
}
//
const maxWidthValue = parseInt(props.maxWidth.replace('px', ''))
const estimatedCharWidth = 14 // 14px
const estimatedTextWidth = props.text.length * estimatedCharWidth
isOverflow.value = estimatedTextWidth > maxWidthValue
}
//
watch([() => props.text, () => props.maxWidth, () => props.forceTooltip, () => props.disableTooltip], () => {
checkOverflow()
}, { immediate: true })
onMounted(() => {
checkOverflow()
})
//
defineExpose({
checkOverflow,
isOverflow: computed(() => isOverflow.value)
})
</script>
<style scoped>
.text-ellipsis-wrapper {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.text-ellipsis-content {
cursor: default;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.text-ellipsis-content:deep(.n-tag) {
display: inline-flex;
align-items: center;
}
.text-ellipsis-content:deep(.n-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.text-ellipsis-content:deep(.n-text) {
display: inline-flex;
align-items: center;
}
.tooltip-content {
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
line-height: 1.4;
}
/* 当组件为 n-tag 时的特殊样式 */
.text-ellipsis-content.n-tag {
max-width: inherit;
}
</style>

View File

@ -87,7 +87,7 @@ async function InitCopyWritingData() {
let saveRes = await window.options.ModifyOptionByKey(
OptionKeyName.CW_AISimpleSetting,
JSON.stringify(optionStore.CW_AISimpleSetting),
OptionType.JOSN
OptionType.JSON
)
if (saveRes.code == 0) {
throw new Error('初始化文案处理界面数据失败,错误信息:' + saveRes.message)

View File

@ -193,7 +193,7 @@ async function SaveData() {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.CW_AISimpleSetting,
JSON.stringify(optionStore.CW_AISimpleSetting),
OptionType.JOSN
OptionType.JSON
)
if (res.code == 0) {
window.api.showGlobalMessageDialog(res)

View File

@ -6,14 +6,19 @@
:options="menuOptions"
:render-icon="renderIcon"
:expand-icon="expandIcon"
:value="currentMenuKey"
default-value="mainHome"
/>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { generateMenuOptions, renderMenuIcon, expandIcon } from '@/renderer/src/common/homeMenu'
//
const route = useRoute()
// Props
const props = defineProps({
collapsed: {
@ -29,6 +34,42 @@ const props = defineProps({
// Emits
const emit = defineEmits(['openBackTask'])
// key
const currentMenuKey = computed(() => {
// key
const routeName = route.name
console.log('当前路由名称:', routeName)
// key
const routeToMenuKeyMap = {
'mainHome': 'mainHome',
'gptCopywriting': 'gptCopywriting',
'sdoriginal': 'sdoriginal',
'getframe': 'backward_frame',
'copywriting': 'copywriting',
'pushBackPrompt': 'push_back',
'regenerate': 'regenerate',
'VideoGenerate': 'VideoGenerate',
'book_management': 'book_management',
'image_text_video': 'image_text_video',
'image_text_video_info': 'image_text_video',
'lai_api': 'lai_api',
'TTS_Services': 'TTS_Services',
'global_setting': 'global_setting',
'clip_setting': 'clip_setting',
'videogeneratesetting': 'videogeneratesetting',
'sd_setting': 'sd_setting',
'mj_setting': 'mj_setting',
'toolbox': 'toolbox',
'image-upload': 'toolbox',
'image-compress': 'toolbox'
}
const menuKey = routeToMenuKeyMap[routeName] || 'mainHome'
console.log('计算出的菜单key:', menuKey)
return menuKey
})
//
const menuOptions = computed(() => {
return generateMenuOptions(props.showOriginal, () => {

View File

@ -29,10 +29,12 @@
<div v-if="selectedNovel" class="chapter-container">
<!-- 章节列表 -->
<ChapterList
:key="forceRenderKey"
:selected-novel="selectedNovel"
@open-chapter="handleOpenChapter"
@add-tag="handleAddTag"
@remove-tag="handleRemoveTag"
@refresh-data="refreshData"
/>
</div>
@ -46,7 +48,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, provide } from 'vue'
import { useMessage } from 'naive-ui'
import NovelSidebar from './components/ImageTextVideo/ImageTextVideoBookSidebar.vue'
import MobileHeader from './components/ImageTextVideo/ImageTextVideoMobileHeader.vue'
@ -54,6 +56,7 @@ import ChapterList from './components/ImageTextVideo/ImageTextVideoTaskList.vue'
import EmptyState from './components/ImageTextVideo/ImageTextVideoEmptyState.vue'
import { useReverseManageStore } from '@/stores/reverseManage'
import { isEmpty } from 'lodash'
const reverseManageStore = useReverseManageStore()
const message = useMessage()
@ -61,16 +64,48 @@ const message = useMessage()
// ID
const selectedNovelId = ref('novel-1')
// key
const forceRenderKey = ref(0)
//
const isMobileSidebarOpen = ref(false)
const bookInfoList = ref([])
onMounted(async () => {
//
await refreshData()
handleSelectNovel()
})
//
async function refreshData() {
try {
let res = await window.book.video.GetVideoBookInfoList({})
console.log('获取小说数据', res)
bookInfoList.value = res.data
})
debugger
//
if (selectedNovelId.value && !isEmpty(selectedNovelId.value)) {
//
const currentId = selectedNovelId.value
handleSelectNovel(currentId)
} else {
// store
const selectedBook = bookInfoList.value.find((book) => book.id === selectedNovelId.value)
if (selectedBook) {
reverseManageStore.selectBook = selectedBook
}
}
//
forceRenderKey.value++
console.log('强制重新渲染key:', forceRenderKey.value)
} catch (error) {
console.error('刷新数据失败:', error)
message.error('刷新数据失败')
}
}
//
const selectedNovel = computed(() => {
@ -79,9 +114,24 @@ const selectedNovel = computed(() => {
//
function handleSelectNovel(novelId) {
if (isEmpty(novelId)) {
novelId = reverseManageStore.selectBook?.id || ''
}
if (isEmpty(novelId)) {
return
}
selectedNovelId.value = novelId
reverseManageStore.selectBook = bookInfoList.value.find((book) => book.id === novelId)
const selectedBook = bookInfoList.value.find((book) => book.id === novelId)
if (selectedBook) {
reverseManageStore.selectBook = selectedBook
console.log('选择小说:', novelId)
// UI
forceRenderKey.value++
console.log('选择小说后强制重新渲染key:', forceRenderKey.value)
} else {
console.warn('未找到对应的小说数据:', novelId)
}
//
closeMobileSidebar()
}

View File

@ -6,14 +6,18 @@
<!-- 左侧表格区域 -->
<n-layout-content class="left-panel">
<image-text-video-info-task-list
:table-data="tableData"
v-if="!loading && reverseManageStore.selectBookTaskDetail"
:loading="loading"
:show-right-panel="showRightPanel"
:show-pagination="showPagination"
@view-detail="handleViewDetail"
@toggle-status="handleToggleStatus"
@add-task="handleAdd"
@toggle-right-panel="handleToggleRightPanel"
/>
<div v-else class="loading-container">
<n-spin size="large">
<template #description> 正在加载任务数据... </template>
</n-spin>
</div>
</n-layout-content>
<!-- 右侧详细操作区域 -->
@ -34,7 +38,6 @@
<image-text-video-info-task-detail
v-else
:task="selectedTask"
@delete-task="handleDeleteTask"
@edit-task="handleEditTask"
@config-change="handleConfigChange"
/>
@ -59,9 +62,7 @@
<image-text-video-info-task-detail
v-else
:task="selectedTask"
@delete-task="handleDeleteTask"
@edit-task="handleEditTask"
@config-change="handleConfigChange"
/>
</n-drawer-content>
</n-drawer>
@ -69,13 +70,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import {
NLayout,
NLayoutSider,
NLayoutContent,
NDrawer,
NDrawerContent,
NSpin,
useMessage
} from 'naive-ui'
import ImageTextVideoInfoTaskList from './components/ImageTextVideoInfo/ImageTextVideoInfoTaskList.vue'
@ -83,14 +85,16 @@ import ImageTextVideoInfoTaskDetail from './components/ImageTextVideoInfo/ImageT
import ImageTextVideoInfoEmptyState from './components/ImageTextVideoInfo/ImageTextVideoInfoEmptyState.vue'
import { useReverseManageStore } from '@/stores/reverseManage'
import { OptionKeyName } from '@/define/enum/option'
import { optionSerialization } from '@/main/Service/Options/optionSerialization'
import { DEFINE_STRING } from '@/define/define_string'
import { ResponseMessageType } from '@/define/enum/softwareEnum'
import { VideoStatus } from '@/define/enum/video'
const reverseManageStore = useReverseManageStore()
const message = useMessage()
//
const tableData = ref([])
//
const loading = ref(false)
@ -100,32 +104,165 @@ const selectedTask = ref(null)
//
const showRightPanel = ref(false)
//
const showPagination = ref(true)
//
const showDrawer = ref(false)
async function handleInitialize() {
//
loading.value = true
try {
//
console.log('初始化 ImageTextVideoHome 组件')
// store
if (!reverseManageStore.selectBookTask?.id) {
console.error('未找到选中的任务,无法继续初始化')
message.error('未选择任务,请返回重新选择')
return
}
console.log('当前选中任务:', reverseManageStore.selectBookTask)
// option
let showRightPanelOptionRes = await window.options.GetOptionByKey(
OptionKeyName.ImageToVideo_ShowRightPanel
)
if (showRightPanelOptionRes.code != 1) {
message.error(showRightPanelOptionRes.message || '加载设置失败')
return
}
showRightPanel.value = optionSerialization(showRightPanelOptionRes.data)
// option
let showPaginationOptionRes = await window.options.GetOptionByKey(
OptionKeyName.ImageToVideo_ShowPagination
)
if (showPaginationOptionRes.code != 1) {
message.error(showPaginationOptionRes.message || '加载设置失败')
return
}
showPagination.value = optionSerialization(showPaginationOptionRes.data)
// 使
console.log('开始加载任务详情数据...')
let res = await reverseManageStore.GetBookTaskDetail(reverseManageStore.selectBookTask.id)
console.log('获取任务数据', res)
if (res.code != 1) {
message.error(res.message)
return
}
tableData.value = res.data
reverseManageStore.selectBookTaskDetail = res.data
console.log('任务详情数据加载完成:', res.data)
await initVideoMessage(res.data)
console.log('视频消息初始化完成')
} catch (error) {
message.error('加载数据失败')
console.error('加载数据失败:', error)
message.error('加载数据失败: ' + error.message)
} finally {
loading.value = false
}
}
onMounted(() => {
handleInitialize()
// videoMessage
async function initVideoMessage(bookTaskDetail) {
if (bookTaskDetail.length === 0) {
return
}
for (const task of bookTaskDetail) {
if (!task.videoMessage) {
// videoMessage
let initRes = await window.book.video.InitVideoMessage(task.id)
if (initRes.code !== 1) {
message.error(`初始化任务 ${task.name} 的视频信息失败: ${initRes.message}`)
} else {
task.videoMessage = initRes.data
}
}
}
}
onMounted(async () => {
console.log('ImageTextVideoInfoHome onMounted 开始执行')
try {
await handleInitialize()
handleIpcTaskListChange()
console.log('ImageTextVideoInfoHome onMounted 执行完成')
} catch (error) {
console.error('ImageTextVideoInfoHome onMounted 执行失败:', error)
message.error('组件初始化失败: ' + error.message)
}
})
onUnmounted(() => {
//
window.api.removeEventListen([DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN])
})
function handleIpcTaskListChange() {
// SD
window.api.setEventListen([DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN], (value) => {
try {
if (value.type == ResponseMessageType.MJ_VIDEO) {
//
let videoMessage = JSON.parse(value.data)
console.log('收到 mj video视频处理进度', videoMessage)
let findIndex = reverseManageStore.selectBookTaskDetail.findIndex(
(item) => item.id === value.id
)
if (findIndex !== -1) {
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.status =
videoMessage.status
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.taskId =
videoMessage.taskId
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.msg = videoMessage.msg
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.messageData =
videoMessage.messageData
}
} else if (value.type == ResponseMessageType.MJ_VIDEO_EXTEND) {
//
let videoMessage = JSON.parse(value.data)
console.log('收到 mj video extend 视频处理进度', videoMessage)
let findIndex = reverseManageStore.selectBookTaskDetail.findIndex(
(item) => item.id === value.id
)
if (findIndex !== -1) {
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.status =
videoMessage.status
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.taskId =
videoMessage.taskId
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.msg = videoMessage.msg
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.messageData =
videoMessage.messageData
}
} else if (value.type == ResponseMessageType.VIDEO_SUCESS) {
//
let bookTaskDetail = JSON.parse(value.data)
console.log('视频处理完成', bookTaskDetail)
let findIndex = reverseManageStore.selectBookTaskDetail.findIndex(
(item) => item.id === bookTaskDetail.id
)
if (findIndex !== -1) {
reverseManageStore.selectBookTaskDetail[findIndex] = bookTaskDetail
}
} else {
console.error('未知的返回类型', value.type, value)
let findIndex = reverseManageStore.selectBookTaskDetail.findIndex(
(item) => item.id === value.id
)
if (findIndex != -1) {
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.status = VideoStatus.FAIL
reverseManageStore.selectBookTaskDetail[findIndex].videoMessage.msg = value.message
}
}
} catch (error) {
console.error('处理IPC数据时发生错误:', error)
message.error('处理数据时发生错误,' + error.message)
}
})
}
//
function handleViewDetail(row) {
selectedTask.value = { ...row }
@ -147,55 +284,6 @@ function handleToggleRightPanel(show) {
showDrawer.value = false
}
}
//
function handleToggleStatus(row) {
const task = tableData.value.find((item) => item.id === row.id)
if (task) {
if (task.status === '进行中') {
task.status = '暂停'
message.warning(`任务 ${task.name} 已暂停`)
} else if (task.status === '暂停') {
task.status = '进行中'
message.success(`任务 ${task.name} 已启动`)
} else {
message.error('当前状态不支持切换')
}
//
if (selectedTask.value && selectedTask.value.id === task.id) {
selectedTask.value = { ...task }
}
}
}
//
function handleAdd() {
message.info('新增任务功能开发中...')
}
//
function handleEditTask(task) {
message.info(`编辑任务: ${task.name}`)
}
//
function handleDeleteTask(task) {
const index = tableData.value.findIndex((item) => item.id === task.id)
if (index > -1) {
tableData.value.splice(index, 1)
selectedTask.value = null
}
}
//
function handleConfigChange(task) {
//
const tableTask = tableData.value.find((item) => item.id === task.id)
if (tableTask) {
tableTask.config = { ...task.config }
}
}
</script>
<style scoped>
@ -249,6 +337,14 @@ function handleConfigChange(task) {
overflow-y: auto;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.layout-wrapper {

View File

@ -2,7 +2,7 @@
<n-card
class="bookTask-card"
hoverable
@click="handleOpenBookTask"
@dblclick="handleOpenBookTask"
:header-style="{
padding: '8px 16px'
}"
@ -15,7 +15,7 @@
<n-space justify="space-between" align="center">
<n-text strong>{{ bookTask.name }}</n-text>
<n-dropdown :options="getChapterMenuOptions(bookTask)" @select="handleChapterAction">
<n-button size="small" quaternary circle>
<n-button size="small" quaternary circle @click.stop>
<template #icon>
<n-icon><EllipsisVerticalOutline /></n-icon>
</template>
@ -28,13 +28,7 @@
<n-space vertical size="small">
<!-- 标签 -->
<n-space size="small" wrap>
<n-tag
v-for="tag in bookTask.tags"
:key="tag"
size="small"
type="primary"
@close="handleRemoveTag(bookTask.id, tag)"
>
<n-tag v-for="tag in bookTask.tags" :key="tag" size="small" type="primary">
{{ tag }}
</n-tag>
</n-space>
@ -47,7 +41,7 @@
<div>
<n-space justify="space-between" style="margin-bottom: 4px">
<n-text depth="3" style="font-size: 12px">转视频进度</n-text>
<n-text depth="3" style="font-size: 12px">{{ progress }}%</n-text>
<n-text depth="3" style="font-size: 12px">{{ Math.floor(progress) }}%</n-text>
</n-space>
<n-progress
type="line"
@ -78,16 +72,17 @@ import {
} from 'naive-ui'
import {
EllipsisVerticalOutline,
AddOutline,
CreateOutline,
TrashOutline,
EyeOutline
EnterOutline,
CloseCircleOutline,
DocumentTextOutline
} from '@vicons/ionicons5'
import { GetBookImageCategoryLabel } from '@/define/enum/bookEnum'
import { GetBookImageCategoryLabel, OperateBookType } from '@/define/enum/bookEnum'
import { GetImageToVideoModelsLabel } from '@/define/enum/video'
import { FormatDate } from '@/renderer/src/common/time'
import { useRouter } from 'vue-router'
import { useReverseManageStore } from '@/stores/reverseManage'
import ManageBookTaskGenerateInformation from '@/renderer/src/components/Book/Components/ManageBook/ManageBookTaskGenerateInformation.vue'
const reverseManageStore = useReverseManageStore()
@ -100,6 +95,8 @@ let props = defineProps({
}
})
const emit = defineEmits(['refresh-data'])
const message = useMessage()
const dialog = useDialog()
const bookTask = toRef(props, 'bookTask')
@ -130,60 +127,125 @@ function handleOpenBookTask() {
console.log('当前URL:', window.location.href)
}
//
function handleRemoveTag(bookTaskId, tag) {
console.log('移除标签:', bookTaskId, tag)
message.info(`已移除标签: ${tag}`)
//
//
async function handleCloseVideoGenerate() {
dialog.warning({
title: '确认关闭视频生成',
content: `确定要关闭任务 "${bookTask.value.name}" 的视频生成吗?此操作会讲当前图/文生视频的批次删除,不影响聚合推文。并且已有的视频不会被导出,是否继续操作!!`,
positiveText: '确认关闭',
negativeText: '取消',
onPositiveClick: async () => {
try {
let res = await window.db.UpdateBookTaskData(bookTask.value.id, {
openVideoGenerate: !bookTask.value.openVideoGenerate
})
if (res.code == 0) {
message.error(res.message)
return
}
message.success(`已关闭任务 "${bookTask.value.name}" 的视频生成`)
//
emit('refresh-data', bookTask.value.id)
} catch (error) {
message.error(`关闭视频生成失败: ${error.message}`)
}
}
})
}
//
function getChapterMenuOptions(bookTask) {
return [
{
label: '阅读',
key: 'read',
icon: () => h(NIcon, null, () => h(EyeOutline))
},
{
label: '编辑',
key: 'edit',
icon: () => h(NIcon, null, () => h(CreateOutline))
},
{
type: 'divider'
},
{
label: '删除',
key: 'delete',
icon: () => h(NIcon, null, () => h(TrashOutline))
}
]
// 稿
async function handleExportDraft() {
dialog.info({
closeOnEsc: false,
title: `生成草稿前检查 ${bookTask.value.name}`,
maskClosable: false,
style: 'width: 800px; max-width: 90vw',
showIcon: false,
content: () =>
h(ManageBookTaskGenerateInformation, {
bookTask: bookTask.value,
type: 'bookTask',
optionType: 'draft'
})
})
}
//
function handleChapterAction(key, option) {
console.log('章节操作:', key)
switch (key) {
case 'read':
message.info('正在打开阅读界面...')
case 'open-info':
handleOpenBookTask()
break
case 'edit':
message.info('正在打开编辑界面...')
case 'export-draft':
handleExportDraft()
break
case 'delete':
case 'close-generate':
handleCloseVideoGenerate()
break
case 'delete-book-task':
dialog.warning({
title: '确认删除',
content: '确定要删除这个章节吗?此操作不可恢复。',
positiveText: '删除',
content: `确定要删除任务 "${bookTask.value.name}" 吗?继续操作会删除此任务且会同步删除聚合推文中的批次任务,若只是删除视频生成任务,请使用“关闭视频生成”按钮,是否继续?`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => {
message.success('章节已删除')
onPositiveClick: async () => {
try {
let res = await window.book.DeleteBookTask(bookTask.value.id, OperateBookType.BOOKTASK)
console.log('删除任务结果:', res)
if (res.code == 1) {
message.success('任务已删除')
//
emit('refresh-data', bookTask.value.id)
} else {
message.error(res.message)
}
} catch (error) {
message.error(`删除任务失败: ${error.message}`)
}
},
onNegativeClick: () => {
message.info('已取消删除操作')
}
})
break
}
}
//
function getChapterMenuOptions(bookTask) {
return [
{
label: '进入主界面',
key: 'open-info',
icon: () => h(NIcon, null, () => h(EnterOutline))
},
{
label: '导出剪映草稿',
key: 'export-draft',
icon: () => h(NIcon, null, () => h(DocumentTextOutline))
},
{
label: '关闭视频生成',
key: 'close-generate',
icon: () =>
h('div', { style: 'display: flex; align-items: center;' }, [
h(NIcon, null, () => h(CloseCircleOutline))
])
},
{
type: 'divider'
},
{
label: () => h('span', { style: 'color: #e74c3c' }, '删除任务'),
key: 'delete-book-task',
icon: () =>
h('div', { style: 'color: #e74c3c; display: flex; align-items: center;' }, [
h(NIcon, null, () => h(TrashOutline))
])
}
]
}
</script>
<style scoped>

View File

@ -1,12 +1,22 @@
<template>
<n-card title="批次目录" :bordered="false" :style="{ minWidth: '380px' }">
<n-card :bordered="false" :style="{ minWidth: '380px' }">
<template #header>
<n-tooltip :show="needTooltip ? undefined : false" trigger="hover" placement="top">
<template #trigger>
<span>{{ title }}</span>
</template>
<template v-if="needTooltip">
{{ fullTitle }}
</template>
</n-tooltip>
</template>
<template #header-extra>
<n-space align="center">
<n-text depth="3"> {{ selectedNovel.bookTasks.length }} 个视频批次</n-text>
<n-divider vertical />
<n-tooltip trigger="hover">
<template #trigger>
<n-button text type="primary" size="small">
<n-button text type="primary" size="small" @click="addNewVideoBatch">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
@ -20,28 +30,17 @@
<div class="task-grid">
<div v-for="bookTask in selectedBook.bookTasks" :key="bookTask.id" class="task-item">
<ChapterCard :bookTask="bookTask" />
<ChapterCard :bookTask="bookTask" @refresh-data="handleTaskDeleted" />
</div>
</div>
</n-card>
</template>
<script setup>
import { computed } from 'vue'
import {
NCard,
NSpace,
NText,
NDivider,
NGrid,
NGridItem,
NButton,
NTooltip,
NIcon
} from 'naive-ui'
import { ref, computed, onMounted } from 'vue'
import { NCard, NSpace, NText, NDivider, NButton, NTooltip, NIcon } from 'naive-ui'
import ChapterCard from './ImageTextVideoTaskCard.vue'
import { AddOutline } from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
const props = defineProps({
selectedNovel: {
@ -50,8 +49,37 @@ const props = defineProps({
}
})
const emit = defineEmits(['refresh-data'])
const selectedBook = ref(props.selectedNovel)
//
function handleTaskDeleted(taskId) {
console.log('任务已删除:', taskId)
// ImageTextVideoHome
emit('refresh-data')
}
// tooltip
const fullTitle = computed(() => {
return `视频批次列表 - ${selectedBook.value.name}`
})
// card
const title = computed(() => {
const bookName = selectedBook.value.name
const maxLength = 12 //
const truncatedName =
bookName.length > maxLength ? bookName.substring(0, maxLength) + '...' : bookName
return `视频批次列表 - ${truncatedName}`
})
// tooltip
const needTooltip = computed(() => {
return selectedBook.value.name.length > 12
})
//
async function progressHandle() {
//
let res = await window.book.video.GetBookImageAndVideoProgress(props.selectedNovel.id, null)

View File

@ -1,34 +1,84 @@
<template>
<n-card title="基本信息" class="info-card">
<n-descriptions :column="2" label-placement="left" class="descriptions">
<n-descriptions-item label="任务名称">
{{ task.name }}
</n-descriptions-item>
<n-descriptions-item label="任务状态">
<n-tag :type="getStatusType(task.status)">
{{ task.status }}
<n-card class="info-card" size="small">
<template #header>
<div class="card-header">
<n-icon size="20" class="header-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
/>
</svg>
</n-icon>
<span class="header-title">基本信息</span>
</div>
</template>
<div class="info-container">
<div class="info-grid">
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="label-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</svg>
</n-icon>
<span>任务名称</span>
</div>
<div class="info-value">
<n-text strong>{{ task.name }}</n-text>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="label-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<span>任务状态</span>
</div>
<div class="info-value">
<n-tag
:type="task.videoMessage?.status == 'error' ? 'error' : 'primary'"
size="small"
round
>
{{ task.videoMessage?.status }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ formatDate(task.createTime) }}
</n-descriptions-item>
<n-descriptions-item label="更新时间">
{{ formatDate(task.updateTime) }}
</n-descriptions-item>
<n-descriptions-item label="任务描述" :span="2">
{{ task.description || '暂无描述' }}
</n-descriptions-item>
</n-descriptions>
</div>
</div>
<div class="info-item description-item" v-if="task.videoMessage?.msg">
<div class="info-label">
<n-icon size="16" class="label-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M16,11H8V13H16V11M16,15H8V17H16V15Z"
/>
</svg>
</n-icon>
<span>任务描述</span>
</div>
<div class="info-value description-value">
<n-text>{{ task.videoMessage?.msg }}</n-text>
</div>
</div>
</div>
</div>
</n-card>
</template>
<script setup>
import {
NCard,
NDescriptions,
NDescriptionsItem,
NTag
} from 'naive-ui'
import { NCard, NTag, NIcon, NText } from 'naive-ui'
// props
const props = defineProps({
@ -37,36 +87,144 @@ const props = defineProps({
required: true
}
})
//
function getStatusType(status) {
const statusMap = {
进行中: 'info',
已完成: 'success',
暂停: 'warning',
失败: 'error'
}
return statusMap[status] || 'default'
}
//
function formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
margin-bottom: 12px;
transition: all 0.3s ease;
border-radius: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.descriptions :deep(.n-descriptions-item-label) {
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
color: #18a058;
}
.header-title {
font-weight: 600;
font-size: 16px;
color: var(--n-text-color);
}
.info-container {
padding: 4px 0;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border-radius: 8px;
border: 1px solid #e8e8e8;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.info-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: linear-gradient(to bottom, #18a058, #36ad6a);
transition: width 0.3s ease;
}
.info-item:hover {
transform: translateY(-1px);
box-shadow: 0 3px 12px rgba(24, 160, 88, 0.12);
border-color: #18a058;
}
.info-item:hover::before {
width: 5px;
}
.description-item {
grid-column: 1 / -1;
}
.info-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
font-size: 14px;
margin-bottom: 4px;
}
.label-icon {
color: #18a058;
flex-shrink: 0;
}
.info-value {
display: flex;
align-items: center;
min-height: 24px;
font-size: 14px;
}
.description-value {
align-items: flex-start;
line-height: 1.6;
word-break: break-word;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.info-item {
padding: 12px;
}
.header-title {
font-size: 14px;
}
}
/* 深色模式适配 */
.info-card :deep(.n-card) {
background: var(--n-card-color);
}
.info-item {
background: var(--n-body-color);
border-color: var(--n-border-color);
}
/* 状态标签动画 */
.info-item :deep(.n-tag) {
transition: all 0.3s ease;
}
.info-item:hover :deep(.n-tag) {
transform: scale(1.05);
}
</style>

View File

@ -1,46 +1,252 @@
<template>
<n-card title="参数配置" class="info-card">
<n-form :model="task.config" label-placement="left" label-width="120px">
<n-form-item label="输出格式">
<n-card class="video-info-card" size="small">
<template #header>
<div class="card-header">
<n-icon size="20" class="header-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z"
/>
</svg>
</n-icon>
<span class="header-title">{{ getTypeTitle() }}</span>
<n-select
v-model:value="task.config.outputFormat"
:options="outputFormatOptions"
@update:value="handleConfigChange"
v-model:value="selectedVideoType"
:options="GetImageToVideoModelsOptions()"
size="small"
style="width: 120px"
@update:value="handleVideoTypeChange"
/>
</n-form-item>
<n-form-item label="质量设置">
<n-slider
v-model:value="task.config.quality"
:min="1"
:max="10"
:step="1"
:marks="{ 1: '低', 5: '中', 10: '高' }"
@update:value="handleConfigChange"
</div>
</template>
<!-- Runway 类型 -->
<div v-if="selectedVideoType == 'RUNWAY'" class="info-content">
<div class="info-section">
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</n-form-item>
<n-form-item label="自动处理">
<n-switch
v-model:value="task.config.autoProcess"
@update:value="handleConfigChange"
</svg>
</n-icon>
<span>模型</span>
</div>
<div class="info-value">
<n-tag size="small" type="info">{{ runwayOptions?.model || 'gen-3' }}</n-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2L13.09,8.26L22,9L13.09,9.74L12,16L10.91,9.74L2,9L10.91,8.26L12,2Z"
/>
</n-form-item>
</n-form>
</svg>
</n-icon>
<span>时长</span>
</div>
<div class="info-value">
<n-text>{{ runwayOptions?.options?.seconds || 5 }}</n-text>
</div>
</div>
<div class="info-item" v-if="runwayOptions?.style">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<span>风格</span>
</div>
<div class="info-value">
<n-text>{{ runwayOptions.style }}</n-text>
</div>
</div>
</div>
</div>
<!-- Luma 类型 -->
<div v-else-if="selectedVideoType === 'LUMA'" class="info-content">
<div class="info-section">
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"
/>
</svg>
</n-icon>
<span>宽高比</span>
</div>
<div class="info-value">
<n-tag size="small" type="info">{{ lumaOptions?.aspect_ratio || '16:9' }}</n-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2L13.09,8.26L22,9L13.09,9.74L12,16L10.91,9.74L2,9L10.91,8.26L12,2Z"
/>
</svg>
</n-icon>
<span>扩展提示词</span>
</div>
<div class="info-value">
<n-tag size="small" :type="lumaOptions?.expand_prompt ? 'success' : 'warning'">
{{ lumaOptions?.expand_prompt ? '是' : '否' }}
</n-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<span>循环模式</span>
</div>
<div class="info-value">
<n-tag size="small" :type="lumaOptions?.loop ? 'success' : 'default'">
{{ lumaOptions?.loop ? '开启' : '关闭' }}
</n-tag>
</div>
</div>
</div>
</div>
<!-- Kling 类型 -->
<div v-else-if="selectedVideoType === 'KLING'" class="info-content">
<div class="info-section">
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</svg>
</n-icon>
<span>模型</span>
</div>
<div class="info-value">
<n-tag size="small" type="info">{{ klingOptions?.model || 'kling-v1' }}</n-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2L13.09,8.26L22,9L13.09,9.74L12,16L10.91,9.74L2,9L10.91,8.26L12,2Z"
/>
</svg>
</n-icon>
<span>模式</span>
</div>
<div class="info-value">
<n-tag size="small" :type="klingOptions?.mode === 'pro' ? 'success' : 'info'">
{{ klingOptions?.mode === 'pro' ? '高表现' : '高性能' }}
</n-tag>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<span>时长</span>
</div>
<div class="info-value">
<n-text>{{ klingOptions?.duration || 5 }}</n-text>
</div>
</div>
<div class="info-item" v-if="klingOptions?.cfg_scale">
<div class="info-label">
<n-icon size="16">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"
/>
</svg>
</n-icon>
<span>提示词相关性</span>
</div>
<div class="info-value">
<n-text>{{ klingOptions.cfg_scale }}</n-text>
</div>
</div>
</div>
</div>
<!-- Midjourney 类型 -->
<div v-else-if="selectedVideoType === 'MJ_VIDEO'" class="info-content">
<ImageTextVideoInfoMJVideoInfo :task="task" />
</div>
<!-- 未知类型或无数据 -->
<div v-else class="info-content">
<div class="empty-state">
<n-icon size="48" depth="3">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<n-text depth="3">暂无视频配置信息</n-text>
</div>
</div>
</n-card>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { NCard, NTag, NIcon, NText, NSelect, useMessage } from 'naive-ui'
import {
NCard,
NForm,
NFormItem,
NSelect,
NSlider,
NSwitch
} from 'naive-ui'
GetImageToVideoModelsLabel,
GetImageToVideoModelsOptions,
ImageToVideoModels
} from '@/define/enum/video'
import { useReverseManageStore } from '@/stores/reverseManage'
import ImageTextVideoInfoMJVideoInfo from './ImageTextVideoInfoMJVideo/ImageTextVideoInfoMJVideoInfo.vue'
// emits
const emit = defineEmits(['config-change'])
const message = useMessage()
const reverseManageStore = useReverseManageStore()
// props
const props = defineProps({
@ -50,27 +256,240 @@ const props = defineProps({
}
})
//
const outputFormatOptions = [
{ label: 'MP4', value: 'mp4' },
{ label: 'AVI', value: 'avi' },
{ label: 'MOV', value: 'mov' },
{ label: 'WMV', value: 'wmv' }
]
// emits
const emit = defineEmits(['update-video-type'])
//
function handleConfigChange() {
emit('config-change')
const selectedVideoType = computed(() => {
return props.task?.videoMessage?.videoType || ImageToVideoModels.MJ_VIDEO
})
//
// const selectedVideoType = ref(videoType.value)
//
async function handleVideoTypeChange(value) {
try {
let res = await window.book.video.UpdateBookTaskDetailVideoMessage(props.task.id, {
videoType: value
})
if (res.code !== 1) {
message.error(`保存失败: ${res.message}`)
return
}
// selectedVideoType
if (props.task.videoMessage) {
props.task.videoMessage.videoType = value
} else {
props.task.videoMessage = { videoType: value }
}
message.success(`视频类型已更改为:${GetImageToVideoModelsLabel(value)}`)
} catch (error) {
console.error('更新视频类型失败:', error)
message.error('更新视频类型失败,请重试')
}
}
const videoMessage = computed(() => {
return props.task?.videoMessage || {}
})
//
const runwayOptions = computed(() => {
if (videoMessage.value?.runwayOptions) {
try {
return JSON.parse(videoMessage.value.runwayOptions)
} catch {
return {}
}
}
return {}
})
const lumaOptions = computed(() => {
if (videoMessage.value?.lumaOptions) {
try {
return JSON.parse(videoMessage.value.lumaOptions)
} catch {
return {}
}
}
return {}
})
const klingOptions = computed(() => {
if (videoMessage.value?.klingOptions) {
try {
return JSON.parse(videoMessage.value.klingOptions)
} catch {
return {}
}
}
return {}
})
const mjVideoOptions = computed(() => {
if (videoMessage.value?.mjVideoOptions) {
try {
return JSON.parse(videoMessage.value.mjVideoOptions)
} catch {
return {}
}
}
return {}
})
//
function getTypeTitle() {
const titles = {
[ImageToVideoModels.RUNWAY]: 'Runway 视频配置',
[ImageToVideoModels.LUMA]: 'Luma 视频配置',
[ImageToVideoModels.KLING]: 'Kling 视频配置',
[ImageToVideoModels.MJ_VIDEO]: 'Midjourney 视频配置',
[ImageToVideoModels.PIKA]: 'PIKA 视频配置'
}
return titles[selectedVideoType.value] || '视频配置信息'
}
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
.video-info-card {
margin-bottom: 12px;
transition: all 0.3s ease;
border-radius: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.video-info-card:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
/* transform: translateY(-1px); */
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
color: #18a058;
}
.header-title {
font-weight: 600;
font-size: 16px;
color: var(--n-text-color);
flex: 1;
}
.info-content {
padding: 4px 0;
}
.info-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border-radius: 8px;
border: 1px solid #e8e8e8;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.info-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: linear-gradient(to bottom, #18a058, #36ad6a);
transition: width 0.3s ease;
}
.info-item:hover {
transform: translateY(-1px);
box-shadow: 0 3px 12px rgba(24, 160, 88, 0.12);
border-color: #18a058;
}
.info-item:hover::before {
width: 5px;
}
.info-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
font-size: 14px;
color: #606266;
margin-bottom: 4px;
}
.info-label n-icon {
color: #18a058;
flex-shrink: 0;
}
.info-value {
display: flex;
align-items: center;
min-height: 24px;
font-size: 14px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-section {
grid-template-columns: 1fr;
gap: 10px;
}
.info-item {
padding: 10px;
}
.header-title {
font-size: 14px;
}
}
/* 深色模式适配 */
.video-info-card :deep(.n-card) {
background: var(--n-card-color);
}
.info-item {
background: var(--n-body-color);
border-color: var(--n-border-color);
}
/* 标签动画 */
.info-item :deep(.n-tag) {
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.info-item:hover :deep(.n-tag) {
transform: scale(1.05);
}
</style>

View File

@ -0,0 +1,344 @@
<template>
<n-space vertical :size="12">
<n-alert type="success" :show-icon="false" title="适配拓展说明">
<div>
视频生成时长从 5
秒开始但并非仅限于此视频制作完成后您可以在当前界面为选定的适配进行延长
</div>
<br />
<div>您可以随意将视频延长最多 4 直至达到 21 即可用的最大长度</div>
</n-alert>
<!-- 父任务ID -->
<n-form-item label="父任务ID" required :show-feedback="false">
<div
style="
display: flex;
gap: 4px;
width: 100%;
justify-content: space-between;
align-items: center;
"
>
<n-input
v-model:value="videoMessage.mjVideoOptionsObject.taskId"
placeholder="请输入需要操作的视频任务ID"
size="small"
:disabled="loading"
@update-value="(value) => handleVideoMessageChange('taskId', value)"
style="flex: 1"
/>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">父任务ID说明</div>
<div>点击按钮可以选择当前分镜的父任务ID转视频类别为 MJ_VIDEO 的任务</div>
<div style="margin-top: 8px; font-weight: bold">
也可手动输入 MJ_VIDEO 的任务ID手动输入时视频索引也需手动数据
</div>
<div style="margin-top: 8px; color: #f56c6c">
注意如果当前任务不是 MJ_VIDEO 类型则无法选择父任务
</div>
</div>
</n-tooltip>
<n-button
size="small"
type="primary"
:disabled="loading"
ghost
@click="handleSelectParentTask"
>
<template #icon>
<n-icon size="16">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
>
<path
d="M428.8 137.6h-86.177a115.52 115.52 0 0 0 2.176-22.4c0-47.914-35.072-83.2-92-83.2c-45.314 0-57.002 48.537-75.707 78.784c-7.735 12.413-16.994 23.317-25.851 33.253l-.131.146l-.129.148C135.662 161.807 127.764 168 120.8 168h-2.679c-5.747-4.952-13.536-8-22.12-8H32c-17.673 0-32 12.894-32 28.8v230.4C0 435.106 14.327 448 32 448h64c8.584 0 16.373-3.048 22.12-8h2.679c28.688 0 67.137 40 127.2 40h21.299c62.542 0 98.8-38.658 99.94-91.145c12.482-17.813 18.491-40.785 15.985-62.791A93.148 93.148 0 0 0 393.152 304H428.8c45.435 0 83.2-37.584 83.2-83.2c0-45.099-38.101-83.2-83.2-83.2zm0 118.4h-91.026c12.837 14.669 14.415 42.825-4.95 61.05c11.227 19.646 1.687 45.624-12.925 53.625c6.524 39.128-10.076 61.325-50.6 61.325H248c-45.491 0-77.21-35.913-120-39.676V215.571c25.239-2.964 42.966-21.222 59.075-39.596c11.275-12.65 21.725-25.3 30.799-39.875C232.355 112.712 244.006 80 252.8 80c23.375 0 44 8.8 44 35.2c0 35.2-26.4 53.075-26.4 70.4h158.4c18.425 0 35.2 16.5 35.2 35.2c0 18.975-16.225 35.2-35.2 35.2zM88 384c0 13.255-10.745 24-24 24s-24-10.745-24-24s10.745-24 24-24s24 10.745 24 24z"
fill="currentColor"
></path>
</svg>
</n-icon>
</template>
选择父任务
</n-button>
</div>
</n-form-item>
<!-- 视频索引 -->
<n-form-item :show-label="false" :show-require-mark="false" :show-feedback="false">
<div class="motion-control">
<div class="motion-label">
<span>视频索引 (Index)</span>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">视频索引说明</div>
<div style="margin-bottom: 6px">选择要进行视频拓展的具体视频索引范围为 0-3</div>
<div style="margin-bottom: 6px"><strong>索引 0</strong> 第一个生成的视频</div>
<div style="margin-bottom: 6px"><strong>索引 1</strong> 第二个生成的视频</div>
<div style="margin-bottom: 6px"><strong>索引 2</strong> 第三个生成的视频</div>
<div><strong>索引 3</strong> 第四个生成的视频</div>
<br />
<div>选择对应的索引后会对该索引的视频进行拓展操作</div>
</div>
</n-tooltip>
</div>
<n-input-number
v-model:value="videoMessage.mjVideoOptionsObject.index"
:min="0"
:max="3"
placeholder="1-4"
size="small"
:disabled="loading"
style="width: 100px"
@update:value="(value) => handleVideoMessageChange('index', value)"
/>
</div>
</n-form-item>
<!-- 提示词 -->
<n-form-item label="提示词可选不填写由MJ自行处理" :show-feedback="false">
<n-input
v-model:value="videoMessage.prompt"
type="textarea"
placeholder="请输入视频生成提示词"
size="small"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="loading"
@change="(value) => handleVideoMessageChange('prompt', value)"
/>
</n-form-item>
<!-- 运动变化 -->
<n-form-item :show-label="false" :show-require-mark="false" :show-feedback="false">
<div class="motion-control">
<div class="motion-label">
<span>运动变化 (Motion)</span>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">运动变化程度说明</div>
<div style="margin-bottom: 6px">
生成视频时您有两个运动设置选项可供选择"低运动""高运动"请使用网站上的相应按钮或在视频提示末尾添加
<code>--motion low</code> 或参数 <code>--motion high</code>
</div>
<div style="margin-bottom: 6px">
<strong>低运动默认</strong
>更有可能产生静止场景低摄像机运动慢动作或细微的角色动作
</div>
<div>
<strong>高运动</strong
>更有可能导致大的摄像机运动和更大的角色运动但也可能产生不切实际或有故障的动作
</div>
</div>
</n-tooltip>
</div>
<n-select
v-model:value="videoMessage.mjVideoOptionsObject.motion"
:options="GetMJVideoMotionOptions()"
placeholder="选择运动变化程度"
@update-value="(value) => handleVideoMessageChange('motion', value)"
size="small"
:disabled="loading"
class="motion-select"
/>
</div>
</n-form-item>
<!-- 减少创意 -->
<n-form-item :show-label="false" :show-require-mark="false" :show-feedback="false">
<div class="motion-control">
<div class="motion-label">
<span>视频原始 (Raw)</span>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">视频原始开关说明</div>
<div style="margin-bottom: 6px">
为了更精确地控制视频创作的运动使用
<code>--raw</code> 视频提示中的参数会很有帮助此功能的作用类似于图像的Raw 模式
</div>
<div>
当您添加 <code>--raw</code>它会减少 Midjourney
通常添加的额外创意天赋从而使您的提示文本对结果产生更大的影响
</div>
<br />
<div style="color: red">注意视频原始只有在有提示词的时候才会生效</div>
</div>
</n-tooltip>
</div>
<n-switch
v-model:value="videoMessage.mjVideoOptionsObject.raw"
size="small"
@update-value="(value) => handleVideoMessageChange('raw', value)"
:disabled="loading"
>
<template #checked></template>
<template #unchecked></template>
</n-switch>
</div>
</n-form-item>
<!-- 执行按钮 -->
<n-form-item>
<n-button type="primary" size="small" :loading="loading" @click="handleExtend" block>
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
</svg>
</n-icon>
</template>
执行视频拓展
</n-button>
</n-form-item>
</n-space>
</template>
<script setup>
import {
NIcon,
NInput,
NInputNumber,
NSelect,
NButton,
NSpace,
NFormItem,
NTooltip,
useMessage
} from 'naive-ui'
import { HelpCircleOutline } from '@vicons/ionicons5'
import { GetMJVideoMotionOptions, ImageToVideoModels } from '@/define/enum/video'
import { isEmpty } from 'lodash'
import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum'
import { DEFINE_STRING } from '@/define/define_string'
const message = useMessage()
// props
const props = defineProps({
videoMessage: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
},
task: {
type: Object,
required: true
}
})
// emits
const emit = defineEmits(['video-message-change', 'extend', 'select-parent-task'])
// videoMessage
function handleVideoMessageChange(key, value = undefined) {
emit('video-message-change', key, value)
}
//
async function handleExtend() {
console.log('执行视频拓展', props.videoMessage, props.task)
let taskId = props.videoMessage.mjVideoOptionsObject.taskId
let videoIndex = props.videoMessage.mjVideoOptionsObject.index
if (isEmpty(taskId) || videoIndex == undefined) {
message.error('请先选择父任务ID和视频索引')
return
}
//
//
let type = BookBackTaskType.MJ_VIDEO_EXTEND
//
let res = await window.task.AddBookBackTask(
props.task.bookId,
type,
TaskExecuteType.AUTO,
props.task.bookTaskId,
props.task.id,
DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN
)
if (res.code != 1) {
message.error(res.message)
return
}
message.success('添加视频拓展任务成功!')
}
//
function handleSelectParentTask() {
if (props.task.videoMessage?.videoType !== ImageToVideoModels.MJ_VIDEO) {
message.error('当前任务不是 MJ_VIDEO 类型,无法选择父任务!')
return
}
emit('select-parent-task')
}
</script>
<style scoped>
.motion-control {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.motion-label {
display: flex;
align-items: center;
justify-self: center;
gap: 6px;
font-size: 14px;
}
.motion-select {
width: 100px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,263 @@
<template>
<n-space vertical :size="12">
<!-- 首帧图片 -->
<n-form-item label="图片链接(图片地址)" required :show-feedback="false">
<div class="input-with-preview">
<n-input
v-model:value="videoMessage.imageUrl"
placeholder="请输入图片链接"
@change="handleVideoMessageChange('imageUrl')"
size="small"
:disabled="loading"
class="image-input"
/>
<n-image
v-if="videoMessage.imageUrl"
:src="videoMessage.imageUrl"
:height="60"
:fallback-src="'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwIiB5PSI1MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEyIiBmaWxsPSIjOTk5IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iMC4zZW0iPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD4KPC9zdmc+'"
object-fit="contain"
class="preview-image"
@error="handleImageError"
/>
<div v-else class="preview-placeholder">
<n-text depth="3" class="placeholder-text">图片预览</n-text>
</div>
</div>
</n-form-item>
<!-- 提示词 -->
<n-form-item label="提示词可选不填写由MJ自行处理" :show-feedback="false">
<n-input
v-model:value="videoMessage.prompt"
type="textarea"
placeholder="请输入视频生成提示词"
size="small"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="loading"
@change="handleVideoMessageChange('prompt')"
/>
</n-form-item>
<!-- 运动变化 -->
<n-form-item :show-label="false" :show-require-mark="false" :show-feedback="false">
<div class="motion-control">
<div class="motion-label">
<span>运动变化 (Motion)</span>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">运动变化程度说明</div>
<div style="margin-bottom: 6px">
生成视频时您有两个运动设置选项可供选择"低运动""高运动"请使用网站上的相应按钮或在视频提示末尾添加
<code>--motion low</code> 或参数 <code>--motion high</code>
</div>
<div style="margin-bottom: 6px">
<strong>低运动默认</strong
>更有可能产生静止场景低摄像机运动慢动作或细微的角色动作
</div>
<div>
<strong>高运动</strong
>更有可能导致大的摄像机运动和更大的角色运动但也可能产生不切实际或有故障的动作
</div>
</div>
</n-tooltip>
</div>
<n-select
v-model:value="videoMessage.mjVideoOptionsObject.motion"
:options="GetMJVideoMotionOptions()"
placeholder="选择运动变化程度"
@update-value="(value) => handleVideoMessageChange('motion', value)"
size="small"
:disabled="loading"
class="motion-select"
/>
</div>
</n-form-item>
<!-- 减少创意 -->
<n-form-item :show-label="false" :show-require-mark="false" :show-feedback="false">
<div class="motion-control">
<div class="motion-label">
<span>视频原始 (Raw)</span>
<n-tooltip trigger="hover" placement="top" style="max-width: 300px">
<template #trigger>
<n-button size="small" quaternary circle>
<template #icon>
<n-icon>
<HelpCircleOutline />
</n-icon>
</template>
</n-button>
</template>
<div style="line-height: 1.5">
<div style="font-weight: bold; margin-bottom: 8px">视频原始开关说明</div>
<div style="margin-bottom: 6px">
为了更精确地控制视频创作的运动使用
<code>--raw</code> 视频提示中的参数会很有帮助此功能的作用类似于图像的Raw 模式
</div>
<div>
当您添加 <code>--raw</code>它会减少 Midjourney
通常添加的额外创意天赋从而使您的提示文本对结果产生更大的影响
</div>
<br />
<div style="color: red;">注意视频原始只有在有提示词的时候才会生效</div>
</div>
</n-tooltip>
</div>
<n-switch
v-model:value="videoMessage.mjVideoOptionsObject.raw"
size="small"
@update-value="handleVideoMessageChange('raw')"
:disabled="loading"
>
<template #checked></template>
<template #unchecked></template>
</n-switch>
</div>
</n-form-item>
<!-- 生成按钮 -->
<n-form-item>
<n-button type="primary" size="small" :loading="loading" @click="handleImageToVideo" block>
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
</svg>
</n-icon>
</template>
生成视频
</n-button>
</n-form-item>
</n-space>
</template>
<script setup>
import { computed } from 'vue'
import {
NIcon,
NInput,
NSelect,
NSwitch,
NButton,
NSpace,
NFormItem,
NImage,
NText,
NTooltip,
useMessage
} from 'naive-ui'
import { HelpCircleOutline } from '@vicons/ionicons5'
import { GetMJVideoMotionOptions } from '@/define/enum/video'
import { isEmpty } from 'lodash'
const message = useMessage()
// props
const props = defineProps({
videoMessage: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
// emits
const emit = defineEmits(['video-message-change', 'image-to-video'])
//
function handleImageError() {
message.warning('图片加载失败,请检查图片链接是否有效')
}
// videoMessage
function handleVideoMessageChange(key, value = undefined) {
emit('video-message-change', key, value)
}
//
function handleImageToVideo() {
if (isEmpty(props.videoMessage.imageUrl)) {
message.error('请输入首帧图片链接')
return
}
if (props.videoMessage.mjVideoOptionsObject.motion == undefined) {
message.error('请选择运动变化程度')
return
}
emit('image-to-video')
}
</script>
<style scoped>
.input-with-preview {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.image-input {
flex: 1;
}
.preview-image {
width: auto;
height: 100%;
max-width: 100%;
object-fit: contain;
}
.preview-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
border: 1px solid #e0e0e6;
background-color: #fafafa;
border-radius: 4px;
}
.motion-control {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.motion-label {
display: flex;
align-items: center;
justify-self: center;
gap: 6px;
font-size: 14px;
}
.motion-select {
width: 100px;
flex-shrink: 0;
}
.placeholder-text {
font-size: 10px;
color: #999;
}
</style>

View File

@ -0,0 +1,199 @@
<template>
<div class="mj-video-container">
<n-tabs v-model:value="activeTab" type="segment" size="small">
<!-- 图生视频 Tab -->
<n-tab-pane name="image-to-video" tab="图生视频">
<ImageTextVideoInfoMJVideoImageToVideo
:video-message="videoMessage"
:loading="loading"
@video-message-change="handleVideoMessageChange"
@image-to-video="handleImageToVideo"
/>
</n-tab-pane>
<!-- 视频拓展 Tab -->
<n-tab-pane name="video-extend" tab="视频拓展">
<ImageTextVideoInfoMJVideoExtend
:video-message="videoMessage"
:loading="loading"
:task="props.task"
@video-message-change="handleVideoMessageChange"
@select-parent-task="handleSelectParentTask"
/>
</n-tab-pane>
</n-tabs>
<!-- 选择父任务的 Modal 弹窗 -->
<n-modal
v-model:show="showParentTaskModal"
:mask-closable="false"
preset="card"
style="width: 90%; max-width: 1200px"
title="选择父任务"
size="huge"
:content-style="{ padding: '8px 16px' }"
:segmented="true"
>
<ImageTextVideoInfoMJVideoSelectParentTask
:taskData="props.task"
:videoList="subVideoPathObject"
@close="showParentTaskModal = false"
/>
</n-modal>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { NTabs, NTabPane, NModal, useMessage } from 'naive-ui'
import { ImageToVideoModels, MJVideoMotion } from '@/define/enum/video'
import { ValidateJsonAndParse } from '@/define/Tools/validate'
import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum'
import { DEFINE_STRING } from '@/define/define_string'
import { isEmpty } from 'lodash'
import ImageTextVideoInfoMJVideoSelectParentTask from './ImageTextVideoInfoMJVideoSelectParentTask.vue'
import ImageTextVideoInfoMJVideoImageToVideo from './ImageTextVideoInfoMJVideoImageToVideo.vue'
import ImageTextVideoInfoMJVideoExtend from './ImageTextVideoInfoMJVideoExtend.vue'
const message = useMessage()
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
//
const activeTab = ref('image-to-video')
const loading = ref(false)
const showParentTaskModal = ref(false)
//
const videoMessage = computed(() => {
let videoMessage = props.task?.videoMessage || {}
let mjVideoOptionsString = videoMessage.mjVideoOptions || '{}'
let mjVideoOptions = ValidateJsonAndParse(mjVideoOptionsString)
mjVideoOptions.image = videoMessage.imageUrl ?? ''
if (mjVideoOptions.motion == undefined) {
mjVideoOptions.motion = MJVideoMotion.Low //
}
if (mjVideoOptions.raw == undefined) {
mjVideoOptions.raw = true //
}
videoMessage.mjVideoOptionsObject = mjVideoOptions
return videoMessage
})
const subVideoPathObject = computed(() => {
return props.task?.subVideoPathObject || []
})
//
async function handleImageToVideo() {
debugger
if (isEmpty(videoMessage.value.imageUrl)) {
message.error('请输入首帧图片链接')
return
}
if (videoMessage.value.mjVideoOptionsObject.motion == undefined) {
message.error('请选择运动变化程度')
return
}
//
let type = BookBackTaskType.MJ_VIDEO
//
let res = await window.task.AddBookBackTask(
props.task.bookId,
type,
TaskExecuteType.AUTO,
props.task.bookTaskId,
props.task.id,
DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN
)
if (res.code != 1) {
message.error(res.message)
return
}
message.success('添加图转视频任务到队列成功')
}
//
async function handleSelectParentTask() {
if (props.task.videoMessage?.videoType !== ImageToVideoModels.MJ_VIDEO) {
message.error('当前任务不是 MJ_VIDEO 类型,无法选择父任务!')
return
}
console.log('选择父任务,当前任务数据:', props.task, subVideoPathObject.value)
showParentTaskModal.value = true
}
// videoMessage
async function handleVideoMessageChange(key, value = undefined) {
let updateObject = {}
switch (key) {
case 'imageUrl':
updateObject.imageUrl = videoMessage.value.imageUrl
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
image: videoMessage.value.imageUrl
})
break
case 'prompt':
updateObject.prompt = videoMessage.value.prompt
break
case 'motion':
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
motion: value
})
break
case 'index':
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
index: value
})
break
case 'taskId':
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
taskId: value
})
break
case 'raw':
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
raw: videoMessage.value.mjVideoOptionsObject.raw
})
break
default:
message.error(`未知的修改键: ${key}`)
return
}
//
let res = await window.book.video.UpdateBookTaskDetailVideoMessage(
videoMessage.value.bookTaskDetailId,
updateObject
)
if (res.code !== 1) {
message.error(`修改失败: ${res.message}, Key: ${key}`)
console.error('修改失败:', res)
return
}
}
</script>
<style scoped>
.mj-video-container {
padding: 16px 0;
}
</style>

View File

@ -0,0 +1,849 @@
<template>
<div>
<n-alert
type="info"
closable
:show-icon="false"
:style="{
marginBottom: '16px'
}"
>
<div>
修改选择的视频任务之后会将对应的选择的视频的<strong>任务ID</strong><strong>视频索引</strong>更新到
<strong>Midjourney 视频配置</strong>
中的 <strong>视频拓展</strong>
</div>
</n-alert>
<div v-if="taskData" class="video-modal-content">
<!-- 选中的视频和任务信息 -->
<div class="selected-video-section">
<div class="selected-video-left">
<n-card
title="🎬 视频预览"
size="small"
:bordered="true"
:style="{
height: '100%'
}"
>
<div class="current-video-container">
<video
v-if="currentSelectedVideo"
:src="currentSelectedVideo"
controls
class="current-video-player"
preload="metadata"
/>
<div v-else class="no-selected-video">
<n-text depth="3">请选择一个视频</n-text>
</div>
</div>
</n-card>
</div>
<div class="task-info-right">
<n-card
title="📋 任务详情"
size="small"
:bordered="true"
:style="{
height: '100%'
}"
>
<template #header-extra>
<n-button size="small" type="primary" @click="handleSaveMJVideoTaskSelection">
<template #icon>
<n-icon>
<Save />
</n-icon>
</template>
保存任务选择
</n-button>
</template>
<div class="info-section">
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</svg>
</n-icon>
<n-text strong>分镜名称</n-text>
</div>
<div class="info-value">
<n-text>{{ taskData.name }}</n-text>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M2,4C2,2.89 2.9,2 4,2H7V4H4V7H2V4M22,4V7H20V4H17V2H20C21.1,2 22,2.89 22,4M20,20V17H22V20C22,21.11 21.1,22 20,22H17V20H20M2,17V20C2,21.11 2.9,22 4,22H7V20H4V17H2M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z"
/>
</svg>
</n-icon>
<n-text strong>任务ID</n-text>
</div>
<div class="info-value">
<TextEllipsis
v-if="currentVideoInfo"
:text="currentVideoInfo.taskId"
component="n-tag"
:component-props="{ type: 'primary', size: 'small' }"
max-width="120px"
/>
<n-text v-else depth="3">-</n-text>
<n-button
v-if="currentVideoInfo"
size="tiny"
type="primary"
ghost
@click="copyToClipboard(currentVideoInfo.taskId)"
class="copy-button"
style="margin-left: 8px"
>
<template #icon>
<n-icon size="12">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
/>
</svg>
</n-icon>
</template>
复制
</n-button>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17Z"
/>
</svg>
</n-icon>
<n-text strong>视频索引</n-text>
</div>
<div class="info-value">
<n-tag v-if="currentVideoInfo" type="info" size="small">
{{ currentVideoInfo.index }}
</n-tag>
<n-text v-else depth="3">-</n-text>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z"
/>
</svg>
</n-icon>
<n-text strong>视频类型</n-text>
</div>
<div class="info-value">
<n-tag v-if="currentVideoInfo" type="success" size="small" round>
{{ GetImageToVideoModelsLabel(currentVideoInfo.type) || '未知类型' }}
</n-tag>
<n-text v-else depth="3">-</n-text>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
/>
</svg>
</n-icon>
<n-text strong>本地路径</n-text>
</div>
<div class="info-value">
<TextEllipsis
v-if="currentVideoInfo && currentVideoInfo.localPath"
:text="currentVideoInfo.localPath"
component="n-tag"
:component-props="{ type: 'success', size: 'small' }"
max-width="150px"
/>
<n-text v-else depth="3">-</n-text>
<n-button
v-if="currentVideoInfo && currentVideoInfo.localPath"
size="tiny"
type="success"
ghost
@click="copyToClipboard(currentVideoInfo.localPath)"
class="copy-button"
style="margin-left: 8px"
>
<template #icon>
<n-icon size="12">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
/>
</svg>
</n-icon>
</template>
复制
</n-button>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M8,10A1,1 0 0,1 9,11A1,1 0 0,1 8,12A1,1 0 0,1 7,11A1,1 0 0,1 8,10Z"
/>
</svg>
</n-icon>
<n-text strong>远端地址</n-text>
</div>
<div class="info-value">
<TextEllipsis
v-if="currentVideoInfo && currentVideoInfo.remotePath"
:text="currentVideoInfo.remotePath"
component="n-tag"
:component-props="{ type: 'warning', size: 'small' }"
max-width="150px"
/>
<n-text v-else depth="3">-</n-text>
<n-button
v-if="currentVideoInfo && currentVideoInfo.remotePath"
size="tiny"
type="warning"
ghost
@click="copyToClipboard(currentVideoInfo.remotePath)"
class="copy-button"
style="margin-left: 8px"
>
<template #icon>
<n-icon size="12">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
/>
</svg>
</n-icon>
</template>
复制
</n-button>
</div>
</div>
<div class="info-item">
<div class="info-label">
<n-icon size="16" class="info-icon">
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M12,4.5L17,9L12,13.5V10.5H8V7.5H12V4.5Z"
/>
</svg>
</n-icon>
<n-text strong>视频总数</n-text>
</div>
<div class="info-value">
<n-tag type="success" size="small" :bordered="false">
{{ videoList.length }}
</n-tag>
</div>
</div>
</div>
</n-card>
</div>
</div>
<div class="video-grid">
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
<n-grid-item v-for="(video, index) in videoList" :key="index">
<n-card
size="small"
:bordered="true"
class="video-card"
:class="{ selected: currentSelectedVideo === video.localPath }"
@click="selectVideo(video)"
>
<template #header>
<n-text>视频 {{ index + 1 }}</n-text>
</template>
<template #header-extra>
<n-space justify="center" align="center">
<n-tooltip :show-arrow="false" trigger="hover">
<template #trigger>
<n-tag type="primary" size="small" class="video-status-tag task-id-tag">
{{ video.taskId }}
</n-tag>
</template>
{{ video.taskId }}
</n-tooltip>
<n-tag type="info" size="small" class="video-status-tag">
{{ video.index }}
</n-tag>
</n-space>
</template>
<div class="video-container">
<video :src="video.localPath" class="video-player" preload="metadata" />
</div>
</n-card>
</n-grid-item>
</n-grid>
</div>
<div v-if="videoList.length === 0" class="no-video">
<n-text depth="3">暂无视频信息</n-text>
</div>
</div>
<n-empty description="当前分镜没有转视频相关的信息,请先进行转视频操作!" v-else />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
NModal,
NAlert,
NText,
NCard,
NGrid,
NGridItem,
NSpace,
NTag,
NIcon,
NButton,
NTooltip,
NEmpty,
useMessage
} from 'naive-ui'
import { GetImageToVideoModelsLabel, ImageToVideoModels } from '@/define/enum/video'
import TextEllipsis from '@/renderer/src/components/Common/TextEllipsis.vue'
import { isEmpty } from 'lodash'
import { Save } from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
// props
const props = defineProps({
taskData: {
type: Object,
default: null
},
videoList: {
type: Array,
default: () => []
}
})
const videoList = computed(() => {
console.log('未过滤的当前视频列表', props.videoList)
//
return props.videoList.filter(
(video) =>
!isEmpty(video.localPath) &&
(video.type == ImageToVideoModels.MJ_VIDEO ||
video.type == ImageToVideoModels.MJ_VIDEO_EXTEND)
)
})
const videoMessage = ref({})
const message = useMessage()
//
const currentSelectedVideo = ref(props.taskData.generateVideoPath)
const currentVideoInfo = ref(null)
//
const windowWidth = ref(window.innerWidth)
//
const gridCols = computed(() => {
if (windowWidth.value <= 480) {
return 1 // 1
} else if (windowWidth.value <= 768) {
return 2 // 2
} else if (windowWidth.value <= 1024) {
return 3 // 3
} else {
return 4 // 4
}
})
//
const handleResize = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
videoMessage.value = props.taskData.videoMessage || {}
videoList.value = videoList.value.filter((t) => !isEmpty(t.localPath))
if (videoList.value.length > 0) {
//
selectVideo(videoList.value[0])
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
//
function selectVideo(video) {
currentSelectedVideo.value = video.localPath
currentVideoInfo.value = video
if (!isEmpty(currentVideoInfo.value.localPath)) {
currentVideoInfo.value.localPath = currentVideoInfo.value.localPath.split('?t=')[0]
}
}
//
async function handleSaveMJVideoTaskSelection() {
try {
if (currentVideoInfo.value == null || isEmpty(currentSelectedVideo.value)) {
message.error('请选择一个视频')
return
}
let taskId = currentVideoInfo.value.taskId
let videoIndex = currentVideoInfo.value.index
console.log('当前选中的视频信息', taskId, videoIndex, currentSelectedVideo.value)
if (isEmpty(taskId) || videoIndex == null) {
message.error('当前选中的视频的 taskId 或 videoIndex 为空,请检查视频信息')
return
}
//
let updateObject = {
mjVideoOptions: JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
taskId: taskId,
index: videoIndex
})
}
console.log('更新的实际数据', updateObject)
let res = await window.book.video.UpdateBookTaskDetailVideoMessage(
props.taskData.id,
updateObject
)
//
if (res.code == 1) {
message.success('视频选择已保存成功')
videoMessage.value.mjVideoOptionsObject.taskId = taskId
videoMessage.value.mjVideoOptionsObject.index = videoIndex
} else {
message.error('保存视频选择失败: ' + res.message)
return
}
} catch (error) {
message.error('保存视频选择失败: ' + error.message)
}
}
//
async function copyToClipboard(text) {
try {
if (isEmpty(text)) {
message.warning('复制的内容为空')
return
}
await navigator.clipboard.writeText(text)
message.success('已复制到剪贴板')
} catch (err) {
console.error('复制失败:', err)
message.error('复制失败,请手动复制')
}
}
</script>
<style scoped>
/* 视频缩略图样式 */
.video-thumbnail-container {
height: 130px;
width: 100%;
padding: 4px;
}
.no-video-thumbnail {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 4px;
color: #999;
font-size: 12px;
}
.thumbnail-grid {
height: 100%;
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
}
.thumbnail-item {
position: relative;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
}
.thumbnail-item:hover {
border-color: #18a058;
transform: translateY(-1px);
}
.thumbnail-video {
width: 60px;
height: 45px;
object-fit: cover;
display: block;
}
.more-count {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
}
/* 视频弹窗样式 */
.video-modal-content {
max-height: 75vh;
overflow-y: auto;
}
/* 选中的视频和任务信息区域 */
.selected-video-section {
display: flex;
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background-color: var(--n-color-embedded);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
.selected-video-left {
flex: 3;
min-width: 0;
}
.current-video-container {
width: 100%;
aspect-ratio: 16/9;
background-color: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.current-video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
.no-selected-video {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--n-color-embedded-popover);
color: var(--n-text-color-disabled);
border-radius: 4px;
}
.task-info-right {
flex: 2;
min-width: 300px;
}
.task-detail-card :deep(.n-card-header__main) {
color: white;
}
.info-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--n-color-embedded);
border-radius: 8px;
border-left: 4px solid #18a058;
transition: all 0.3s ease;
border: 1px solid var(--n-border-color);
position: relative;
overflow: hidden;
}
.info-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 5px;
height: 100%;
background: linear-gradient(to bottom, #18a058, #36ad6a);
transition: width 0.3s ease;
}
.info-item:hover {
background: var(--n-color-embedded-popover);
transform: translateX(4px);
border-color: #18a058;
border-left-color: #0c7a43;
box-shadow: 0 2px 8px rgba(24, 160, 88, 0.15);
}
.info-item:hover::before {
width: 8px;
}
.info-label {
display: flex;
align-items: center;
gap: 8px;
min-width: 100px;
}
.info-icon {
color: #18a058;
display: flex;
align-items: center;
}
.info-value {
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.copy-button {
transition: all 0.2s ease;
}
.copy-button:hover {
transform: scale(1.05);
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-item {
text-align: center;
padding: 12px 8px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border-radius: 8px;
color: white;
display: flex;
flex-direction: column;
gap: 4px;
transition: transform 0.3s ease;
}
.stat-item:hover {
transform: translateY(-2px);
}
.stat-item:nth-child(2) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.video-grid {
margin-top: 20px;
}
.video-card {
height: 100%;
cursor: pointer;
transition: all 0.3s ease;
}
.video-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.video-card.selected {
border-color: #18a058;
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.2);
}
.video-card.selected :deep(.n-card-header) {
background-color: var(--n-color-embedded-popover);
}
.video-container {
width: 100%;
aspect-ratio: 16/9;
background-color: var(--n-color-embedded-popover);
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-video {
text-align: center;
padding: 40px;
color: var(--n-text-color-disabled);
}
/* 响应式设计 */
@media (max-width: 480px) {
.selected-video-section {
flex-direction: column;
padding: 12px;
gap: 12px;
}
.task-info-right {
min-width: unset;
}
.video-grid {
margin-top: 16px;
}
.info-item {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.info-label {
min-width: unset;
}
.info-value {
justify-content: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.selected-video-section {
flex-direction: column;
padding: 16px;
gap: 16px;
}
.task-info-right {
min-width: unset;
}
.video-grid {
margin-top: 18px;
}
}
@media (max-width: 1024px) {
.selected-video-section {
gap: 16px;
padding: 18px;
}
.task-info-right {
min-width: 280px;
}
}
@media (min-width: 1400px) {
.selected-video-section {
padding: 24px;
gap: 24px;
}
}
/* Task ID Tag 样式 */
.task-id-tag {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* display: inline-block; */
}
.task-id-tag :deep(.n-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,45 +0,0 @@
<template>
<n-card title="进度信息" class="info-card">
<n-space vertical>
<n-space justify="space-between">
<n-text>完成进度</n-text>
<n-text>{{ task.progress }}%</n-text>
</n-space>
<n-progress
type="line"
:percentage="task.progress"
color="#18a058"
rail-color="#f3f4f6"
:height="8"
/>
</n-space>
</n-card>
</template>
<script setup>
import {
NCard,
NSpace,
NText,
NProgress
} from 'naive-ui'
// props
const props = defineProps({
task: {
type: Object,
required: true
}
})
</script>
<style scoped>
.info-card {
margin-bottom: 16px;
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -3,62 +3,31 @@
<div class="detail-header">
<n-space justify="space-between" align="center">
<n-text strong style="font-size: 18px">任务详情</n-text>
<n-button-group>
<n-button type="primary" @click="handleEdit">
<template #icon>
<n-icon><CreateOutline /></n-icon>
</template>
编辑
</n-button>
<n-button type="error" @click="handleDelete">
<template #icon>
<n-icon><TrashOutline /></n-icon>
</template>
删除
</n-button>
</n-button-group>
</n-space>
</div>
<n-divider />
<!-- 基本信息 -->
<image-text-video-info-basic-info :task="task" />
<!-- 不同的模式切换 -->
<!-- 进度信息 -->
<image-text-video-info-progress :task="task" />
<!-- 基本信息 -->
<image-text-video-info-basic-info :task="task" :video-message="videoMessage" />
<!-- 参数配置 -->
<image-text-video-info-config :task="task" @config-change="handleConfigChange" />
<image-text-video-info-config :task="task" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import {
NSpace,
NText,
NButton,
NButtonGroup,
NIcon,
NDivider,
useMessage,
useDialog
NDivider
} from 'naive-ui'
import {
CreateOutline,
TrashOutline
} from '@vicons/ionicons5'
import ImageTextVideoInfoBasicInfo from './ImageTextVideoInfoBasicInfo.vue'
import ImageTextVideoInfoProgress from './ImageTextVideoInfoProgress.vue'
import ImageTextVideoInfoConfig from './ImageTextVideoInfoConfig.vue'
const message = useMessage()
const dialog = useDialog()
// emits
const emit = defineEmits(['delete-task', 'edit-task', 'config-change'])
// props
const props = defineProps({
task: {
@ -67,52 +36,19 @@ const props = defineProps({
}
})
//
function handleEdit() {
emit('edit-task', props.task)
message.info(`编辑任务: ${props.task.name}`)
}
const videoMessage = ref(props.task.videoMessage)
//
function handleDelete() {
dialog.warning({
title: '确认删除',
content: `确定要删除任务 "${props.task.name}" 吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
emit('delete-task', props.task)
message.success('任务已删除')
}
})
}
//
function handleConfigChange() {
emit('config-change', props.task)
message.info('配置已更新')
}
onMounted(() => {
//
})
</script>
<style scoped>
.task-detail {
height: 100%;
height: calc(100% - 20px);
}
.detail-header {
margin-bottom: 16px;
}
/* 滚动条样式 */
.task-detail::-webkit-scrollbar {
width: 6px;
}
.task-detail::-webkit-scrollbar-track {
border-radius: 3px;
}
.task-detail::-webkit-scrollbar-thumb {
border-radius: 3px;
}
</style>

View File

@ -2,7 +2,15 @@
<div class="table-container">
<div class="table-header">
<n-space justify="space-between" align="center">
<n-text strong>任务列表</n-text>
<div>
<n-button type="default" size="small" @click="handleBackToTaskList">
<template #icon>
<n-icon><ArrowBackOutline /></n-icon>
</template>
返回任务列表
</n-button>
</div>
<n-space align="center">
<n-space align="center" size="small">
<n-text>分页</n-text>
@ -12,7 +20,7 @@
<n-text>右侧面板</n-text>
<n-switch v-model:value="showRightPanel" @update:value="handleRightPanelToggle" />
</n-space>
<n-button type="primary" size="small" @click="handleAdd">
<n-button type="primary" size="small" @click="message.info('新增任务功能开发中...')">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
@ -24,11 +32,11 @@
<n-data-table
:columns="columns"
:data="tableData"
:data="reverseManageStore.selectBookTaskDetail"
:loading="loading"
:pagination="paginationEnabled ? paginationReactive : false"
:row-key="(row) => row.id"
:scroll-x="1400"
:scroll-x="1200"
:max-height="tableMaxHeight"
:min-height="tableMinHeight"
class="data-table"
@ -37,7 +45,8 @@
</template>
<script setup>
import { ref, reactive, h, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, reactive, h, computed, onMounted, onUnmounted, watch, toRef } from 'vue'
import { useRouter } from 'vue-router'
import {
NSpace,
NText,
@ -48,32 +57,31 @@ import {
NSwitch,
NImage,
NInput,
useMessage
NEmpty,
NSelect,
useMessage,
useDialog
} from 'naive-ui'
import { AddOutline, EyeOutline, PlayOutline, PauseOutline } from '@vicons/ionicons5'
import { AddOutline, EyeOutline, ArrowBackOutline } from '@vicons/ionicons5'
import { define } from '@/define/define'
import ImageTextVideoInfoVideoConfig from './ImageTextVideoInfoVideoConfig.vue'
import ImageTextVideoInfoVideoListInfo from './ImageTextVideoInfoVideoListInfo.vue'
import ImageTextVideoInfoTaskOptions from './ImageTextVideoInfoTaskOptions.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option'
import { useReverseManageStore } from '@/stores/reverseManage'
import { GetImageToVideoModelsOptions } from '@/define/enum/video'
const reverseManageStore = useReverseManageStore()
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
// emits
const emit = defineEmits([
'view-detail',
'toggle-status',
'add-task',
'update-prompt',
'update-method',
'update-note',
'toggle-right-panel'
])
const emit = defineEmits(['toggle-right-panel', 'view-detail'])
// props
const props = defineProps({
tableData: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
@ -81,16 +89,26 @@ const props = defineProps({
showRightPanel: {
type: Boolean,
default: true
},
showPagination: {
type: Boolean,
default: true
}
})
//
const paginationEnabled = ref(true)
// - 使ref + watchprops
const paginationEnabled = ref(props.showPagination)
//
// - 使ref + watchprops
const showRightPanel = ref(props.showRightPanel)
// props
//
const batchVideoType = ref(null)
//
const videoTypeOptions = GetImageToVideoModelsOptions()
// props
watch(
() => props.showRightPanel,
(newVal) => {
@ -98,6 +116,13 @@ watch(
}
)
watch(
() => props.showPagination,
(newVal) => {
paginationEnabled.value = newVal
}
)
//
const windowHeight = ref(window.innerHeight)
@ -207,10 +232,11 @@ const columns = [
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
backgroundColor: 'var(--n-color-embedded-popover)',
borderRadius: '4px',
color: '#999',
fontSize: '12px'
color: 'var(--n-text-color-disabled)',
fontSize: '12px',
border: '1px dashed var(--n-border-color)'
}
},
'图片不存在'
@ -254,8 +280,8 @@ const columns = [
spellcheck: false
},
onUpdateValue: (value) => {
row.note = value
emit('update-note', row.id, value)
row.videoMessage.imageUrl = value
handleSaveBookTaskDetailVideoMessage(row, row.id, 'imageUrl', value)
}
})
]
@ -263,7 +289,29 @@ const columns = [
}
},
{
title: '视频配置',
title: () =>
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px'
}
},
[
h('span', '视频配置'),
h(NSelect, {
value: batchVideoType.value,
size: 'small',
style: { width: '120px' },
options: videoTypeOptions,
placeholder: '批量设置',
onUpdateValue: handleBatchVideoTypeChange
})
]
),
key: 'videoConfig',
minWidth: 300,
className: noPaddingColumnClass,
@ -271,13 +319,11 @@ const columns = [
return h(ImageTextVideoInfoVideoConfig, {
taskId: row.id,
videoMessage: row?.videoMessage,
onUpdateMethod: (taskId, method) => {
row.videoMethod = method
emit('update-method', taskId, method)
onUpdateMethod: async (taskId, value) => {
await handleSaveBookTaskDetailVideoMessage(row, taskId, 'videoType', value)
},
onUpdatePrompt: (taskId, prompt) => {
row.videoPrompt = prompt
emit('update-prompt', taskId, prompt)
onUpdatePrompt: async (taskId, prompt) => {
await handleSaveBookTaskDetailVideoMessage(row, taskId, 'prompt', prompt)
}
})
}
@ -322,14 +368,15 @@ const columns = [
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
color: '#999',
fontSize: '12px'
justifyContent: 'center'
}
},
'暂无视频'
[
h(NEmpty, {
description: '暂无视频',
size: 'small'
})
]
)
}
}
@ -343,85 +390,134 @@ const columns = [
render(row) {
return h(ImageTextVideoInfoVideoListInfo, {
taskData: row,
videoList: row.subVideoPath || []
videoList: row.subVideoPathObject || []
})
}
},
{
title: '操作',
key: 'actions',
width: 160,
width: 120,
fixed: 'right',
render(row) {
return h(
NSpace,
{ size: 'small' },
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'primary',
onClick: () => handleViewDetail(row)
},
{
default: () => '查看',
icon: () => h(NIcon, null, { default: () => h(EyeOutline) })
}
),
h(
NButton,
{
size: 'small',
type: row.status === '进行中' ? 'warning' : 'success',
onClick: () => handleToggleStatus(row)
},
{
default: () => (row.status === '进行中' ? '暂停' : '启动'),
icon: () =>
h(NIcon, null, {
default: () => (row.status === '进行中' ? h(PauseOutline) : h(PlayOutline))
return h(ImageTextVideoInfoTaskOptions, {
bookTaskDetail: row,
onViewDetail: (bookTaskDetail) => handleViewDetail(bookTaskDetail)
})
}
)
]
}
)
}
}
]
//
function getStatusType(status) {
const statusMap = {
进行中: 'info',
已完成: 'success',
暂停: 'warning',
失败: 'error'
}
return statusMap[status] || 'default'
}
//
function handleViewDetail(row) {
emit('view-detail', row)
message.info(`查看任务: ${row.name}`)
}
//
function handleToggleStatus(row) {
emit('toggle-status', row)
//
function handleBackToTaskList() {
router.push('/image_text_video')
message.info('返回任务列表')
}
//
function handleAdd() {
emit('add-task')
message.info('新增任务功能开发中...')
//
async function handleBatchVideoTypeChange(value) {
if (!value) return
try {
//
const tasks = reverseManageStore.selectBookTaskDetail
if (!tasks || tasks.length === 0) {
message.warning('没有可设置的任务')
return
}
// dialog /
const selectedOption = videoTypeOptions.find((option) => option.value === value)
const optionLabel = selectedOption ? selectedOption.label : value
dialog.warning({
title: '批量设置确认',
content: `确定要将所有 ${tasks.length} 个任务的视频类型设置为 "${optionLabel}" 吗?此操作不可撤销。`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
//
const updatePromises = tasks.map((task) =>
handleSaveBookTaskDetailVideoMessage(task, task.id, 'videoType', value)
)
// videoType
await Promise.all(updatePromises)
// bookTask videoCategory
let res = await window.db.UpdateBookTaskData(reverseManageStore.selectBookTask.id, {
videoCategory: value
})
if (res.code !== 1) {
message.error(`修改小说批次任务的出图方式失败: ${res.message}`)
return
}
message.success(`已批量设置 ${tasks.length} 个任务的视频类型为: ${optionLabel}`)
//
batchVideoType.value = null
} catch (error) {
message.error(`批量设置失败: ${error.message}`)
}
},
onNegativeClick: () => {
//
batchVideoType.value = null
message.info('已取消批量设置')
}
})
} catch (error) {
message.error(`批量设置失败: ${error.message}`)
}
}
//
async function handleSaveBookTaskDetailVideoMessage(row, taskId, key, value) {
try {
let res = await window.book.video.UpdateBookTaskDetailVideoMessage(taskId, {
[key]: value
})
if (res.code !== 1) {
message.error(`保存失败: ${res.message}`)
return
}
row.videoMessage[key] = value
// store
let index = reverseManageStore.selectBookTaskDetail.findIndex((item) => item.id === taskId)
if (index !== -1) {
reverseManageStore.selectBookTaskDetail[index].videoMessage[key] = value
}
} catch (error) {
message.error(`保存失败: ${error.message}`)
}
}
//
function handlePaginationToggle(value) {
async function handlePaginationToggle(value) {
//
//
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ImageToVideo_ShowPagination,
value,
OptionType.BOOLEAN
)
if (res.code != 1) {
message.error(res.message || '切换失败')
return
}
//
if (value) {
//
paginationReactive.page = 1 //
@ -433,8 +529,21 @@ function handlePaginationToggle(value) {
}
//
function handleRightPanelToggle(value) {
async function handleRightPanelToggle(value) {
//
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ImageToVideo_ShowRightPanel,
value,
OptionType.BOOLEAN
)
if (res.code != 1) {
message.error(res.message || '切换失败')
return
}
//
showRightPanel.value = value
//
emit('toggle-right-panel', value)
if (value) {
message.info('右侧面板已显示')
@ -500,20 +609,6 @@ function handleRightPanelToggle(value) {
background: #a8a8a8;
}
/* 按钮样式 */
.data-table :deep(.n-button) {
transition: all 0.2s ease;
}
.data-table :deep(.n-button:hover) {
transform: translateY(-1px);
}
/* 进度条样式 */
.data-table :deep(.n-progress) {
margin: 4px 0;
}
/* 通用的无padding列样式 */
.data-table :deep(.no-padding-column) {
padding: 2px !important;

View File

@ -0,0 +1,100 @@
<template>
<n-space size="small" justify="center">
<n-button size="small" type="primary" text @click="handleViewDetail">
<template #icon>
<n-icon>
<EyeOutline />
</n-icon>
</template>
查看
</n-button>
<n-button size="small" type="warning" text @click="handleReloadTask">
<template #icon>
<n-icon>
<RefreshOutline />
</n-icon>
</template>
重新加载
</n-button>
</n-space>
</template>
<script setup>
import { NSpace, NButton, NIcon, useDialog, useMessage } from 'naive-ui'
import { EyeOutline, RefreshOutline } from '@vicons/ionicons5'
import { useSoftwareStore } from '@/stores/software'
import { useReverseManageStore } from '@/stores/reverseManage'
const dialog = useDialog()
const message = useMessage()
const softwareStore = useSoftwareStore()
const reverseManageStore = useReverseManageStore()
// emits
const emit = defineEmits(['view-detail', 'reload-task'])
// props
const props = defineProps({
bookTaskDetail: {
type: Object,
required: true
}
})
//
function handleViewDetail() {
emit('view-detail', props.bookTaskDetail)
}
//
function handleReloadTask() {
dialog.warning({
title: '确认重新加载',
content: `确定要重新加载任务 "${props.bookTaskDetail.name}" 吗?\n\n重新加载会将当前的任务重新获取只能再当前任务视频下载失败时才能使用。否则会导致重复的视频显示请确认是否继续`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
softwareStore.spin.spinning = true
softwareStore.spin.tip = '正在重新加载视频任务...'
try {
console.log(props.bookTaskDetail.videoMessage)
if (
props.bookTaskDetail.videoMessage &&
props.bookTaskDetail.videoMessage.taskId &&
props.bookTaskDetail.videoMessage.videoType
) {
let res = await window.book.video.ReloadVideoTaskInfo(props.bookTaskDetail.id)
console.log('重新加载任务结果:', res)
if (res.code != 1) {
message.error(res.message)
return
}
//
let findIndex = reverseManageStore.selectBookTaskDetail.findIndex(
(x) => x.id == res.data.id
)
if (findIndex != -1) {
reverseManageStore.selectBookTaskDetail[findIndex] = res.data
}
} else {
message.error('未找到当前分镜的视频配置,不可重新加载!!')
}
} catch (error) {
message.error('重新加载任务失败,' + error.message)
} finally {
softwareStore.spin.spinning = false
}
},
onNegativeClick: () => {
//
message.info('用户取消操作')
}
})
}
</script>
<style scoped>
/* 可以在这里添加组件特定的样式 */
</style>

View File

@ -4,23 +4,37 @@
<div class="method-section">
<div class="method-row">
<n-select
v-model:value="videoMessage.videoType"
:value="props.videoMessage?.videoType || ''"
:options="videoMethodOptions"
placeholder="选择转视频方式"
size="small"
@update:value="handleMethodChange"
class="method-select"
/>
<n-tag type="primary" size="small" class="method-progress">
{{ videoMessage.status ?? 'wait' }}
<n-tooltip v-if="props.videoMessage?.status == 'fail'">
<template #trigger>
<n-tag type="error" size="small" class="method-progress">
{{ props.videoMessage?.status }}
</n-tag>
</template>
{{ props.videoMessage?.msg ?? '' }}
</n-tooltip>
<n-tag v-else type="primary" size="small" class="method-progress">
{{ props.videoMessage?.status ?? 'wait' }}
</n-tag>
<n-tooltip :show-arrow="false" trigger="hover">
<template #trigger>
<span class="task-id-span">{{ props.videoMessage?.taskId ?? '' }}</span>
</template>
{{ props.videoMessage?.taskId ?? '' }}
</n-tooltip>
</div>
</div>
<!-- 视频提示词输入 -->
<div class="prompt-section">
<n-input
v-model:value="videoMessage.prompt"
:value="props.videoMessage?.prompt || ''"
type="textarea"
:rows="3"
placeholder="请输入视频提示词..."
@ -32,8 +46,8 @@
</template>
<script setup>
import { ref, watch, onMounted, toRef } from 'vue'
import { NSelect, NInput, NTag } from 'naive-ui'
import { ref, onMounted } from 'vue'
import { NSelect, NInput, NTag, NTooltip } from 'naive-ui'
import { GetImageToVideoModelsOptions } from '@/define/enum/video'
// emits
@ -54,29 +68,18 @@ const props = defineProps({
//
const videoMethodOptions = GetImageToVideoModelsOptions()
const videoMessage = ref({})
//
function handleMethodChange(value) {
emit('update-method', props.taskId, value)
emit('update-method', props.videoMessage?.bookTaskDetailId, value)
}
//
function handlePromptChange(value) {
emit('update-prompt', props.taskId, value)
emit('update-prompt', props.videoMessage?.bookTaskDetailId, value)
}
//
onMounted(() => {
if (props.videoMessage) {
videoMessage.value = props.videoMessage
} else {
videoMessage.value = {
videoType: 'RUNWAY',
prompt: ''
}
}
})
onMounted(() => {})
</script>
<style scoped>
@ -135,4 +138,18 @@ onMounted(() => {
height: 100%;
resize: none;
}
.task-id-span {
display: inline-block;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 6px;
background-color: var(--primary-color-suppl);
color: var(--primary-color);
border-radius: 3px;
font-size: 12px;
cursor: pointer;
}
</style>

View File

@ -4,7 +4,7 @@
<div class="video-thumbnail-container">
<div v-if="videoList.length === 0" class="no-video-thumbnail">
<div class="empty-state">
<n-text depth="3">暂无视频</n-text>
<n-empty description="暂可选视频" size="small" />
</div>
</div>
@ -15,7 +15,7 @@
class="thumbnail-item"
@click="handleShowModal"
>
<video :src="video" class="thumbnail-video" muted preload="metadata" />
<video :src="video.localPath" class="thumbnail-video" muted preload="metadata" />
<!-- 如果有更多视频在最后一个缩略图上显示数量 -->
<div v-if="index === 2 && videoList.length > 3" class="more-count">
@ -34,10 +34,18 @@
:title="'视频信息详情:' + taskData.name"
size="huge"
:segmented="true"
:content-style="{
padding: '8px 16px'
}"
>
<n-alert
type="info"
closable
:show-icon="false"
:style="{
marginBottom: '16px'
}"
>
<n-alert type="info" closable :show-icon="false" :style="{
marginBottom : '16px'
}">
修改选择的视频之后请点击 保存视频选择 按钮将操作进行保存生效
</n-alert>
@ -202,15 +210,31 @@
size="small"
:bordered="true"
class="video-card"
:class="{ selected: currentSelectedVideo === video }"
:class="{ selected: currentSelectedVideo === video.localPath }"
@click="selectVideo(video)"
>
<template #header>
<n-text>视频 {{ index + 1 }}</n-text>
</template>
<template #header-extra>
<n-space justify="center" align="center">
<n-tooltip :show-arrow="false" trigger="hover">
<template #trigger>
<n-tag type="primary" size="small" class="video-status-tag task-id-tag">
{{ video.taskId }}
</n-tag>
</template>
{{ video.taskId }}
</n-tooltip>
<n-tag type="info" size="small" class="video-status-tag">
{{ video.index }}
</n-tag>
</n-space>
</template>
<div class="video-container">
<video :src="video" class="video-player" preload="metadata" />
<video :src="video.localPath" class="video-player" preload="metadata" />
</div>
<template #footer>
@ -235,19 +259,32 @@
<n-text depth="3">暂无视频信息</n-text>
</div>
</div>
<n-empty description="当前分镜没有转视频相关的信息,请先进行转视频操作!" v-else> </n-empty>
<n-empty description="当前分镜没有转视频相关的信息,请先进行转视频操作!" v-else />
</n-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NModal, NText, NCard, NGrid, NGridItem, NSpace, NTag, NIcon, NButton } from 'naive-ui'
import {
NModal,
NAlert,
NText,
NCard,
NGrid,
NGridItem,
NSpace,
NTag,
NIcon,
NButton,
NTooltip,
NEmpty,
useMessage
} from 'naive-ui'
import { GetImageToVideoModelsLabel } from '@/define/enum/video'
import { isEmpty } from 'lodash'
import { Save } from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
// props
const props = defineProps({
@ -261,6 +298,11 @@ const props = defineProps({
}
})
const videoList = computed(() => {
//
return props.videoList.filter((video) => !isEmpty(video.localPath))
})
//
const showModal = ref(false)
@ -295,6 +337,7 @@ const handleResize = () => {
onMounted(() => {
window.addEventListener('resize', handleResize)
videoMessage.value = props.taskData.videoMessage || {}
})
onUnmounted(() => {
@ -304,8 +347,8 @@ onUnmounted(() => {
//
function handleShowModal() {
//
if (props.videoList.length > 0 && isEmpty(props.taskData.generateVideoPath)) {
currentSelectedVideo.value = props.videoList[0]
if (videoList.value.length > 0 && isEmpty(props.taskData.generateVideoPath)) {
currentSelectedVideo.value = videoList.value[0].localPath
} else {
currentSelectedVideo.value = props.taskData.generateVideoPath
}
@ -314,7 +357,7 @@ function handleShowModal() {
//
function selectVideo(video) {
currentSelectedVideo.value = video
currentSelectedVideo.value = video.localPath
}
async function handleSaveVideoSelection() {
@ -412,7 +455,7 @@ async function copyTaskId() {
/* 视频缩略图样式 */
.video-thumbnail-container {
height: 130px;
width: 100%;
width: 99%;
padding: 4px;
}
@ -430,10 +473,11 @@ async function copyTaskId() {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
background-color: var(--n-color-embedded);
border-radius: 4px;
color: #999;
color: var(--n-text-color-disabled);
font-size: 12px;
border: 1px solid var(--n-border-color);
}
.thumbnail-grid {
@ -492,8 +536,9 @@ async function copyTaskId() {
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
background-color: var(--n-color-embedded);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
.selected-video-left {
@ -524,8 +569,9 @@ async function copyTaskId() {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
color: #999;
background-color: var(--n-color-embedded-popover);
color: var(--n-text-color-disabled);
border-radius: 4px;
}
.task-info-right {
@ -548,15 +594,36 @@ async function copyTaskId() {
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8f9fa;
background: var(--n-color-embedded);
border-radius: 8px;
border-left: 4px solid #18a058;
transition: all 0.3s ease;
border: 1px solid var(--n-border-color);
position: relative;
overflow: hidden;
}
.info-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 5px;
height: 100%;
background: linear-gradient(to bottom, #18a058, #36ad6a);
transition: width 0.3s ease;
}
.info-item:hover {
background: #e8f5e8;
background: var(--n-color-embedded-popover);
transform: translateX(4px);
border-color: #18a058;
border-left-color: #0c7a43;
box-shadow: 0 2px 8px rgba(24, 160, 88, 0.15);
}
.info-item:hover::before {
width: 8px;
}
.info-label {
@ -634,13 +701,13 @@ async function copyTaskId() {
}
.video-card.selected :deep(.n-card-header) {
background-color: #f0f9ff;
background-color: var(--n-color-embedded-popover);
}
.video-container {
width: 100%;
aspect-ratio: 16/9;
background-color: #f5f5f5;
background-color: var(--n-color-embedded-popover);
border-radius: 4px;
overflow: hidden;
display: flex;
@ -657,7 +724,7 @@ async function copyTaskId() {
.no-video {
text-align: center;
padding: 40px;
color: #999;
color: var(--n-text-color-disabled);
}
/* 响应式设计 */
@ -728,4 +795,19 @@ async function copyTaskId() {
gap: 24px;
}
}
/* Task ID Tag 样式 */
.task-id-tag {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* display: inline-block; */
}
.task-id-tag :deep(.n-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -184,7 +184,7 @@ async function handleDelete() {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.MJ_CustomAPISetting,
JSON.stringify(configList.value),
OptionType.JOSN
OptionType.JSON
)
if (res.code != 1) {
message.error('删除配置失败,' + res.message)
@ -225,7 +225,7 @@ const handleSave = (e) => {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.MJ_CustomAPISetting,
JSON.stringify(configList.value),
OptionType.JOSN
OptionType.JSON
)
if (res.code != 1) {
throw new Error(res.message)

View File

@ -196,7 +196,7 @@ async function handleDelete() {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.MJ_CustomPackageSetting,
JSON.stringify(configList.value),
OptionType.JOSN
OptionType.JSON
)
if (res.code != 1) {
message.error('删除配置失败,' + res.message)
@ -238,7 +238,7 @@ const handleSave = (e) => {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.MJ_CustomPackageSetting,
JSON.stringify(configList.value),
OptionType.JOSN
OptionType.JSON
)
if (res.code != 1) {
throw new Error(res.message)

View File

@ -138,7 +138,7 @@ async function saveWorkflow() {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_WorkFlowSetting,
JSON.stringify(optionStore.ComfyUI_WorkFlowSetting),
OptionType.JOSN
OptionType.JSON
)
if (res.code == 1) {

View File

@ -135,7 +135,7 @@ async function SaveComfyUISimpleSetting() {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_SimpleSetting,
JSON.stringify(optionStore.ComfyUI_SimpleSetting),
OptionType.JOSN
OptionType.JSON
)
if (res.code == 1) {
message.success('保存设置成功')
@ -269,7 +269,7 @@ const handleRemove = (row) => {
let res = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_WorkFlowSetting,
JSON.stringify(optionStore.ComfyUI_WorkFlowSetting),
OptionType.JOSN
OptionType.JSON
)
if (res.code == 0) {
@ -280,7 +280,7 @@ const handleRemove = (row) => {
res = await window.options.ModifyOptionByKey(
OptionKeyName.ComfyUI_SimpleSetting,
JSON.stringify(optionStore.ComfyUI_SimpleSetting),
OptionType.JOSN
OptionType.JSON
)
if (res.code == 1) {

View File

@ -0,0 +1,49 @@
<template>
<div style="padding: 20px;">
<h3>TextEllipsis 组件测试</h3>
<div style="margin: 20px 0;">
<h4>测试 n-tag 组件 (120px 宽度)</h4>
<TextEllipsis
text="这是一个很长的任务ID用来测试省略号效果12345678901234567890"
component="n-tag"
:component-props="{ type: 'primary', size: 'small' }"
max-width="120px"
/>
</div>
<div style="margin: 20px 0;">
<h4>测试 n-text 组件 (100px 宽度)</h4>
<TextEllipsis
text="这是一个很长的文本用来测试省略号效果"
component="n-text"
max-width="100px"
/>
</div>
<div style="margin: 20px 0;">
<h4>短文本不显示tooltip</h4>
<TextEllipsis
text="短文本"
component="n-tag"
:component-props="{ type: 'success', size: 'small' }"
max-width="120px"
/>
</div>
<div style="margin: 20px 0;">
<h4>强制显示tooltip</h4>
<TextEllipsis
text="短文本"
component="n-tag"
:component-props="{ type: 'warning', size: 'small' }"
max-width="120px"
:force-tooltip="true"
/>
</div>
</div>
</template>
<script setup>
import TextEllipsis from '../Common/TextEllipsis.vue'
</script>

View File

@ -1,10 +1,11 @@
import { messageDark, useMessage } from 'naive-ui'
import { defineStore } from 'pinia'
import { errorMessage, successMessage } from '../main/Public/generalTools'
import { BookTaskStatus } from '../define/enum/bookEnum'
import { BookImageCategory, BookTaskStatus } from '../define/enum/bookEnum'
import { Book } from '../model/book/book'
import { GeneralResponse } from '../model/generalResponse'
import { PresetModel } from '../model/preset'
import { ImageToVideoModels } from '@/define/enum/video'
// 系统相关设置
export const useReverseManageStore = defineStore('reverseManage', {
@ -12,36 +13,37 @@ export const useReverseManageStore = defineStore('reverseManage', {
bookType: [],
bookData: [], // 当前显示的所有小说数据
selectBook: {
id: null,
name: null,
bookFolderPath: null,
type: null,
oldVideoPath: null,
srtPath: null,
audioPath: null,
imageFolder: null,
subtitlePosition: null
id: undefined,
name: undefined,
bookFolderPath: undefined,
type: undefined,
oldVideoPath: undefined,
srtPath: undefined,
audioPath: undefined,
imageFolder: undefined,
subtitlePosition: undefined
} as Book.SelectBook, // 当前选中的小说
bookTaskData: [] as Book.SelectBookTask[], // 当前显示的所有小说任务数据
selectBookTask: {
no: null,
id: null,
bookId: null,
name: null,
generateVideoPath: null,
srtPath: null,
audioPath: null,
draftSrtStyle: null, // 草稿字幕样式
backgroundMusic: null, // 背景音乐ID
friendlyReminder: null, // 友情提示
imageFolder: null,
styleList: null,
prefix: null,
imageCategory: null,
no: undefined,
id: undefined,
bookId: undefined,
name: undefined,
generateVideoPath: undefined,
srtPath: undefined,
audioPath: undefined,
draftSrtStyle: undefined, // 草稿字幕样式
backgroundMusic: undefined, // 背景音乐ID
friendlyReminder: undefined, // 友情提示
imageFolder: undefined,
styleList: undefined,
prefix: undefined,
imageCategory: undefined,
videoCategory: undefined,
status: BookTaskStatus.WAIT,
errorMsg: null,
errorMsg: undefined,
openVideoGenerate: false
} as Book.SelectBookTask// 当前选中的小说任务
,
@ -62,6 +64,22 @@ export const useReverseManageStore = defineStore('reverseManage', {
},
actions: {
resetSelectBook() {
this.selectBook = {
id: undefined,
name: undefined,
bookFolderPath: undefined,
type: undefined,
oldVideoPath: undefined,
srtPath: undefined,
audioPath: undefined,
imageFolder: undefined,
subtitlePosition: undefined
}
},
//#region 更新小说批次任务数据
/** 更新小说批次任务数据 */
UpdatedBookTaskData(bookTaskId: string | string[], data: Book.SelectBookTask) {
@ -101,6 +119,7 @@ export const useReverseManageStore = defineStore('reverseManage', {
if (res.data.res_book.length <= 0) {
throw new Error('没有找到对应的小说数据,请先添加小说')
}
debugger
this.SetBookData(res.data.res_book)
this.selectBook = res.data.res_book[0]
return successMessage(res.data)
@ -119,26 +138,29 @@ export const useReverseManageStore = defineStore('reverseManage', {
if (res.code == 0) {
throw new Error(res.message)
}
console.log('获取小说批次任务数据:', res);
if (res.data.bookTasks.length > 0) {
this.bookTaskData = res.data.bookTasks
this.selectBookTask = res.data.bookTasks[0]
} else {
this.selectBookTask = {
no: null,
id: null,
bookId: null,
name: null,
generateVideoPath: null,
srtPath: null,
audioPath: null,
draftSrtStyle: null, // 草稿字幕样式
backgroundMusic: null, // 背景音乐ID
friendlyReminder: null, // 友情提示
imageFolder: null,
styleList: null,
prefix: null,
no: undefined,
id: undefined,
bookId: undefined,
name: undefined,
generateVideoPath: undefined,
srtPath: undefined,
audioPath: undefined,
draftSrtStyle: undefined, // 草稿字幕样式
backgroundMusic: undefined, // 背景音乐ID
friendlyReminder: undefined, // 友情提示
imageFolder: undefined,
imageCategory: BookImageCategory.MJ,
videoCategory: ImageToVideoModels.MJ_VIDEO,
styleList: undefined,
prefix: undefined,
status: BookTaskStatus.WAIT,
errorMsg: null
errorMsg: undefined
} as Book.SelectBookTask
throw new Error('没有找到对应的子批次数据,请先创建')
}
@ -259,6 +281,7 @@ export const useReverseManageStore = defineStore('reverseManage', {
try {
//@ts-ignore
let detailRes = await window.book.GetBookTaskDetail(bookTaskId)
console.log('获取小说任务详细数据222222222', detailRes);
let bookTaskDetail = []
if (detailRes.code == 1) {
bookTaskDetail = detailRes.data.map(item => {