优化 MJ 转视频

优化视频的显示
添加图片的上传
This commit is contained in:
lq1405 2025-09-14 16:25:54 +08:00
parent 8bc60256ba
commit d94e21b3b2
46 changed files with 1504 additions and 478 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "laitool-pro", "name": "laitool-pro",
"productName": "来推 Pro", "productName": "来推 Pro",
"version": "v3.4.5", "version": "v3.4.6",
"description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。", "description": "来推 Pro - 一款集音频处理、文案生成、图片生成、视频生成等功能于一体的多合一AI工具软件。",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "xiangbei", "author": "xiangbei",

View File

@ -9,7 +9,7 @@ const fspromises = fs.promises
/** /**
* *
* @param {*} path * @param {*} filePath
* @returns true表示存在false表示不存在 * @returns true表示存在false表示不存在
*/ */
export async function CheckFileOrDirExist(filePath) { export async function CheckFileOrDirExist(filePath) {

View File

@ -67,7 +67,7 @@ export class BookTaskDetailService extends RealmBaseService {
oldImage: JoinPath(projectPath, item.oldImage), oldImage: JoinPath(projectPath, item.oldImage),
outImagePath: JoinPath(projectPath, item.outImagePath), outImagePath: JoinPath(projectPath, item.outImagePath),
subImagePath: (item.subImagePath as string[])?.map((subImage) => { subImagePath: (item.subImagePath as string[])?.map((subImage) => {
return JoinPath(projectPath, subImage) return JoinPath(projectPath, subImage) + '?t=' + new Date().getTime()
}), }),
subVideoPath: (item.subVideoPath as string[]).map((subVideo) => subVideo.toString()), subVideoPath: (item.subVideoPath as string[]).map((subVideo) => subVideo.toString()),
subVideoPathObject: (item.subVideoPath as string[])?.map((subVideo) => { subVideoPathObject: (item.subVideoPath as string[])?.map((subVideo) => {

View File

@ -52,6 +52,7 @@ export const GetImageToVideoModelsLabel = (model: ImageToVideoModels | string) =
case ImageToVideoModels.PIKA: case ImageToVideoModels.PIKA:
return 'Pika' return 'Pika'
case ImageToVideoModels.MJ_VIDEO: case ImageToVideoModels.MJ_VIDEO:
case ImageToVideoModels.MJ_VIDEO_EXTEND:
return t('MJ视频') return t('MJ视频')
default: default:
return '未知' return '未知'

View File

@ -48,5 +48,17 @@ export function bookImageIpc() {
await bookHandle.DownloadImageUrlAndSplit(bookTaskDetailId, imageUrl) await bookHandle.DownloadImageUrlAndSplit(bookTaskDetailId, imageUrl)
) )
/** 同步主图文件到批次任务 */
ipcMain.handle(
DEFINE_STRING.BOOK.SYNC_MAIN_IMAGE_FOR_BOOK_TASK,
async (_, bookTaskId: string) => await bookHandle.SyncMainImageForBookTask(bookTaskId)
)
/** 同步子图文件到批次任务 */
ipcMain.handle(
DEFINE_STRING.BOOK.SYNC_SUB_IMAGE_FOR_BOOK_TASK,
async (_, bookTaskId: string) => await bookHandle.SyncSubImageForBookTask(bookTaskId)
)
//#endregion //#endregion
} }

View File

@ -133,6 +133,13 @@ function SystemIpc() {
async (_, filePath: string) => await electronInterface.ReadTextFile(filePath) async (_, filePath: string) => await electronInterface.ReadTextFile(filePath)
) )
/** 上传图片到LaiTool云端 */
ipcMain.handle(
DEFINE_STRING.SYSTEM.UPLOAD_IMAGE_TO_LAITOOL,
async (_, imagePath: string, type: "video" | "image") =>
await electronInterface.UploadImageToLaiTool(imagePath, type)
)
//#endregion //#endregion
} }

View File

@ -136,6 +136,12 @@ const BOOK = {
/** 下载图片并拆分处理应用到分镜 */ /** 下载图片并拆分处理应用到分镜 */
DOWNLOAD_IMAGE_URL_AND_SPLIT: 'DOWNLOAD_IMAGE_URL_AND_SPLIT', DOWNLOAD_IMAGE_URL_AND_SPLIT: 'DOWNLOAD_IMAGE_URL_AND_SPLIT',
/** 同步主图文件到批次任务 */
SYNC_MAIN_IMAGE_FOR_BOOK_TASK: 'SYNC_MAIN_IMAGE_FOR_BOOK_TASK',
/** 同步子图文件到批次任务 */
SYNC_SUB_IMAGE_FOR_BOOK_TASK: 'SYNC_SUB_IMAGE_FOR_BOOK_TASK',
//#endregion //#endregion
//#region 导出相关 //#region 导出相关

View File

@ -51,6 +51,9 @@ const SYSTEM = {
/** 读取文本文件内容 */ /** 读取文本文件内容 */
READ_TEXT_FILE: 'READ_TEXT_FILE', READ_TEXT_FILE: 'READ_TEXT_FILE',
/** 上传图片到LaiTool云端 */
UPLOAD_IMAGE_TO_LAITOOL: 'UPLOAD_IMAGE_TO_LAITOOL',
//#endregion //#endregion
/** 打开指定的url */ /** 打开指定的url */

View File

@ -137,6 +137,9 @@ declare namespace BookTaskDetail {
*/ */
endImageUrl?: string endImageUrl?: string
/** 视频拓展时的尾帧图片 */
extendEndImageUrl?: string
/** /**
* *
*/ */

View File

@ -10,4 +10,40 @@ declare namespace BookVideo {
bookId?: string // 小说ID bookId?: string // 小说ID
bookTaskId?: string // 小说任务ID bookTaskId?: string // 小说任务ID
} }
/**
*
*
* @interface VideoGenerateRequest
* @property {string | null} [prompt] -
* @property {'low' | 'high'} motion -
* @property {'vid_1.1_i2v_480' | 'vid_1.1_i2v_720'} videoType -
* @property {'url' | 'base64'} image -
* @property {string} [endImage] -
* @property {boolean} [loop] -
* @property {1 | 2 | 4} [batchSize] - 4
* @property {'extend'} [action] - indextaskId必填
* @property {number} [index] - 0-3
* @property {string} [taskId] - ID
* @property {string} [state] -
* @property {string} [notifyHook] -
* @property {boolean} [noStorage] - True时返回官方链接
*/
interface MJVideoGenerateRequest {
prompt?: string | null
motion: 'low' | 'high'
videoType: 'vid_1.1_i2v_480' | 'vid_1.1_i2v_720'
image?: string
endImage?: string
loop?: boolean
batchSize?: 1 | 2 | 4
action?: 'extend'
index?: number // 0-3
taskId?: string
state?: string
notifyHook?: string
noStorage?: boolean
}
} }

16
src/define/model/image.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
declare namespace ImageModel {
/**
*
*/
interface FileUploadRequest {
/** 文件内容 - Base64编码或二进制数据 */
file: string
/** 文件名称 - 包含扩展名的完整文件名 */
fileName: string
/** 文件内容类型 - MIME类型如 'image/jpeg', 'image/png' 等 */
contentType: string
/** 文件类型分类 - 用于服务器端分类处理,如 'image', 'video' 等 */
type?: string
}
}

View File

@ -2,6 +2,7 @@ export default {
//#region 通用 //#region 通用
"是": "Yes", "是": "Yes",
"否": 'No', "否": 'No',
"空": 'Empty',
"操作": 'Action', "操作": 'Action',
"清空": 'Clear', "清空": 'Clear',
"生成": 'Generate', "生成": 'Generate',
@ -227,6 +228,10 @@ export default {
"初始视频消息失败,未找到指定小说批次任务的分镜数据": "Failed to initialize video message, storyboard data for specified novel batch task not found", "初始视频消息失败,未找到指定小说批次任务的分镜数据": "Failed to initialize video message, storyboard data for specified novel batch task not found",
'根据ID获取小说批次任务信息失败ID不能为空': 'Failed to retrieve novel batch task information by ID, ID cannot be empty!', '根据ID获取小说批次任务信息失败ID不能为空': 'Failed to retrieve novel batch task information by ID, ID cannot be empty!',
"未找到对应的小说批次任务信息,请检查!": "Corresponding novel batch task information not found, please check!", "未找到对应的小说批次任务信息,请检查!": "Corresponding novel batch task information not found, please check!",
"同步主图文件失败,{error}": "Synchronization of main image file failed, {error}",
"同步分镜主图文件成功,同步成功数 {count},同步分镜名称 {name}": "Storyboard main image files synchronized successfully, {count} images synchronized, storyboard names: {name}",
"同步分镜子图文件成功,同步成功数 {count},同步分镜名称 {name}": "Storyboard sub-image files synchronized successfully, {count} images synchronized, storyboard names: {name}",
"同步分镜子图文件失败,{error}": "Synchronization of sub-image files failed, {error}",
//#endregion //#endregion
//#region 小说分镜 //#region 小说分镜
@ -298,6 +303,12 @@ export default {
"反推提示词的ID不能为空": "Reverse prompt ID cannot be empty", "反推提示词的ID不能为空": "Reverse prompt ID cannot be empty",
"全部翻译完成": "All translation completed", "全部翻译完成": "All translation completed",
"翻译失败,{error}": "Translation failed, {error}", "翻译失败,{error}": "Translation failed, {error}",
"检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!": "Check all storyboard information, check storyboards without main images, determine if there are images with the same name as the storyboard in the corresponding image output folder, if so, sync to the main image of the corresponding storyboard, if not, skip!",
'检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!\n\n 注意:该操作不会覆盖已经有主图的分镜!\n\n 该操作适用于手动将图片放入输出文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹': 'Check all storyboard information, check storyboards without main images, determine if there are images with the same name as the storyboard in the corresponding image output folder, if so, sync to the main image of the corresponding storyboard, if not, skip!\n\n Note: This operation will not overwrite storyboards that already have main images!\n\n This operation is suitable for manually placing images into the output folder and then syncing to the storyboard! Click "Open Folder" on the right to open the current batch\'s image output folder',
"检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!": "Check all storyboard information, determine if the images in the storyboard's sub-image folder are all in the storyboard's sub-image list, if not, add them!",
'检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!\n\n 注意:该操作不会删除分镜中已经有的子图信息!而是根据文件中的图片信息添加到分镜的子图数据中\n\n 该操作适用于手动将子图放入子图文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹': 'Check all storyboard information, determine if the images in the storyboard\'s sub-image folder are all in the storyboard\'s sub-image list, if not, add them!\n\n Note: This operation will not delete existing sub-image information in the storyboard! Instead, it adds image information from the folder to the storyboard\'s sub-image data\n\n This operation is suitable for manually placing sub-images into the sub-image folder and then syncing to the storyboard! Click "Open Folder" on the right to open the current batch\'s image output folder',
'正在同步主图信息,请稍后...': 'Syncing main image information, please wait...',
'同步主图信息失败,{error}': 'Failed to sync main image information, {error}',
//#endregion //#endregion
//#region 出图 //#region 出图
@ -550,6 +561,8 @@ export default {
"将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接": "Upload images to LaiTool image host, supports multiple image formats, get shareable links", "将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接": "Upload images to LaiTool image host, supports multiple image formats, get shareable links",
"搜索工具...": "Search tools...", "搜索工具...": "Search tools...",
"上传图片到LaiTool图床获取图片链接": "Upload images to LaiTool image host, get image links", "上传图片到LaiTool图床获取图片链接": "Upload images to LaiTool image host, get image links",
"上传成功": "Upload successful",
"上传后返回的链接为空!": "Returned link after upload is empty!",
"上传失败,{error}": "Upload failed, {error}", "上传失败,{error}": "Upload failed, {error}",
'未找到机器ID请重启软件后重试': 'Machine ID not found, please restart software and try again!!', '未找到机器ID请重启软件后重试': 'Machine ID not found, please restart software and try again!!',
'图片处理完毕,开始上传文件...': 'Image processing completed, starting file upload...', '图片处理完毕,开始上传文件...': 'Image processing completed, starting file upload...',
@ -1326,6 +1339,7 @@ export default {
'初始化小说分镜模块失败,{error}': 'Failed to initialize novel storyboard module, {error}', '初始化小说分镜模块失败,{error}': 'Failed to initialize novel storyboard module, {error}',
'正在初始化小说分镜模块,{bookName}_{bookTaskName}': 'Initializing novel storyboard module, {bookName}_{bookTaskName}', '正在初始化小说分镜模块,{bookName}_{bookTaskName}': 'Initializing novel storyboard module, {bookName}_{bookTaskName}',
'分镜出图的进度:{progress}% ': 'Storyboard generation progress: {progress}% ', '分镜出图的进度:{progress}% ': 'Storyboard generation progress: {progress}% ',
"视频生成的进度:{progress}% ": "Video generation progress: {progress}% ",
'正在删除小说数据...': 'Deleting novel data...', '正在删除小说数据...': 'Deleting novel data...',
'是否删除该小说,删除后数据将无法恢复,请确认是否继续!': 'Are you sure you want to delete this novel? Data cannot be recovered after deletion, please confirm whether to continue!', '是否删除该小说,删除后数据将无法恢复,请确认是否继续!': 'Are you sure you want to delete this novel? Data cannot be recovered after deletion, please confirm whether to continue!',
'正在重置小说数据。。。': 'Resetting novel data...', '正在重置小说数据。。。': 'Resetting novel data...',
@ -1777,6 +1791,7 @@ export default {
'复制失败:存在未生成的文本,请先生成文本!': 'Copy failed: Ungenerated text exists, please generate text first!', '复制失败:存在未生成的文本,请先生成文本!': 'Copy failed: Ungenerated text exists, please generate text first!',
'复制成功': 'Copy successful', '复制成功': 'Copy successful',
'复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}': 'Copy failed, please manually copy in the display generated text on the left, error details: {error}', '复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}': 'Copy failed, please manually copy in the display generated text on the left, error details: {error}',
"复制失败,复制内容为空": "Copy failed, copied content is empty",
'复制失败:{error}': 'Copy failed: {error}', '复制失败:{error}': 'Copy failed: {error}',
"复制失败,请手动复制:{data}": "Copy failed, please copy manually: {data}", "复制失败,请手动复制:{data}": "Copy failed, please copy manually: {data}",
'取消复制': 'Cancel Copy', '取消复制': 'Cancel Copy',

View File

@ -2,6 +2,7 @@ export default {
//#region 通用 //#region 通用
"是": '是', "是": '是',
"否": '否', "否": '否',
"空": '空',
"操作": '操作', "操作": '操作',
"清空": '清空', "清空": '清空',
"生成": '生成', "生成": '生成',
@ -227,6 +228,10 @@ export default {
"初始视频消息失败,未找到指定小说批次任务的分镜数据": "初始视频消息失败,未找到指定小说批次任务的分镜数据", "初始视频消息失败,未找到指定小说批次任务的分镜数据": "初始视频消息失败,未找到指定小说批次任务的分镜数据",
'根据ID获取小说批次任务信息失败ID不能为空': '根据ID获取小说批次任务信息失败ID不能为空', '根据ID获取小说批次任务信息失败ID不能为空': '根据ID获取小说批次任务信息失败ID不能为空',
"未找到对应的小说批次任务信息,请检查!": "未找到对应的小说批次任务信息,请检查!", "未找到对应的小说批次任务信息,请检查!": "未找到对应的小说批次任务信息,请检查!",
"同步主图文件失败,{error}": "同步主图文件失败,{error}",
"同步分镜主图文件成功,同步成功数 {count},同步分镜名称 {name}": "同步分镜主图文件成功,同步成功数 {count},同步分镜名称 {name}",
"同步分镜子图文件成功,同步成功数 {count},同步分镜名称 {name}": "同步分镜子图文件成功,同步成功数 {count},同步分镜名称 {name}",
"同步分镜子图文件失败,{error}": "同步分镜子图文件失败,{error}",
//#endregion //#endregion
//#region 小说分镜 //#region 小说分镜
@ -298,6 +303,12 @@ export default {
"反推提示词的ID不能为空": "反推提示词的ID不能为空", "反推提示词的ID不能为空": "反推提示词的ID不能为空",
"全部翻译完成": "全部翻译完成", "全部翻译完成": "全部翻译完成",
"翻译失败,{error}": "翻译失败,{error}", "翻译失败,{error}": "翻译失败,{error}",
"检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!": "检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!",
'检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!\n\n 注意:该操作不会覆盖已经有主图的分镜!\n\n 该操作适用于手动将图片放入输出文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹': '检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!\n\n 注意:该操作不会覆盖已经有主图的分镜!\n\n 该操作适用于手动将图片放入输出文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹',
"检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!": "检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!",
'检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!\n\n 注意:该操作不会删除分镜中已经有的子图信息!而是根据文件中的图片信息添加到分镜的子图数据中\n\n 该操作适用于手动将子图放入子图文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹': '检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!\n\n 注意:该操作不会删除分镜中已经有的子图信息!而是根据文件中的图片信息添加到分镜的子图数据中\n\n 该操作适用于手动将子图放入子图文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹',
'正在同步主图信息,请稍后...': '正在同步主图信息,请稍后...',
'同步主图信息失败,{error}': '同步主图信息失败,{error}',
//#endregion //#endregion
//#region 出图 //#region 出图
@ -550,6 +561,8 @@ export default {
"将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接": "将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接", "将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接": "将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接",
"搜索工具...": "搜索工具...", "搜索工具...": "搜索工具...",
"上传图片到LaiTool图床获取图片链接": "上传图片到LaiTool图床获取图片链接", "上传图片到LaiTool图床获取图片链接": "上传图片到LaiTool图床获取图片链接",
"上传成功": "上传成功",
"上传后返回的链接为空!": "上传后返回的链接为空!",
"上传失败,{error}": "上传失败,{error}", "上传失败,{error}": "上传失败,{error}",
'未找到机器ID请重启软件后重试': '未找到机器ID请重启软件后重试', '未找到机器ID请重启软件后重试': '未找到机器ID请重启软件后重试',
'图片处理完毕,开始上传文件...': '图片处理完毕,开始上传文件...', '图片处理完毕,开始上传文件...': '图片处理完毕,开始上传文件...',
@ -1326,6 +1339,7 @@ export default {
'初始化小说分镜模块失败,{error}': '初始化小说分镜模块失败,{error}', '初始化小说分镜模块失败,{error}': '初始化小说分镜模块失败,{error}',
'正在初始化小说分镜模块,{bookName}_{bookTaskName}': '正在初始化小说分镜模块,{bookName}_{bookTaskName}', '正在初始化小说分镜模块,{bookName}_{bookTaskName}': '正在初始化小说分镜模块,{bookName}_{bookTaskName}',
'分镜出图的进度:{progress}% ': '分镜出图的进度:{progress}% ', '分镜出图的进度:{progress}% ': '分镜出图的进度:{progress}% ',
"视频生成的进度:{progress}% ": "视频生成的进度:{progress}% ",
'正在删除小说数据...': '正在删除小说数据...', '正在删除小说数据...': '正在删除小说数据...',
'是否删除该小说,删除后数据将无法恢复,请确认是否继续!': '是否删除该小说,删除后数据将无法恢复,请确认是否继续!', '是否删除该小说,删除后数据将无法恢复,请确认是否继续!': '是否删除该小说,删除后数据将无法恢复,请确认是否继续!',
'正在重置小说数据。。。': '正在重置小说数据。。。', '正在重置小说数据。。。': '正在重置小说数据。。。',
@ -1777,6 +1791,7 @@ export default {
'复制失败:存在未生成的文本,请先生成文本!': '复制失败:存在未生成的文本,请先生成文本!', '复制失败:存在未生成的文本,请先生成文本!': '复制失败:存在未生成的文本,请先生成文本!',
'复制成功': '复制成功', '复制成功': '复制成功',
'复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}': '复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}', '复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}': '复制失败,请在左边的显示生成文本中进行手动复制,失败信息如下:{error}',
"复制失败,复制内容为空": "复制失败,复制内容为空",
'复制失败:{error}': '复制失败:{error}', '复制失败:{error}': '复制失败:{error}',
"复制失败,请手动复制:{data}": "复制失败,请手动复制:{data}", "复制失败,请手动复制:{data}": "复制失败,请手动复制:{data}",
'取消复制': '取消复制', '取消复制': '取消复制',

View File

@ -55,5 +55,13 @@ export class BookImageEntrance {
DownloadImageUrlAndSplit = async (bookTaskDetailId: string, imageUrl: string) => DownloadImageUrlAndSplit = async (bookTaskDetailId: string, imageUrl: string) =>
await this.bookImageHandle.DownloadImageUrlAndSplit(bookTaskDetailId, imageUrl) await this.bookImageHandle.DownloadImageUrlAndSplit(bookTaskDetailId, imageUrl)
/** 同步主图文件到批次任务 */
SyncMainImageForBookTask = async (bookTaskId: string) =>
await this.bookImageHandle.SyncMainImageForBookTask(bookTaskId)
/** 同步子图文件到批次任务 */
SyncSubImageForBookTask = async (bookTaskId: string) =>
await this.bookImageHandle.SyncSubImageForBookTask(bookTaskId)
//#endregion //#endregion
} }

View File

@ -5,7 +5,8 @@ import {
CheckFileOrDirExist, CheckFileOrDirExist,
CheckFolderExistsOrCreate, CheckFolderExistsOrCreate,
CopyFileOrFolder, CopyFileOrFolder,
DownloadImageFromUrl DownloadImageFromUrl,
GetFilesWithExtensions
} from '@/define/Tools/file' } from '@/define/Tools/file'
import { getProjectPath } from '../../option/optionCommonService' import { getProjectPath } from '../../option/optionCommonService'
import { errorMessage, successMessage } from '@/public/generalTools' import { errorMessage, successMessage } from '@/public/generalTools'
@ -741,4 +742,277 @@ export class BookImageHandle extends BookBasicHandle {
) )
} }
} }
/**
*
*
*
*
*
*
*
* 1.
* 2.
* 3.
* 4.
* 5.
* 6.
*
*
* - .png
* - .png
* -
* -
*
* @param {string} bookTaskId - ID
* @returns {Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem>}
* ID
*
*
* @throws {Error}
*
* @example
* // 同步批次任务的所有主图文件
* const result = await bookImageHandle.SyncMainImageForBookTask("task-123");
* if (result.code === 1) {
* console.log("同步成功:", result.data);
* // result.data 包含: [{ id: "detail-1", name: "第一章", outImagePath: "..." }]
* }
*/
public async SyncMainImageForBookTask(
bookTaskId: string
): Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem> {
try {
await this.InitBookBasicHandle()
// 获取批次任务信息
const bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId, true)
// 获取图片输出文件夹
const imageFolder = bookTask.imageFolder
if (!imageFolder || isEmpty(imageFolder)) {
throw new Error(t('没有找到对应的小说批次任务的图片输出地址,请检查'))
}
// 检查图片文件夹是否存在
if (!(await CheckFileOrDirExist(imageFolder))) {
throw new Error(t("目的文件/文件夹不存在,{data}", {
data: imageFolder
}))
}
// 获取所有分镜详情
const bookTaskDetails = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({
bookTaskId: bookTaskId
})
if (bookTaskDetails.length <= 0) {
throw new Error(t('没有找到对应的小说批次任务的分镜数据,请检查'))
}
let result: any = []
const projectPath = await getProjectPath()
for (const detail of bookTaskDetails) {
// 跳过主图已经存在并且对应的文件也存在的分镜
if (!isEmpty(detail.outImagePath) && await CheckFileOrDirExist(detail.outImagePath as string)) {
continue
}
// 按照分镜名称获取默认的图片文件,并检查对应的文件是不是存在
let foundImagePath = path.join(imageFolder, detail.name as string + '.png');
const foundImagePathExist = await CheckFileOrDirExist(foundImagePath)
if (!foundImagePathExist) {
continue
}
// 对应的图片存在,小改小说分镜信息
await this.bookTaskDetailService.ModifyBookTaskDetailById(detail.id as string, {
outImagePath: path.relative(projectPath, foundImagePath),
} as any)
// 修改出图信息
let mjMessage = {
status: 'success',
progress: 100,
category: ImageGenerateMode.MJ_API,
messageId: '',
action: MJAction.IMAGINE
}
this.bookTaskDetailService.UpdateBookTaskDetailMjMessage(detail.id as string, mjMessage)
// 成功 构建返回数据
result.push({
id: detail.id,
name: detail.name,
outImagePath: foundImagePath + '?t=' + new Date().getTime()
})
}
return successMessage(
result,
t("同步分镜主图文件成功,同步成功数 {count},同步分镜名称 {name}", {
count: result.length,
name: result.length == 0 ? t("空") : result.map((item: any) => item.name).join(',')
}),
'BookImage_SyncMainImageForBookTask'
)
} catch (error: any) {
return errorMessage(
t('同步主图文件失败,{error}', {
error: (error as Error).message
}),
'BookImage_SyncMainImageForBookTask'
)
}
}
/**
*
*
*
*
*
*
* 1.
* 2.
* 3. imageFolder/subImage/
* 4. .png
* 5.
* 6.
* 7.
*
*
* - .png
* -
* - /subImage/
* -
*
* @param {string} bookTaskId - ID
* @returns {Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem>}
* ID
*
*
* @throws {Error}
*
* @example
* // 同步批次任务的所有子图文件
* const result = await bookImageHandle.SyncSubImageForBookTask("task-123");
* if (result.code === 1) {
* console.log("同步成功:", result.data);
* // result.data 包含: [{ id: "detail-1", name: "第一章", subImagePath: [...] }]
* }
*/
public async SyncSubImageForBookTask(
bookTaskId: string
): Promise<GeneralResponse.SuccessItem | GeneralResponse.ErrorItem> {
try {
// 初始化基础处理器
await this.InitBookBasicHandle()
// 获取批次任务信息
const bookTask = await this.bookTaskService.GetBookTaskDataById(bookTaskId, true)
// 获取并验证图片输出文件夹路径
const imageFolder = bookTask.imageFolder
if (!imageFolder || isEmpty(imageFolder)) {
throw new Error(t('没有找到对应的小说批次任务的图片输出地址,请检查'))
}
// 检查图片文件夹是否存在
if (!(await CheckFileOrDirExist(imageFolder))) {
throw new Error(t("目的文件/文件夹不存在,{data}", {
data: imageFolder
}))
}
// 获取该批次任务下的所有分镜详情
const bookTaskDetails = await this.bookTaskDetailService.GetBookTaskDetailDataByCondition({
bookTaskId: bookTaskId
})
// 验证是否有分镜数据
if (bookTaskDetails.length <= 0) {
throw new Error(t('没有找到对应的小说批次任务的分镜数据,请检查'))
}
// 初始化结果数组和项目根路径
const result: { id: string; name: string, subImagePath: string[] }[] = []
let projectPath = await getProjectPath()
// 遍历每个分镜进行子图同步
for (const detail of bookTaskDetails) {
// 构建子图文件夹路径:图片输出目录/subImage/分镜名称
let subImageFolderPath = path.join(imageFolder, 'subImage', detail.name as string)
// 检查子图文件夹是否存在,不存在则跳过该分镜
if (!(await CheckFileOrDirExist(subImageFolderPath))) {
continue
}
// 获取子图文件夹中的所有 .png 图片文件
let imageFiles = await GetFilesWithExtensions(subImageFolderPath, ['.png']);
if (imageFiles.length === 0) {
continue // 没有图片文件则跳过
}
// 获取当前分镜在数据库中已有的子图列表
const currentSubImages = detail.subImagePath || []
// 将数据库中已有的子图路径转换为绝对路径,便于与文件夹中的图片进行比较
const currentSubImagePaths = currentSubImages
.map(item => item.replace(/\?t=\d+$/, '')) // 移除时间戳参数 (?t=数字)
.map(item => path.resolve(projectPath, item)) // 相对路径转换为绝对路径
// 找出新图片:在文件夹中存在但在数据库记录中不存在的图片
let newImages: string[] = [];
for (let i = 0; i < imageFiles.length; i++) {
const imageFile = imageFiles[i];
const absoluteImagePath = path.resolve(imageFile);
// 如果这个图片文件不在已有的子图列表中,则添加到新图片列表
if (!currentSubImagePaths.includes(absoluteImagePath)) {
newImages.push(imageFile);
}
}
// 如果没有新图片需要添加,跳过该分镜
if (newImages.length === 0) {
continue
}
// 合并已有子图和新发现的图片,形成完整的子图列表
const allSubImages = [...currentSubImages, ...newImages]
// 更新数据库中分镜的子图列表(存储相对路径)
await this.bookTaskDetailService.ModifyBookTaskDetailById(detail.id as string, {
subImagePath: allSubImages.map((item) => path.relative(projectPath, item))
} as any)
// 将同步结果添加到返回数组(返回绝对路径+时间戳)
result.push({
id: detail.id as string,
name: detail.name as string,
subImagePath: allSubImages.map((item) => item + '?t=' + new Date().getTime())
})
}
// 返回成功结果,包含统计信息和详细数据
return successMessage(
result,
t("同步分镜子图文件成功,同步成功数 {count},同步分镜名称 {name}", {
count: result.length,
name: result.length == 0 ? t("空") : result.map((item: any) => item.name).join(',')
}),
'BookImage_SyncSubImageForBookTask'
)
} catch (error: any) {
// 捕获并返回错误信息
return errorMessage(
t('同步子图文件失败,{error}', {
error: (error as Error).message
}),
'BookImage_SyncSubImageForBookTask'
)
}
}
} }

View File

@ -308,6 +308,8 @@ export class BookVideoServiceHandle extends BookBasicHandle {
switch (task.type) { switch (task.type) {
case BookBackTaskType.MJ_VIDEO: case BookBackTaskType.MJ_VIDEO:
return await videoHandle.MJImageToVideo(task) return await videoHandle.MJImageToVideo(task)
case BookBackTaskType.MJ_VIDEO_EXTEND:
return await videoHandle.MJVideoExtendToVideo(task)
default: default:
throw new Error('未知的视频生成方式,请检查') throw new Error('未知的视频生成方式,请检查')
} }

View File

@ -5,6 +5,10 @@ import fs from 'fs/promises'
import { errorMessage, successMessage } from '../../../public/generalTools' import { errorMessage, successMessage } from '../../../public/generalTools'
import { ErrorItem, SuccessItem } from '@/define/model/generalResponse' import { ErrorItem, SuccessItem } from '@/define/model/generalResponse'
import { t } from '@/i18n' import { t } from '@/i18n'
import { GetImageBase64, GetMimeType } from '@/define/Tools/image'
import axios from 'axios'
import { define } from '@/define/define'
import { isEmpty } from 'lodash'
/** 打开指定的文件夹的方法 */ /** 打开指定的文件夹的方法 */
export type OpenFolderParams = { export type OpenFolderParams = {
@ -318,4 +322,89 @@ export default class ElectronInterface {
) )
} }
} }
/**
* LaiTool云端
*
* LaiTool云端服务器
*
*
* @param {string} imagePath -
* @param {"video" | "image"} type -
* - "image":
* - "video": MJ垫图等功能
*
* @returns {Promise<ErrorItem | SuccessItem>}
* - SuccessItem对象
* - ErrorItem对象
*
* @throws {Error}
* -
* -
* -
* -
* -
*
* @description
* 1. 50/
* 2. jpg, jpeg, png, gif, bmp, webp
* 3. LaiTool服务器保留
* 4. 访
* 5.
*/
public async UploadImageToLaiTool(
imagePath: string,
type: "video" | "image"
): Promise<ErrorItem | SuccessItem> {
try {
// 检查机器码是不是存在
if (!global.machineId || isEmpty(global.machineId)) {
throw new Error(t('获取机器码失败,请重启软件或者检查对应权限!!'))
}
// 检查文件是不是存在
if (!(await CheckFileOrDirExist(imagePath))) {
throw new Error(t('文件不存在'))
}
// 获取图片文件的base64
let fileBase64 = await GetImageBase64(imagePath)
let contentType = GetMimeType(imagePath);
let body: ImageModel.FileUploadRequest = {
file: fileBase64,
fileName: path.basename(imagePath),
contentType: contentType
}
if (type == "video") {
body.type = "video"
}
// 开始上传
let res = await axios.post(define.lms_url + `/lms/FileUpload/FileUpload/${global.machineId}`, body)
let resData = res.data;
if (resData.code !== 1) {
throw new Error(resData.message || t('未知错误'))
}
let url = resData.data.url;
if (url == null || isEmpty(url)) {
throw new Error(t('上传后返回的链接为空'))
}
// 上传成功,返回结果
return successMessage(
resData.data,
t('上传成功'),
'SystemIpc_UploadImageToLaiTool'
)
} catch (error: any) {
console.error('上传图片错误:', error)
return errorMessage(
t("上传失败,{error}", {
error: error.message
}),
'SystemIpc_UploadImageToLaiTool'
)
}
}
} }

View File

@ -12,4 +12,9 @@ export class VideoHandle {
MJImageToVideo(task: TaskModal.Task) { MJImageToVideo(task: TaskModal.Task) {
return this.mjVideoService.MJImageToVideo(task) return this.mjVideoService.MJImageToVideo(task)
} }
/** MJ视频扩展生成视频处理方法 将指定的视频通过Midjourney API进行扩展生成新视频 */
MJVideoExtendToVideo(task: TaskModal.Task) {
return this.mjVideoService.MJVideoExtendToVideo(task)
}
} }

View File

@ -22,6 +22,7 @@ import path from 'path'
import { CheckFolderExistsOrCreate, CopyFileOrFolder } from '@/define/Tools/file' import { CheckFolderExistsOrCreate, CopyFileOrFolder } from '@/define/Tools/file'
import { DownloadFile } from '@/define/Tools/common' import { DownloadFile } from '@/define/Tools/common'
import { getProjectPath } from '../option/optionCommonService' import { getProjectPath } from '../option/optionCommonService'
import { define } from '@/define/define'
export class MJVideoService extends MJApiService { export class MJVideoService extends MJApiService {
constructor() { constructor() {
@ -95,31 +96,25 @@ export class MJVideoService extends MJApiService {
throw new Error(t("不支持的图片链接,仅支持网络图片链接!")) throw new Error(t("不支持的图片链接,仅支持网络图片链接!"))
} }
// 在提示词后面添加 --raw // 判断有没有提示词若有提示词并且raw为true在提示词后面添加 --raw
if (!isEmpty(prompt) && raw) { if (!isEmpty(prompt) && raw) {
prompt = prompt + ' --raw' prompt = prompt + ' --raw'
} }
prompt = imageUrl + ' ' + prompt
// 添加 批次信息 let body: BookVideo.MJVideoGenerateRequest = {
prompt = prompt + ' --bs ' + batchSize
if (loop) {
prompt = prompt + ' --end loop'
} else {
if (!isEmpty(endImageUrl)) {
prompt = prompt + ' --end ' + endImageUrl
}
}
let body: {
prompt: string
motion?: MJVideoMotion
videoType: MJVideoType
} = {
prompt: prompt, prompt: prompt,
motion: motion, motion: motion,
videoType: videoType videoType: videoType,
image: imageUrl,
batchSize: batchSize
}
if (loop) {
body.loop = loop
} else {
if (endImageUrl && !isEmpty(endImageUrl)) {
body.endImage = endImageUrl
}
} }
// 开始请求 // 开始请求
@ -178,6 +173,131 @@ export class MJVideoService extends MJApiService {
} }
} }
//#region MJVideoExtendToVideo
async MJVideoExtendToVideo(task: TaskModal.Task) {
try {
await this.InitMJBasic()
await this.InitMJSetting(ImageGenerateMode.MJ_API)
// 检查是否支持视频功能
if (this.videoUrl == null || isEmpty(this.videoUrl)) {
throw new Error(t('当前Midjourney模式不支持视频生成功能请更换为MJ API或本地代理模式后重试'))
}
// 检查 token
if (this.token == null || isEmpty(this.token)) {
throw new Error(
t("对应Midjourney模式的Token不能为空请前往 {settingPath} 中配置", { settingPath: t("设置 -> MJ设置") })
)
}
// 开始处理小说数据
let bookTaskDetail = await this.bookTaskDetailService.GetBookTaskDetailDataById(
task.bookTaskDetailId as string, true
)
// 获取视频配置信息
let videoMessage = bookTaskDetail.videoMessage
if (videoMessage == null || videoMessage == undefined) {
throw new Error(t("小说批次任务的分镜数据的转视频配置为空,请检查"))
}
// 获取 MJ Video 的options
let mjVideoOptionsString = bookTaskDetail.videoMessage?.mjVideoOptions as string
if (!ValidateJson(mjVideoOptionsString)) {
throw new Error(t("当前分镜数据的MJ图转视频参数为空或参数校验失败请检查"))
}
let mjVideoOptions: BookTaskDetail.MjVideoOptions = JSON.parse(mjVideoOptionsString)
let prompt = videoMessage.prompt?.trim()
let motion: MJVideoMotion =
mjVideoOptions.motion === MJVideoMotion.High ? MJVideoMotion.High : MJVideoMotion.Low
let videoType = mjVideoOptions.videoType ?? MJVideoType.HD
let batchSize = mjVideoOptions.batchSize ?? MJVideoBatchSize.FOUR
// 暂不支持尾帧
// let extendEndImageUrl = mjVideoOptions.extendEndImageUrl?.trim() || ''
let raw = mjVideoOptions.raw ?? false
if (mjVideoOptions.taskId == null || isEmpty(mjVideoOptions.taskId) || mjVideoOptions.index == null) {
throw new Error(t("请先选择父任务ID和视频索引"))
}
// 开始构建请求体
// 判断有没有提示词若有提示词并且raw为true则在提示词后面添加 --raw
if (!isEmpty(prompt) && raw) {
prompt = prompt + ' --raw'
}
let body: BookVideo.MJVideoGenerateRequest = {
motion: motion,
videoType: videoType,
batchSize: batchSize,
action: 'extend',
taskId: mjVideoOptions.taskId,
index: mjVideoOptions.index
}
if (!isEmpty(prompt)) {
body.prompt = prompt
}
// 开始请求
let res = await axios.post(this.videoUrl, body, {
headers: {
Authorization: this.token
}
})
console.log('MJVideoExtendToVideo response', res.data)
let resData = res.data
let id = resData.result
// 修改Task, 将数据写入
this.taskListService.UpdateBackTaskData(task.id as string, {
taskId: id as string,
taskMessage: JSON.stringify(resData)
})
// 修改videoMessage
videoMessage.taskId = id
videoMessage.status = VideoStatus.WAIT
videoMessage.messageData = JSON.stringify(resData)
videoMessage.msg = ''
delete videoMessage.imageUrl // 不要修改原本的图片地址
// 添加任务成功 返回前端任务事件
SendReturnMessage(
{
code: 1,
id: task.bookTaskDetailId as string,
message: t('已成功提交Midjourney图转视频任务任务ID{taskId}', { taskId: id }),
type: ResponseMessageType.MJ_VIDEO,
data: JSON.stringify(videoMessage)
},
task.messageName as string
)
// 开始循环查询任务状态
await this.FetchMJVideoResult(bookTaskDetail, task, id)
return successMessage(
t('Midjourney图转视频任务执行完成。'),
'MJVideoService_MJImageToVideo'
)
} catch (error) {
throw new Error(
t('Midjourney图转视频任务执行失败失败信息如下{error}', { error: (error as Error).message })
)
}
}
//#endregion
//#endregion //#endregion
//#region FetchMJVideoResult //#region FetchMJVideoResult
@ -217,7 +337,7 @@ export class MJVideoService extends MJApiService {
task: TaskModal.Task, task: TaskModal.Task,
taskId: string, taskId: string,
useTransfer: boolean = false useTransfer: boolean = false
) { ): Promise<void> {
console.log(useTransfer) console.log(useTransfer)
while (true) { while (true) {
let fetchUrl = this.fetchTaskUrl.replace('${id}', taskId) let fetchUrl = this.fetchTaskUrl.replace('${id}', taskId)
@ -310,8 +430,9 @@ export class MJVideoService extends MJApiService {
taskMessage: JSON.stringify(resData) taskMessage: JSON.stringify(resData)
}) })
let mjId = resData.properties?.messageHash ?? resData.id
// 下载 视频 // 下载 视频
await this.DownloadMJVideo(videoMessage.videoUrls || [], task, bookTaskDetail) await this.DownloadMJVideo(videoMessage.videoUrls || [], task, bookTaskDetail, mjId)
SendReturnMessage( SendReturnMessage(
{ {
@ -365,7 +486,8 @@ export class MJVideoService extends MJApiService {
async DownloadMJVideo( async DownloadMJVideo(
videoUrls: string[], videoUrls: string[],
task: TaskModal.Task, task: TaskModal.Task,
bookTaskDetail: Book.SelectBookTaskDetail bookTaskDetail: Book.SelectBookTaskDetail,
preffix: string
) { ) {
// 处理完成 开始下载指定的图片 // 处理完成 开始下载指定的图片
@ -388,15 +510,36 @@ export class MJVideoService extends MJApiService {
bookTask.imageFolder as string, bookTask.imageFolder as string,
`video/subVideo/${bookTaskDetail.name}/${new Date().getTime()}_${i}.mp4` `video/subVideo/${bookTaskDetail.name}/${new Date().getTime()}_${i}.mp4`
) )
let remoteUrl = videoUrl
// 开始处理下载 mj 官方的图片不支持转存
if (global.machineId && !isEmpty(global.machineId) && !videoUrl.startsWith('https://cdn.midjourney.com')) {
// 转存一下视频文件
// 获取当前url的文件名
let fileName = preffix + "_" + path.basename(videoUrl)
let transferRes = await axios.post(define.lms_url + `/lms/FileUpload/UrlUpload/${global.machineId}`, {
url: videoUrl,
fileName: fileName
})
if (transferRes.status == 200 && transferRes.data.code == 1) {
remoteUrl = transferRes.data.data.url
}
}
if (isEmpty(remoteUrl)) {
remoteUrl = videoUrl
}
await CheckFolderExistsOrCreate(path.dirname(videoPath)) await CheckFolderExistsOrCreate(path.dirname(videoPath))
await DownloadFile(videoUrl, videoPath) await DownloadFile(remoteUrl, videoPath)
// 处理返回数据信息 // 处理返回数据信息
// 开始修改信息 // 开始修改信息
// 将信息添加到里面 // 将信息添加到里面
let a = { let a = {
localPath: path.relative(project_path, videoPath), localPath: path.relative(project_path, videoPath),
remotePath: videoUrl, remotePath: remoteUrl,
taskId: bookTaskDetail.videoMessage?.taskId, taskId: bookTaskDetail.videoMessage?.taskId,
index: i, index: i,
type: MappingTaskTypeToVideoModel(task.type as string) type: MappingTaskTypeToVideoModel(task.type as string)

View File

@ -58,6 +58,14 @@ export const bookImagePreload = {
DEFINE_STRING.BOOK.DOWNLOAD_IMAGE_URL_AND_SPLIT, DEFINE_STRING.BOOK.DOWNLOAD_IMAGE_URL_AND_SPLIT,
bookTaskDetailId, bookTaskDetailId,
imageUrl imageUrl
) ),
/** 同步主图文件到批次任务 */
SyncMainImageForBookTask: async (bookTaskId: string) =>
await ipcRenderer.invoke(DEFINE_STRING.BOOK.SYNC_MAIN_IMAGE_FOR_BOOK_TASK, bookTaskId),
/** 同步子图文件到批次任务 */
SyncSubImageForBookTask: async (bookTaskId: string) =>
await ipcRenderer.invoke(DEFINE_STRING.BOOK.SYNC_SUB_IMAGE_FOR_BOOK_TASK, bookTaskId)
// #endregion // #endregion
} }

View File

@ -46,6 +46,10 @@ const system = {
ReadTextFile: (filePath: string) => ReadTextFile: (filePath: string) =>
ipcRenderer.invoke(DEFINE_STRING.SYSTEM.READ_TEXT_FILE, filePath), ipcRenderer.invoke(DEFINE_STRING.SYSTEM.READ_TEXT_FILE, filePath),
/** 上传图片到LaiTool云端 */
UploadImageToLaiTool: (imagePath: string, type: "video" | "image") =>
ipcRenderer.invoke(DEFINE_STRING.SYSTEM.UPLOAD_IMAGE_TO_LAITOOL, imagePath, type),
//#endregion //#endregion
//#region 系统相关 //#region 系统相关

View File

@ -50,6 +50,7 @@ declare module 'vue' {
InputDialogContent: typeof import('./src/components/common/InputDialogContent.vue')['default'] InputDialogContent: typeof import('./src/components/common/InputDialogContent.vue')['default']
JianyingGenerateInformation: typeof import('./src/components/Original/BookTaskDetail/JianyingGenerateInformation.vue')['default'] JianyingGenerateInformation: typeof import('./src/components/Original/BookTaskDetail/JianyingGenerateInformation.vue')['default']
JianyingKeyFrameSetting: typeof import('./src/components/Setting/JianyingKeyFrameSetting.vue')['default'] JianyingKeyFrameSetting: typeof import('./src/components/Setting/JianyingKeyFrameSetting.vue')['default']
LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default']
LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default'] LoadingComponent: typeof import('./src/components/common/LoadingComponent.vue')['default']
ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.vue')['default'] ManageAISetting: typeof import('./src/components/CopyWriting/ManageAISetting.vue')['default']
MediaToVideoInfoBasicInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoBasicInfo.vue')['default'] MediaToVideoInfoBasicInfo: typeof import('./src/components/MediaToVideo/MediaToVideoInfo/MediaToVideoInfoBasicInfo.vue')['default']
@ -121,7 +122,6 @@ declare module 'vue' {
OpenInBrowserRound: typeof import('./src/components/common/Icon/OpenInBrowserRound.vue')['default'] OpenInBrowserRound: typeof import('./src/components/common/Icon/OpenInBrowserRound.vue')['default']
OriginalAddBook: typeof import('./src/components/Original/MainHome/OriginalAddBook.vue')['default'] OriginalAddBook: typeof import('./src/components/Original/MainHome/OriginalAddBook.vue')['default']
OriginalAddBookTask: typeof import('./src/components/Original/MainHome/OriginalAddBookTask.vue')['default'] OriginalAddBookTask: typeof import('./src/components/Original/MainHome/OriginalAddBookTask.vue')['default']
OriginalBookTaskCard: typeof import('./src/components/Original/MainHome/OriginalBookTaskCard.vue')['default']
OriginalEmptyState: typeof import('./src/components/Original/MainHome/OriginalEmptyState.vue')['default'] OriginalEmptyState: typeof import('./src/components/Original/MainHome/OriginalEmptyState.vue')['default']
OriginalMobileHeader: typeof import('./src/components/Original/MainHome/OriginalMobileHeader.vue')['default'] OriginalMobileHeader: typeof import('./src/components/Original/MainHome/OriginalMobileHeader.vue')['default']
OriginalModifyBookTask: typeof import('./src/components/Original/MainHome/OriginalModifyBookTask.vue')['default'] OriginalModifyBookTask: typeof import('./src/components/Original/MainHome/OriginalModifyBookTask.vue')['default']
@ -149,6 +149,7 @@ declare module 'vue' {
TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default'] TopMenuButtons: typeof import('./src/components/Original/BookTaskDetail/TopMenuButtons.vue')['default']
UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default'] UploadRound: typeof import('./src/components/common/Icon/UploadRound.vue')['default']
UserAnalysis: typeof import('./src/components/Original/Analysis/UserAnalysis.vue')['default'] UserAnalysis: typeof import('./src/components/Original/Analysis/UserAnalysis.vue')['default']
VideoDisplay: typeof import('./src/components/common/VideoDisplay.vue')['default']
WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default'] WechatGroup: typeof import('./src/components/SoftHome/WechatGroup.vue')['default']
WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default'] WordGroup: typeof import('./src/components/Original/Copywriter/WordGroup.vue')['default']
} }

View File

@ -2,7 +2,7 @@ import { t } from '@/i18n'
import { CloudUploadOutline } from '@vicons/ionicons5' import { CloudUploadOutline } from '@vicons/ionicons5'
// 工具分类 // 工具分类
export const categories = [ export const getCategories = () => [
{ key: 'media', label: t('媒体工具'), color: '#2080f0' } { key: 'media', label: t('媒体工具'), color: '#2080f0' }
// { key: 'document', label: '文档处理', color: '#18a058' }, // { key: 'document', label: '文档处理', color: '#18a058' },
// { key: 'development', label: '开发工具', color: '#f0a020' }, // { key: 'development', label: '开发工具', color: '#f0a020' },
@ -14,7 +14,7 @@ export const categories = [
] ]
// 工具数据 // 工具数据
export const toolsData = [ export const getToolsData = () => [
// 媒体工具 // 媒体工具
{ {
id: 'image-converter', id: 'image-converter',
@ -251,3 +251,7 @@ export const toolsData = [
// } // }
// } // }
] ]
// 兼容性:保持原有的导出名称,但使用函数
export const categories = getCategories()
export const toolsData = getToolsData()

View File

@ -54,7 +54,7 @@
<div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div> <div style="font-size: 14px; font-weight: 500">{{ subCategory.name }}</div>
</template> </template>
<div style="font-size: 12px; color: #909399; line-height: 1.4"> <div style="font-size: 12px; color: #909399; line-height: 1.4">
{{ subCategory.remark }} {{ subCategory.description }}
</div> </div>
</n-card> </n-card>
</div> </div>

View File

@ -132,6 +132,74 @@
</div> </div>
</n-form-item> </n-form-item>
<!-- 尾帧图片链接 -->
<n-form-item :label="t('尾帧图片链接(可选)')" :show-feedback="false">
<div class="input-with-preview">
<n-input
v-model:value="videoMessage.mjVideoOptionsObject.extendEndImageUrl"
:placeholder="t('请输入图片链接')"
@change="
handleVideoMessageChange(
'extendEndImageUrl',
videoMessage.mjVideoOptionsObject.extendEndImageUrl
)
"
size="small"
:disabled="loading"
class="image-input"
>
<template #suffix>
<n-button
size="tiny"
quaternary
@click="
handleUploadImage(
videoMessage.mjVideoOptionsObject.extendEndImageUrl,
'video',
'extendEndImageUrl'
)
"
:disabled="loading"
>
<template #icon>
<n-icon size="20">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
<path d="M7 9l5-5l5 5"></path>
<path d="M12 4v12"></path>
</g>
</svg>
</n-icon>
</template>
</n-button>
</template>
</n-input>
<n-image
v-if="videoMessage.mjVideoOptionsObject.extendEndImageUrl"
:src="videoMessage.mjVideoOptionsObject.extendEndImageUrl"
:height="60"
:fallback-src="'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwIiB5PSI1MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEyIiBmaWxsPSIjOTk5IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iMC4zZW0iPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD4KPC9zdmc+'"
object-fit="contain"
class="preview-image"
@error="handleImageError(videoMessage.mjVideoOptionsObject.extendEndImageUrl)"
/>
<div v-else class="preview-placeholder">
<n-text depth="3" class="placeholder-text">{{ t('图片预览') }}</n-text>
</div>
</div>
</n-form-item>
<!-- 提示词 --> <!-- 提示词 -->
<n-form-item :label="t('提示词(可选)')" :show-feedback="false"> <n-form-item :label="t('提示词(可选)')" :show-feedback="false">
<n-input <n-input
@ -398,6 +466,10 @@ import {
NSpace, NSpace,
NFormItem, NFormItem,
NTooltip, NTooltip,
NSwitch,
NAlert,
NImage,
NText,
useMessage useMessage
} from 'naive-ui' } from 'naive-ui'
@ -412,9 +484,15 @@ import { isEmpty } from 'lodash'
import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum' import { BookBackTaskType, TaskExecuteType } from '@/define/enum/bookEnum'
import { DEFINE_STRING } from '@/define/ipcDefineString' import { DEFINE_STRING } from '@/define/ipcDefineString'
import { t } from '@/i18n' import { t } from '@/i18n'
import { AddOneTask } from '@/renderer/src/common/task'
const message = useMessage() const message = useMessage()
//
function handleImageError(imageUrl) {
message.warning(t('图片加载失败,请检查图片链接是否有效!'))
}
// props // props
const props = defineProps({ const props = defineProps({
videoMessage: { videoMessage: {
@ -432,7 +510,7 @@ const props = defineProps({
}) })
// emits // emits
const emit = defineEmits(['video-message-change', 'extend', 'select-parent-task']) const emit = defineEmits(['video-message-change', 'extend', 'select-parent-task', 'image-upload'])
// videoMessage // videoMessage
function handleVideoMessageChange(key, value = undefined) { function handleVideoMessageChange(key, value = undefined) {
@ -456,14 +534,14 @@ async function handleExtend() {
let type = BookBackTaskType.MJ_VIDEO_EXTEND let type = BookBackTaskType.MJ_VIDEO_EXTEND
// //
let res = await window.task.AddBookBackTask( let res = await AddOneTask({
props.task.bookId, bookId: props.task.bookId,
type, type: type,
TaskExecuteType.AUTO, executeType: TaskExecuteType.AUTO,
props.task.bookTaskId, bookTaskId: props.task.bookTaskId,
props.task.id, bookTaskDetailId: props.task.id,
DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN messageName: DEFINE_STRING.BOOK.MJ_VIDEO_TO_VIDEO_RETURN
) })
if (res.code != 1) { if (res.code != 1) {
message.error(res.message) message.error(res.message)
return return
@ -480,9 +558,43 @@ function handleSelectParentTask() {
emit('select-parent-task') emit('select-parent-task')
} }
//
function handleUploadImage(currentImageUrl, type, property) {
emit('image-upload', currentImageUrl, type, property)
}
</script> </script>
<style scoped> <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 { .motion-control {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -10,7 +10,38 @@
size="small" size="small"
:disabled="loading" :disabled="loading"
class="image-input" class="image-input"
/> >
<template #suffix>
<n-button
size="tiny"
quaternary
@click="handleUploadImage(videoMessage.imageUrl, 'video', 'imageUrl')"
:disabled="loading"
>
<template #icon>
<n-icon size="20">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
<path d="M7 9l5-5l5 5"></path>
<path d="M12 4v12"></path>
</g>
</svg>
</n-icon>
</template>
</n-button>
</template>
</n-input>
<n-image <n-image
v-if="videoMessage.imageUrl" v-if="videoMessage.imageUrl"
:src="videoMessage.imageUrl" :src="videoMessage.imageUrl"
@ -38,7 +69,44 @@
size="small" size="small"
:disabled="loading" :disabled="loading"
class="image-input" class="image-input"
/> >
<template #suffix>
<n-button
size="tiny"
quaternary
@click="
handleUploadImage(
videoMessage.mjVideoOptionsObject.endImageUrl,
'video',
'endImageUrl'
)
"
:disabled="loading"
>
<template #icon>
<n-icon size="20">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
<path d="M7 9l5-5l5 5"></path>
<path d="M12 4v12"></path>
</g>
</svg>
</n-icon>
</template>
</n-button>
</template>
</n-input>
<n-image <n-image
v-if="videoMessage.mjVideoOptionsObject.endImageUrl" v-if="videoMessage.mjVideoOptionsObject.endImageUrl"
:src="videoMessage.mjVideoOptionsObject.endImageUrl" :src="videoMessage.mjVideoOptionsObject.endImageUrl"
@ -390,7 +458,7 @@ const props = defineProps({
}) })
// emits // emits
const emit = defineEmits(['video-message-change', 'image-to-video']) const emit = defineEmits(['video-message-change', 'image-to-video', 'image-upload'])
// //
function handleImageError(imageUrl) { function handleImageError(imageUrl) {
@ -403,6 +471,11 @@ function handleVideoMessageChange(key, value = undefined) {
emit('video-message-change', key, value) emit('video-message-change', key, value)
} }
//
async function handleUploadImage(filePath, type, property) {
emit('image-upload', filePath, type, property)
}
// //
function handleImageToVideo() { function handleImageToVideo() {
if (isEmpty(props.videoMessage.imageUrl)) { if (isEmpty(props.videoMessage.imageUrl)) {

View File

@ -8,18 +8,20 @@
:loading="loading" :loading="loading"
@video-message-change="handleVideoMessageChange" @video-message-change="handleVideoMessageChange"
@image-to-video="handleImageToVideo" @image-to-video="handleImageToVideo"
@image-upload="handleUploadImage"
/> />
</n-tab-pane> </n-tab-pane>
<!-- 视频拓展 Tab --> <!-- 视频拓展 Tab -->
<n-tab-pane name="video-extend" :tab="t('视频拓展')"> <n-tab-pane name="video-extend" :tab="t('视频拓展')">
<DisabledWrapper :un-use="true"> <DisabledWrapper :un-use="false">
<ImageTextVideoInfoMJVideoExtend <ImageTextVideoInfoMJVideoExtend
:video-message="videoMessage" :video-message="videoMessage"
:loading="loading" :loading="loading"
:task="props.task" :task="props.task"
@video-message-change="handleVideoMessageChange" @video-message-change="handleVideoMessageChange"
@select-parent-task="handleSelectParentTask" @select-parent-task="handleSelectParentTask"
@image-upload="handleUploadImage"
/> />
</DisabledWrapper> </DisabledWrapper>
</n-tab-pane> </n-tab-pane>
@ -59,8 +61,13 @@ import ImageTextVideoInfoMJVideoImageToVideo from './MediaToVideoInfoMJVideoImag
import ImageTextVideoInfoMJVideoExtend from './MediaToVideoInfoMJVideoExtend.vue' import ImageTextVideoInfoMJVideoExtend from './MediaToVideoInfoMJVideoExtend.vue'
import { AddOneTask } from '@/renderer/src/common/task' import { AddOneTask } from '@/renderer/src/common/task'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useSoftwareStore } from '@/renderer/src/stores'
import { useFile } from '@/renderer/src/hooks/useFile'
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const softwareStore = useSoftwareStore()
const { UploadImageToLaiTool } = useFile()
// props // props
const props = defineProps({ const props = defineProps({
@ -81,14 +88,21 @@ const videoMessage = computed(() => {
let mjVideoOptionsString = videoMessage.mjVideoOptions || '{}' let mjVideoOptionsString = videoMessage.mjVideoOptions || '{}'
let mjVideoOptions = ValidateJsonAndParse(mjVideoOptionsString) let mjVideoOptions = ValidateJsonAndParse(mjVideoOptionsString)
mjVideoOptions.image = videoMessage.imageUrl ?? '' // mjVideoOptions
if (mjVideoOptions.motion == undefined) { const cleanMjVideoOptions = {
mjVideoOptions.motion = MJVideoMotion.Low // taskId: mjVideoOptions.taskId || '',
index: mjVideoOptions.index || 0,
videoType: mjVideoOptions.videoType || '',
motion: mjVideoOptions.motion || MJVideoMotion.Low,
raw: mjVideoOptions.raw !== undefined ? mjVideoOptions.raw : true,
batchSize: mjVideoOptions.batchSize || 1,
image: videoMessage.imageUrl || mjVideoOptions.image || '',
endImageUrl: mjVideoOptions.endImageUrl || '',
extendEndImageUrl: mjVideoOptions.extendEndImageUrl || '',
loop: mjVideoOptions.loop !== undefined ? mjVideoOptions.loop : false
} }
if (mjVideoOptions.raw == undefined) {
mjVideoOptions.raw = true // videoMessage.mjVideoOptionsObject = cleanMjVideoOptions
}
videoMessage.mjVideoOptionsObject = mjVideoOptions
return videoMessage return videoMessage
}) })
@ -156,6 +170,12 @@ async function handleVideoMessageChange(key, value = undefined) {
endImageUrl: value endImageUrl: value
}) })
break break
case 'extendEndImageUrl':
updateObject.mjVideoOptions = JSON.stringify({
...videoMessage.value.mjVideoOptionsObject,
extendEndImageUrl: value
})
break
case 'prompt': case 'prompt':
updateObject.prompt = videoMessage.value.prompt updateObject.prompt = videoMessage.value.prompt
break break
@ -221,6 +241,49 @@ async function handleVideoMessageChange(key, value = undefined) {
return return
} }
} }
//
async function handleUploadImage(filePath, type, property) {
debugger
let da = dialog.warning({
title: t('操作确认'),
content: () =>
h(
'div',
{
style: { whiteSpace: 'pre-line' }
},
{
default: () =>
t(
'是否上传图片文件到LaiTool云端\n\n上传后会返回一个全球可分享的网络链接地址但是每日限制五十次上传。上传后的图片可用于MJ垫图转视频等功能。\n\n注意上传后的图片会再Laitool服务器留存若介意请勿上传。'
)
}
),
positiveText: t('继续'),
negativeText: t('取消'),
onPositiveClick: async () => {
da?.destroy()
try {
softwareStore.spin.spinning = true
softwareStore.spin.tip = t('开始上传文件...')
let url = await UploadImageToLaiTool(filePath, type)
if (property == 'endImageUrl' || property == 'extendEndImageUrl')
videoMessage.value.mjVideoOptionsObject[property] = url
else videoMessage.value[property] = url
await handleVideoMessageChange(property, url)
} catch (error) {
message.error(error.message)
} finally {
softwareStore.spin.spinning = false
}
},
onNegativeClick: () => {
message.info(t('取消操作'))
}
})
}
</script> </script>
<style scoped> <style scoped>

View File

@ -57,8 +57,6 @@ import {
NDataTable, NDataTable,
NSwitch, NSwitch,
NImage, NImage,
NInput,
NEmpty,
NSelect, NSelect,
useMessage, useMessage,
useDialog useDialog
@ -68,6 +66,7 @@ import { define } from '@/define/define'
import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue' import ImageTextVideoInfoVideoConfig from './MediaToVideoInfoVideoConfig.vue'
import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue' import ImageTextVideoInfoVideoListInfo from './MediaToVideoInfoVideoListInfo.vue'
import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue' import ImageTextVideoInfoTaskOptions from './MediaToVideoInfoTaskOptions.vue'
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
import { OptionKeyName, OptionType } from '@/define/enum/option' import { OptionKeyName, OptionType } from '@/define/enum/option'
import { useBookStore } from '@/renderer/src/stores' import { useBookStore } from '@/renderer/src/stores'
import { GetImageToVideoModelsOptions } from '@/define/enum/video' import { GetImageToVideoModelsOptions } from '@/define/enum/video'
@ -246,50 +245,6 @@ const columns = [
} }
} }
}, },
{
title: t('图片链接'),
key: 'image_url',
width: 150,
className: noPaddingColumnClass,
render(row) {
return h(
'div',
{
style: {
height: '130px',
width: '95%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px'
}
},
[
h(NInput, {
value: row.videoMessage?.imageUrl || '',
type: 'textarea',
placeholder: t('请输入图片链接...'),
resizable: false,
style: {
width: '100%',
height: '100%'
},
inputProps: {
style: {
height: '100%',
resize: 'none'
},
spellcheck: false
},
onUpdateValue: (value) => {
row.videoMessage.imageUrl = value
handleSaveBookTaskDetailVideoMessage(row, row.id, 'imageUrl', value)
}
})
]
)
}
},
{ {
title: () => title: () =>
h( h(
@ -337,50 +292,13 @@ const columns = [
width: 160, width: 160,
className: noPaddingColumnClass, className: noPaddingColumnClass,
render(row) { render(row) {
if (row.generateVideoPath) { return h(VideoDisplay, {
return h( videoPath: row.generateVideoPath,
'div', containerHeight: '130px',
{ containerWidth: '100%',
style: { autoSize: true,
height: '130px', emptyDescription: t('暂无选择视频')
display: 'flex', })
alignItems: 'center',
justifyContent: 'center'
}
},
[
h('video', {
src: row.generateVideoPath,
controls: true,
style: {
height: '130px',
maxWidth: '160px',
objectFit: 'cover',
borderRadius: '4px'
}
})
]
)
} else {
return h(
'div',
{
style: {
height: '130px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
},
[
h(NEmpty, {
description: t('暂无选择视频'),
size: 'small'
})
]
)
}
} }
}, },
{ {

View File

@ -22,12 +22,53 @@
<n-tag v-else type="primary" size="small" class="method-progress"> <n-tag v-else type="primary" size="small" class="method-progress">
{{ props.videoMessage?.status ?? 'wait' }} {{ props.videoMessage?.status ?? 'wait' }}
</n-tag> </n-tag>
<n-tooltip :show-arrow="false" trigger="hover">
<template #trigger> <n-text
<span class="task-id-span">{{ props.videoMessage?.taskId ?? '' }}</span> v-if="progress"
</template> code
{{ props.videoMessage?.taskId ?? '' }} :style="{
</n-tooltip> fontSize: '14px'
}"
>{{ progress }}
</n-text>
<div v-if="props.videoMessage?.taskId" class="task-id-container">
<n-tooltip>
<template #trigger>
<n-text
code
:style="{
fontSize: '14px',
maxWidth: '150px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer'
}"
>{{ props.videoMessage?.taskId }}
</n-text>
</template>
<span>{{ props.videoMessage?.taskId }}</span>
</n-tooltip>
<n-button
size="tiny"
type="primary"
ghost
@click="CopyData(props.videoMessage?.taskId)"
class="copy-button"
>
<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>
</div> </div>
@ -47,9 +88,13 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { NSelect, NInput, NTag, NTooltip } from 'naive-ui' import { NSelect, NInput, NTag, NTooltip, NText, NButton, NIcon, useMessage } from 'naive-ui'
import { GetImageToVideoModelsOptions } from '@/define/enum/video' import { GetImageToVideoModelsOptions, ImageToVideoModels } from '@/define/enum/video'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useSimple } from '@/renderer/src/hooks/useSimple'
const message = useMessage()
const { CopyData } = useSimple()
// emits // emits
const emit = defineEmits(['update-method', 'update-prompt']) const emit = defineEmits(['update-method', 'update-prompt'])
@ -69,6 +114,19 @@ const props = defineProps({
// //
const videoMethodOptions = GetImageToVideoModelsOptions() const videoMethodOptions = GetImageToVideoModelsOptions()
const progress = computed(() => {
let p = '0%'
let messageData = props.videoMessage?.messageData || '{}'
let messageObject = JSON.parse(messageData)
let videoType = props.videoMessage?.videoType || undefined
if (videoType == ImageToVideoModels.MJ_VIDEO) {
p = messageObject.progress ?? '0%'
} else {
p = '0%'
}
return p
})
// //
function handleMethodChange(value) { function handleMethodChange(value) {
emit('update-method', props.videoMessage?.bookTaskDetailId, value) emit('update-method', props.videoMessage?.bookTaskDetailId, value)
@ -140,17 +198,19 @@ onMounted(() => {})
resize: none; resize: none;
} }
.task-id-span { /* 任务ID容器样式 */
display: inline-block; .task-id-container {
max-width: 80px; display: flex;
overflow: hidden; align-items: center;
text-overflow: ellipsis; gap: 8px;
white-space: nowrap; height: 100%;
padding: 2px 6px; }
background-color: var(--primary-color-suppl);
color: var(--primary-color); /* 复制按钮样式 */
border-radius: 3px; .copy-button {
font-size: 12px; padding: 4px !important;
cursor: pointer; min-width: auto !important;
width: 24px !important;
height: 24px !important;
} }
</style> </style>

View File

@ -403,7 +403,7 @@ async function copyTaskId() {
try { try {
const taskId = videoMessage.value.taskId const taskId = videoMessage.value.taskId
if (!taskId) { if (!taskId) {
message.warning(t('task_id_is_empty')) message.warning(t('任务ID不能为空请检查'))
return return
} }

View File

@ -8,19 +8,20 @@
:loading="loading" :loading="loading"
:single-line="false" :single-line="false"
:row-key="rowKey" :row-key="rowKey"
:scroll-x="1600" :scroll-x="bookStore.selectBookTask.openVideoGenerate ? 1700 : 1600"
:max-height="tableHeight" :max-height="tableHeight"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { useBookStore, usePresetStore, useSoftwareStore } from '@/renderer/src/stores' import { useBookStore, useSoftwareStore } from '@/renderer/src/stores'
import DatatableAfterGpt from './DatatableAfterGpt.vue' import DatatableAfterGpt from './DatatableAfterGpt.vue'
import DatatableCharacterAndSceneAndStyle from './DatatableCharacterAndSceneAndStyle.vue' import DatatableCharacterAndSceneAndStyle from './DatatableCharacterAndSceneAndStyle.vue'
import DataTableGptPrompt from './DataTableGptPrompt.vue' import DataTableGptPrompt from './DataTableGptPrompt.vue'
import { DEFINE_STRING } from '@/define/ipcDefineString' import { DEFINE_STRING } from '@/define/ipcDefineString'
import DatatableGenerateImage from './DatatableGenerateImage.vue' import DatatableGenerateImage from './DatatableGenerateImage.vue'
import VideoDisplay from '@/renderer/src/components/common/VideoDisplay.vue'
import DataTableAction from './DataTableAction.vue' import DataTableAction from './DataTableAction.vue'
import DatatableHeaderImage from './DatatableHeaderImage.vue' import DatatableHeaderImage from './DatatableHeaderImage.vue'
@ -124,7 +125,17 @@ const columns = computed(() => {
key: 'generateVideoPath', key: 'generateVideoPath',
className: 'empty-margin', className: 'empty-margin',
width: '200', width: '200',
minWidth: bookStore.selectBookTask.openVideoGenerate ? 130 : 0 minWidth: bookStore.selectBookTask.openVideoGenerate ? 130 : 0,
render(row, index) {
return h(VideoDisplay, {
videoPath: row.generateVideoPath,
containerHeight: softwareStore.originalDatatableHeight + 'px',
containerWidth: '100%',
autoSize: true,
emptyDescription: t('暂无视频'),
emptySize: 'small'
})
}
}, },
{ {
// //

View File

@ -9,7 +9,7 @@
:options="getImageCategoryOptions()" :options="getImageCategoryOptions()"
/> />
<n-dropdown <TooltipDropdown
trigger="click" trigger="click"
:options="blockOptions" :options="blockOptions"
@select="dropdownSelectHandle" @select="dropdownSelectHandle"
@ -24,12 +24,12 @@
</template> </template>
{{ t('更多操作') }} {{ t('更多操作') }}
</n-button> </n-button>
</n-dropdown> </TooltipDropdown>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, h } from 'vue'
import { useMessage, useDialog, NIcon, NSelect, NButton, NDropdown, NDataTable } from 'naive-ui' import { useMessage, useDialog, NIcon, NSelect, NButton, NDropdown, NDataTable } from 'naive-ui'
import { ChevronDown, ChevronUp } from '@vicons/ionicons5' import { ChevronDown, ChevronUp } from '@vicons/ionicons5'
@ -39,7 +39,7 @@ import { isEmpty } from 'lodash'
import { useMD } from '@/renderer/src/hooks/useMD' import { useMD } from '@/renderer/src/hooks/useMD'
import { t } from '@/i18n' import { t } from '@/i18n'
const { showErrorDialog } = useMD() const { showErrorDialog, showSuccessDialog } = useMD()
let props = defineProps({ let props = defineProps({
style: undefined style: undefined
@ -84,6 +84,98 @@ async function handleUpdateValue(value) {
message.success(t('修改成功')) message.success(t('修改成功'))
} }
//
async function handleSyncMainImage() {
let da = dialog.warning({
title: t('操作提示'),
content: () =>
h(
'div',
{
style: { whiteSpace: 'pre-line' }
},
t(
'检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!\n\n 注意:该操作不会覆盖已经有主图的分镜!\n\n 该操作适用于手动将图片放入输出文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹'
)
),
positiveText: t('继续'),
negativeText: t('取消'),
onPositiveClick: async () => {
try {
da?.destroy()
softwareStore.spin.spinning = true
softwareStore.spin.tip = t('正在同步主图信息,请稍后...')
let res = await window.book.SyncMainImageForBookTask(bookStore.selectBookTask.id)
console.log('同步主图结果', res)
if (res.code != 1) {
throw new Error(res.message)
}
//
for (let i = 0; i < res.data.length; i++) {
const element = res.data[i]
let findIndex = bookStore.selectBookTaskDetail.findIndex((item) => item.id == element.id)
if (findIndex != -1) {
bookStore.selectBookTaskDetail[findIndex].outImagePath = element.outImagePath
}
}
showSuccessDialog(t('成功'), res.message)
} catch (error) {
showErrorDialog(t('失败'), t(error.message))
} finally {
softwareStore.spin.spinning = false
}
}
})
}
//
async function handleSyncSubMainImage() {
let da = dialog.warning({
title: t('操作提示'),
content: () =>
h(
'div',
{
style: { whiteSpace: 'pre-line' }
},
t(
'检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!\n\n 注意:该操作不会删除分镜中已经有的子图信息!而是根据文件中的图片信息添加到分镜的子图数据中\n\n 该操作适用于手动将子图放入子图文件夹后,同步到分镜中!点击右侧"打开文件夹"打开当前批次的图片输出文件夹'
)
),
positiveText: t('继续'),
negativeText: t('取消'),
onPositiveClick: async () => {
try {
da?.destroy()
softwareStore.spin.spinning = true
softwareStore.spin.tip = t('正在同步子图信息,请稍后...')
let res = await window.book.SyncSubImageForBookTask(bookStore.selectBookTask.id)
console.log('同步子图结果', res)
if (res.code != 1) {
throw new Error(res.message)
}
//
for (let i = 0; i < res.data.length; i++) {
const element = res.data[i]
let findIndex = bookStore.selectBookTaskDetail.findIndex((item) => item.id == element.id)
if (findIndex != -1) {
bookStore.selectBookTaskDetail[findIndex].subImagePath = [...element.subImagePath]
}
}
showSuccessDialog(t('成功'), res.message)
} catch (error) {
showErrorDialog(t('失败'), t(error.message))
} finally {
softwareStore.spin.spinning = false
}
}
})
}
// //
async function handleImageLockOperation(type) { async function handleImageLockOperation(type) {
let title = type == 'lock' ? t('一键锁定图片提示') : t('一键解锁图片提示') let title = type == 'lock' ? t('一键锁定图片提示') : t('一键解锁图片提示')
@ -156,6 +248,13 @@ async function dropdownSelectHandle(key) {
case 'oneToFour': case 'oneToFour':
await OneToFourBookTask() await OneToFourBookTask()
break break
case 'syncMainImage':
await handleSyncMainImage()
break
case 'syncSubImage':
await handleSyncSubMainImage()
break
default: default:
message.error(t('未知操作')) message.error(t('未知操作'))
break break
@ -260,6 +359,28 @@ async function OneToFourBookTask() {
// //
const blockOptions = computed(() => { const blockOptions = computed(() => {
const baseOptions = [ const baseOptions = [
{
label: t('同步主图文件'),
tooltip: t(
'检查所有的分镜信息,检查没有主图的分镜,判断对应的图片输出文件夹中是否有和分镜同名的图片,若有,则同步到对应分镜的主图中,若没有,则跳过!'
),
key: 'syncMainImage'
},
{
type: 'divider',
key: 'd1'
},
{
label: t('同步子图文件'),
key: 'syncSubImage',
tooltip: t(
'检查所有的分镜信息,判断分镜的子图文件夹中的图片是否都在分镜的子图列表中,若没有,则添加进去!'
)
},
{
type: 'divider',
key: 'd1'
},
{ {
label: t('一键锁定'), label: t('一键锁定'),
key: 'lock' key: 'lock'

View File

@ -1,27 +1,27 @@
<template> <template>
<div style="display: flex; align-items: center; font-size: 12px; font-weight: bold"> <div style="display: flex; align-items: center; font-size: 12px; font-weight: bold">
<n-space> <n-space>
<div style="display: flex; align-items: center"> <n-space align="center" size="small">
<div>{{ t('音频文件') }}</div> <div>{{ t('音频文件') }}</div>
<span class="clickable-link" @click="OpenFile(audioPath)">{{ <span class="clickable-link" @click="OpenFile(audioPath)">{{
getBasename(audioPath) getBasename(audioPath)
}}</span> }}</span>
</div> </n-space>
<div style="display: flex; align-items: center"> <n-space align="center" size="small">
<div>{{ t('SRT文件') }}</div> <div>{{ t('SRT文件') }}</div>
<span class="clickable-link" @click="OpenFile(srtPath)">{{ getBasename(srtPath) }}</span> <span class="clickable-link" @click="OpenFile(srtPath)">{{ getBasename(srtPath) }}</span>
</div> </n-space>
<div style="display: flex; align-items: center"> <n-space align="center" size="small">
<div>{{ t('出图文件夹') }}</div> <div>{{ t('出图文件夹') }}</div>
<span class="clickable-link" @click="OpenFolder(imageFolderPath)">{{ <span class="clickable-link" @click="OpenFolder(imageFolderPath)">{{
getBasename(imageFolderPath) getBasename(imageFolderPath)
}}</span> }}</span>
</div> </n-space>
<!-- 推理提示词进度 --> <!-- 推理提示词进度 -->
<div style="display: flex; align-items: center"> <n-space align="center" size="small">
<div>{{ t('提示词') }}</div> <div>{{ t('提示词') }}</div>
<n-progress <n-progress
type="line" type="line"
@ -31,11 +31,12 @@
@click="ErrorPosition('gptPrompt')" @click="ErrorPosition('gptPrompt')"
rail-color="#bbb" rail-color="#bbb"
/> />
</div> </n-space>
<!-- 反推提示词进度 --> <!-- 反推提示词进度 -->
<div <n-space
style="display: flex; align-items: center" align="center"
size="small"
v-if=" v-if="
bookStore.selectBook.type == 'mj_reverse' || bookStore.selectBook.type == 'sd_reverse' bookStore.selectBook.type == 'mj_reverse' || bookStore.selectBook.type == 'sd_reverse'
" "
@ -50,11 +51,11 @@
@click="ErrorPosition('reverseGptPrompt')" @click="ErrorPosition('reverseGptPrompt')"
rail-color="#bbb" rail-color="#bbb"
/> />
</div> </n-space>
<!-- 出图进度 --> <!-- 出图进度 -->
<div style="display: flex; align-items: center"> <n-space align="center" size="small">
<div style="display: flex; align-items: center"> <n-space align="center">
<div>{{ t('出图') }}</div> <div>{{ t('出图') }}</div>
<n-progress <n-progress
type="line" type="line"
@ -64,15 +65,15 @@
@click="ErrorPosition('image')" @click="ErrorPosition('image')"
rail-color="#bbb" rail-color="#bbb"
/> />
</div> </n-space>
</div> </n-space>
</n-space> </n-space>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { NProgress, useMessage } from 'naive-ui' import { NProgress, NSpace, useMessage } from 'naive-ui'
import { useSoftwareStore, useBookStore, useThemeStore } from '@/renderer/src/stores' import { useSoftwareStore, useBookStore, useThemeStore } from '@/renderer/src/stores'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { getBasename } from '@/renderer/src/common/file' import { getBasename } from '@/renderer/src/common/file'

View File

@ -1,249 +0,0 @@
<template>
<div class="subprojects-cards">
<div
v-for="subproject in bookStore.selectBook.children"
:key="subproject.id"
class="subproject-card-wrapper"
>
<div class="subproject-card">
<div class="subproject-content">
<div class="subproject-left">
<div
:style="{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
justifyItems: 'center'
}"
>
<div>
<h5 class="subproject-name" :title="subproject.name">{{ subproject.name }}</h5>
</div>
<div class="subproject-actions">
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-button
size="small"
quaternary
circle
@click.stop="enterBookTaskDetail(subproject)"
>
<n-icon :size="20"><Enter /></n-icon>
</n-button>
</template>
{{ t('进入小说批次信息') }}
</n-tooltip>
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-button
size="small"
type="error"
quaternary
circle
@click="deleteProject(subproject)"
>
<n-icon :size="20"><Trash /></n-icon>
</n-button>
</template>
{{ t('删除子项目') }}
</n-tooltip>
</div>
</div>
<div class="subproject-tags">
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-tag size="small" :type="getImageCategoryLabel(subproject.imageCategory).type">
{{ getImageCategoryLabel(subproject.imageCategory).label }}
</n-tag>
</template>
{{ t('批次出图方式') }}
</n-tooltip>
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-tag size="small" :type="GetBookBackTaskStatusLabel(subproject.status).type">
{{ GetBookBackTaskStatusLabel(subproject.status).label }}
</n-tag>
</template>
{{ t('状态') }}
</n-tooltip>
<n-tooltip trigger="hover" placement="top">
<template #trigger>
<n-tag size="small" type="info">
{{ getName(subproject.name) }}
</n-tag>
</template>
{{ t('批次号') }}
</n-tooltip>
</div>
<div class="subproject-date">
{{
t('创建时间 {time}', {
time: formatDateToString(subproject.createTime)
})
}}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getImageCategoryLabel } from '@/define/data/imageData'
import { GetBookBackTaskStatusLabel } from '@/define/enum/bookEnum'
import { formatDateToString } from '@/renderer/src/common/time'
import { toRGBA } from '@/renderer/src/common/color'
import {
useBookStore,
useThemeStore,
useSoftwareStore,
usePresetStore
} from '@/renderer/src/stores'
import { Trash, Enter } from '@vicons/ionicons5'
import { useRouter } from 'vue-router'
import { getShowTagsData } from '@/renderer/src/common/book'
import { isEmpty } from 'lodash'
import { t } from '@/i18n'
const bookStore = useBookStore()
const themeStore = useThemeStore()
const softwareStore = useSoftwareStore()
const presetStore = usePresetStore()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const emit = defineEmits(['update:bookTask'])
function getName(name) {
if (!isEmpty(name)) {
let sp = name.split('_')
return sp[sp.length - 1]
} else {
return t('未命名')
}
}
//
async function enterBookTaskDetail(subproject) {
softwareStore.spin.spinning = true
softwareStore.spin.text = t('正在加载小说批次信息...')
await new Promise((resolve) => setTimeout(resolve, 1000))
//
let res = await window.book.GetBookTaskDetailDataByCondition({
bookTaskId: subproject.id
})
if (res.code != 1) {
message.error(res.message)
softwareStore.spin.spinning = false
return
}
console.log('小说批次信息', res.data)
bookStore.selectBookTaskDetail = res.data
bookStore.selectBookTask = { ...subproject }
//
let tagRes = await getShowTagsData({
isShow: true
})
//
presetStore.showCharacterPresetArray = tagRes.character
presetStore.showScenePresetArray = tagRes.scene
presetStore.showStylePresetArray = tagRes.style
router.push('/original-book-detail/' + subproject.id)
}
async function deleteProject(subproject) {
dialog.warning({
title: t('操作确认'),
positiveText: t('确定'),
negativeText: t('取消'),
content: t(
'确定要删除小说任务吗?该操作不可逆,会将对应的子任务数据和分镜数据全部删除,请谨慎操作!'
),
onPositiveClick: async () => {
let res = await window.book.DeleteBookTaskByIds([subproject.id])
console.log('删除小说任务', res)
if (res.code == 1) {
//
emit('update:bookTask', res.data)
//
message.success(t('删除小说任务并刷新数据成功'))
} else {
message.error(res.message)
}
}
})
}
</script>
<style scoped>
/* 子项目卡片列表 */
.subprojects-cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
}
/* 子项目卡片包装器 */
.subproject-card-wrapper {
width: 250px;
flex-grow: 0;
flex-shrink: 0;
}
/* 子项目卡片 */
.subproject-card {
padding: 14px;
border-radius: 8px;
background-color: v-bind('toRGBA(themeStore.menuPrimaryColor, 0.6)');
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.subproject-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.subproject-content {
display: flex;
justify-content: space-between;
}
.subproject-left {
flex: 1;
min-width: 0;
}
.subproject-name {
font-size: 14px;
font-weight: 500;
margin: 0 0 6px 0;
white-space: nowrap; /* 防止文本换行 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
max-width: 120px; /* 设置最大宽度,根据实际需求调整 */
}
.subproject-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.subproject-date {
font-size: 12px;
}
.subproject-actions {
display: flex;
gap: 4px;
align-items: flex-start;
}
</style>

View File

@ -2,7 +2,7 @@
<n-card <n-card
class="task-card" class="task-card"
hoverable hoverable
@dblclick="handleOpenTask" @dblclick="handleDBOpenTask"
:header-style="{ :header-style="{
padding: '12px 16px', padding: '12px 16px',
background: themeStore.menuPrimaryShadow, background: themeStore.menuPrimaryShadow,
@ -16,7 +16,6 @@
<template #header> <template #header>
<n-space justify="space-between" align="center"> <n-space justify="space-between" align="center">
<!-- 左侧TASK图标 + 任务名称 --> <!-- 左侧TASK图标 + 任务名称 -->
<div class="task-icon"> <div class="task-icon">
<n-icon <n-icon
size="24" size="24"
@ -151,9 +150,13 @@
</template> </template>
<template #default> <template #default>
{{ {{
t('分镜出图的进度:{progress}% ', { props.type == 'mediaToVideo'
progress: progress ? t('视频生成的进度:{progress}% ', {
}) progress: progress
})
: t('分镜出图的进度:{progress}% ', {
progress: progress
})
}} }}
</template> </template>
</n-tooltip> </n-tooltip>
@ -277,7 +280,9 @@ function handleButtonClick(event, action, ...args) {
// //
const progress = computed(() => { const progress = computed(() => {
// //
return Math.floor(props.bookTask?.imageVideoProgress?.imageRate ?? 0) // return props.type == 'mediaToVideo'
? Math.floor(props.bookTask?.imageVideoProgress?.videoRate ?? 0)
: Math.floor(props.bookTask?.imageVideoProgress?.imageRate ?? 0)
}) })
// //
@ -306,9 +311,23 @@ async function handleTaskAction(key) {
break break
} }
} }
async function handleDBOpenTask() {
switch (props.type) {
case 'origin':
handleOpenTask()
break
case 'mediaToVideo':
handleOpenVideoInfo()
break
default:
message.error(t('未知的任务类型'))
break
}
}
// //
async function handleOpenTask() { async function handleOpenTask() {
console.log('打开任务', props.bookTask, props.type)
// //
if (isActionClicked.value) { if (isActionClicked.value) {
return return

View File

@ -10,12 +10,7 @@
> >
{{ displayText }} {{ displayText }}
</n-tag> </n-tag>
<n-text <n-text v-else v-bind="componentProps" :class="[customClass]" :style="ellipsisStyle">
v-else
v-bind="componentProps"
:class="[customClass]"
:style="ellipsisStyle"
>
{{ displayText }} {{ displayText }}
</n-text> </n-text>
</template> </template>
@ -23,7 +18,12 @@
<!-- 正常的省略显示模式 --> <!-- 正常的省略显示模式 -->
<template v-else> <template v-else>
<!-- 如果内容超出限制则显示 tooltip --> <!-- 如果内容超出限制则显示 tooltip -->
<n-tooltip v-if="isOverflow" :show-arrow="showArrow" :trigger="trigger" :placement="placement"> <n-tooltip
v-if="isOverflow"
:show-arrow="showArrow"
:trigger="trigger"
:placement="placement"
>
<template #trigger> <template #trigger>
<n-tag <n-tag
v-if="component === 'n-tag'" v-if="component === 'n-tag'"
@ -290,28 +290,37 @@ defineExpose({
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
vertical-align: middle; vertical-align: middle;
min-height: 28px;
} }
.text-ellipsis-content { .text-ellipsis-content {
cursor: default; cursor: default;
display: inline-block; display: inline-flex;
align-items: center;
vertical-align: middle; vertical-align: middle;
max-width: inherit; max-width: inherit;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-height: 24px;
padding: 2px 0;
} }
.text-ellipsis-content:deep(.n-tag) { .text-ellipsis-content:deep(.n-tag) {
display: inline-flex; display: inline-flex !important;
align-items: center; align-items: center !important;
min-height: 24px !important;
} }
.text-ellipsis-content:deep(.n-tag__content) { .text-ellipsis-content:deep(.n-tag__content) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
display: inline-block; display: flex !important;
align-items: center !important;
line-height: 1.4 !important;
min-height: 20px !important;
padding: 2px 6px !important;
} }
.text-ellipsis-content:deep(.n-text) { .text-ellipsis-content:deep(.n-text) {
@ -319,8 +328,10 @@ defineExpose({
text-overflow: ellipsis !important; text-overflow: ellipsis !important;
white-space: nowrap !important; white-space: nowrap !important;
max-width: inherit !important; max-width: inherit !important;
display: inline-block !important; display: inline-flex !important;
align-items: center !important;
vertical-align: middle; vertical-align: middle;
padding: 2px 6px !important;
} }
.tooltip-content { .tooltip-content {
@ -335,5 +346,6 @@ defineExpose({
max-width: inherit; max-width: inherit;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px;
} }
</style> </style>

View File

@ -0,0 +1,173 @@
<template>
<div
class="video-display-container"
:style="{
height: containerHeight,
width: containerWidth || '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
}"
>
<video
v-if="videoPath"
ref="videoRef"
:src="videoPath"
controls
:style="videoStyle"
@loadedmetadata="handleVideoLoaded"
/>
<n-empty v-else :description="emptyDescription" :size="emptySize" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { NEmpty } from 'naive-ui'
import { t } from '@/i18n'
// props
const props = defineProps({
//
videoPath: {
type: String,
default: ''
},
//
containerHeight: {
type: String,
default: '130px'
},
//
containerWidth: {
type: String,
default: ''
},
// -
autoSize: {
type: Boolean,
default: true
},
// autoSizefalse
videoWidth: {
type: String,
default: ''
},
// autoSizefalse
videoHeight: {
type: String,
default: ''
},
//
maxWidth: {
type: String,
default: '160px'
},
//
maxHeight: {
type: String,
default: ''
},
//
objectFit: {
type: String,
default: 'cover', // 使cover
validator: (value) => ['fill', 'contain', 'cover', 'none', 'scale-down'].includes(value)
},
//
borderRadius: {
type: String,
default: '4px'
},
//
emptyDescription: {
type: String,
default: () => t('暂无选择视频')
},
//
emptySize: {
type: String,
default: 'small'
}
})
const videoRef = ref(null)
const videoNaturalWidth = ref(0)
const videoNaturalHeight = ref(0)
const isVideoLoaded = ref(false)
//
const handleVideoLoaded = () => {
if (videoRef.value) {
videoNaturalWidth.value = videoRef.value.videoWidth
videoNaturalHeight.value = videoRef.value.videoHeight
isVideoLoaded.value = true
}
}
//
const videoStyle = computed(() => {
if (!props.autoSize) {
// 使
return {
width: props.videoWidth || 'auto',
height: props.videoHeight || props.containerHeight,
objectFit: props.objectFit,
borderRadius: props.borderRadius
}
}
//
if (!isVideoLoaded.value || !videoNaturalWidth.value || !videoNaturalHeight.value) {
//
return {
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: props.borderRadius
}
}
// px
const containerWidthNum = parseFloat(props.containerWidth || '100')
const containerHeightNum = parseFloat(props.containerHeight || '130')
//
const videoAspectRatio = videoNaturalWidth.value / videoNaturalHeight.value
const containerAspectRatio = containerWidthNum / containerHeightNum
let finalWidth, finalHeight
if (videoAspectRatio > containerAspectRatio) {
//
finalWidth = '100%'
finalHeight = 'auto'
} else {
//
finalWidth = 'auto'
finalHeight = '100%'
}
return {
width: finalWidth,
height: finalHeight,
objectFit: 'contain', //
borderRadius: props.borderRadius
}
})
</script>
<style scoped>
.video-display-container {
overflow: hidden;
}
.video-display-container video {
transition: all 0.2s ease;
}
.video-display-container video:hover {
transform: scale(1.02);
}
</style>

View File

@ -0,0 +1,23 @@
import { useMessage } from 'naive-ui'
export function useFile() {
const message = useMessage()
async function UploadImageToLaiTool(imagePath: string, type: "video" | "image") {
// 开始上传
let res = await window.system.UploadImageToLaiTool(imagePath, type)
if (res.code != 1) {
message.error(res.message)
return;
}
let url = res.data.url;
return url;
}
return {
UploadImageToLaiTool
}
}

View File

@ -3,7 +3,12 @@ import { t } from "@/i18n"
export function useMD() { export function useMD() {
let dialog = useDialog() let dialog = useDialog()
/** 调用一个错误的dialog */ /**
* 显示错误对话框
* @param {string} title - 对话框标题
* @param {string} content - 对话框内容
* @description 调用一个错误类型的对话框用于显示错误信息或警告用户操作失败
*/
function showErrorDialog(title, content) { function showErrorDialog(title, content) {
dialog.error({ dialog.error({
title: title, title: title,
@ -13,7 +18,12 @@ export function useMD() {
}) })
} }
/** 调用一个成功的dialog */ /**
* 显示成功对话框
* @param {string} title - 对话框标题
* @param {string} content - 对话框内容
* @description 调用一个成功类型的对话框用于显示操作成功信息或确认用户操作完成
*/
function showSuccessDialog(title, content) { function showSuccessDialog(title, content) {
dialog.success({ dialog.success({
title: title, title: title,

View File

@ -0,0 +1,30 @@
import { t } from "@/i18n"
import { isEmpty } from "lodash"
export function useSimple() {
const message = useMessage()
// 复制任务ID
async function CopyData(data: string) {
try {
if (!data || isEmpty(data)) {
message.warning(t("复制失败,复制内容为空"))
return
}
await navigator.clipboard.writeText(data)
message.success(t('复制成功'))
} catch (err) {
message.error(
t('复制失败:{error}', {
error: (err as Error).message
})
)
}
}
return {
CopyData
}
}

View File

@ -1,6 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue' import Home from '../views/Home.vue'
import { toolsData } from '@renderer/common/toolData' import { getToolsData } from '@renderer/common/toolData'
import { t } from '@/i18n' import { t } from '@/i18n'
// 创建404页面组件(可以是单独文件,这里用组件选项简化) // 创建404页面组件(可以是单独文件,这里用组件选项简化)
@ -79,7 +79,7 @@ const dynamicRoutes = () => {
let defaultRoutes = routes let defaultRoutes = routes
// 检查tooldata添加路由 // 检查tooldata添加路由
toolsData.forEach((tool) => { getToolsData().forEach((tool) => {
if (tool.action?.type == 'route') { if (tool.action?.type == 'route') {
const route = { const route = {
path: tool.action.route, path: tool.action.route,

View File

View File

@ -230,9 +230,6 @@ async function InitServerGptOptions() {
// //
for (let i = 0; i < prompt.data.data.length; i++) { for (let i = 0; i < prompt.data.data.length; i++) {
const element = prompt.data.data[i] const element = prompt.data.data[i]
if (!element.remark) {
element.remark = ''
}
let findIndex = pc.findIndex((item) => item.id == element.promptTypeId) let findIndex = pc.findIndex((item) => item.id == element.promptTypeId)
if (findIndex != -1) { if (findIndex != -1) {

View File

@ -51,7 +51,7 @@
<ToolGrid :tools="filteredTools" @tool-click="handleToolClick" /> <ToolGrid :tools="filteredTools" @tool-click="handleToolClick" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-for="category in categories" v-for="category in getCategories()"
:key="category.key" :key="category.key"
:name="category.key" :name="category.key"
:tab="category.label" :tab="category.label"
@ -65,7 +65,7 @@
<div class="stats-section"> <div class="stats-section">
<n-space> <n-space>
<n-statistic :label="t('工具总数')" :value="totalTools" /> <n-statistic :label="t('工具总数')" :value="totalTools" />
<n-statistic :label="t('分类数量')" :value="categories.length" /> <n-statistic :label="t('分类数量')" :value="getCategories().length" />
</n-space> </n-space>
</div> </div>
</div> </div>
@ -76,7 +76,7 @@ import { ref, computed, onMounted } from 'vue'
import { NInput, NSelect, NButton, NSpace, NTabs, NTabPane, NStatistic, NIcon } from 'naive-ui' import { NInput, NSelect, NButton, NSpace, NTabs, NTabPane, NStatistic, NIcon } from 'naive-ui'
import { SearchOutline, RefreshOutline } from '@vicons/ionicons5' import { SearchOutline, RefreshOutline } from '@vicons/ionicons5'
import ToolGrid from '@/renderer/src/components/ToolBox/ToolGrid.vue' import ToolGrid from '@/renderer/src/components/ToolBox/ToolGrid.vue'
import { toolsData, categories } from '@/renderer/src/common/toolData' import { getToolsData, getCategories } from '@/renderer/src/common/toolData'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { t } from '@/i18n' import { t } from '@/i18n'
@ -91,19 +91,19 @@ let router = useRouter()
// //
const categoryOptions = computed(() => const categoryOptions = computed(() =>
categories.map((cat) => ({ label: cat.label, value: cat.key })) getToolsData().map((cat) => ({ label: cat.label, value: cat.key }))
) )
const tagOptions = computed(() => { const tagOptions = computed(() => {
const allTags = new Set() const allTags = new Set()
toolsData.forEach((tool) => { getCategories().forEach((tool) => {
tool.tags?.forEach((tag) => allTags.add(tag)) tool.tags?.forEach((tag) => allTags.add(tag))
}) })
return Array.from(allTags).map((tag) => ({ label: tag, value: tag })) return Array.from(allTags).map((tag) => ({ label: tag, value: tag }))
}) })
const filteredTools = computed(() => { const filteredTools = computed(() => {
let tools = toolsData let tools = getToolsData()
// //
if (searchKeyword.value) { if (searchKeyword.value) {
@ -134,11 +134,11 @@ const filteredTools = computed(() => {
return tools return tools
}) })
const totalTools = computed(() => toolsData.length) const totalTools = computed(() => getToolsData().length)
// //
function getToolsByCategory(categoryKey) { function getToolsByCategory(categoryKey) {
return toolsData.filter((tool) => tool.category === categoryKey) return getToolsData().filter((tool) => tool.category === categoryKey)
} }
function handleToolClick(tool) { function handleToolClick(tool) {