Jimeng VirtualHuman and actionTransfer is ready

This commit is contained in:
GeekMaster
2025-09-15 20:29:46 +08:00
parent 822d1831cd
commit c4b44d84e3
12 changed files with 1155 additions and 151 deletions

View File

@@ -46,6 +46,7 @@ const (
type JimengTaskRequest struct {
TaskType JMTaskType `json:"type"` // 任务类型
ReqKey string `json:"req_key"` // 请求Key
Action string `json:"action"` // 请求Action
Power int `json:"power"` // 消耗算力
// 公共参数
Prompt string `json:"prompt,omitempty"`
@@ -54,6 +55,8 @@ type JimengTaskRequest struct {
// 图片生成参数
Size string `json:"size,omitempty"`
UsePreLLM bool `json:"use_pre_llm,omitempty"`
Scale float64 `json:"scale,omitempty"`
ForceSingle bool `json:"force_single,omitempty"`
// 视频生成参数
Duration int `json:"duration,omitempty"` // 视频时长,单位:秒
@@ -63,6 +66,7 @@ type JimengTaskRequest struct {
// 数字人视频生成参数
AudioURL string `json:"audio_url,omitempty"` // 音频URL
RecognizeKey string `json:"recognize_key,omitempty"` // 识别主体请求Key
// 视频动作迁移参数
VideoURL string `json:"video_url,omitempty"` // 动作视频URL

View File

@@ -97,7 +97,7 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
// 获取算力消耗
powerCost, err := h.getTaskPower(req)
if err != nil {
resp.ERROR(c, "计算任务消耗积分失败")
resp.ERROR(c, "计算任务消耗积分失败: "+err.Error())
return
}
@@ -105,6 +105,7 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
resp.ERROR(c, fmt.Sprintf("算力不足,需要%d算力", powerCost))
return
}
req.Power = powerCost
job, err := h.jimengService.CreateTask(user.Id, &req)
if err != nil {
@@ -116,12 +117,27 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
h.userService.DecreasePower(user.Id, powerCost, model.PowerLog{
Type: types.PowerConsume,
Model: job.ReqKey,
Remark: fmt.Sprintf("%s任务ID%d", req.ReqKey, job.Id),
Remark: h.getTaskRemark(req, job.Id),
})
resp.SUCCESS(c)
}
func (h *JimengHandler) getTaskRemark(req types.JimengTaskRequest, jobId uint) string {
remark := fmt.Sprintf("即梦任务%s任务ID%d", req.ReqKey, jobId)
switch req.TaskType {
case types.JMTaskTypeImage:
remark = fmt.Sprintf("即梦图片生成任务ID%d%d积分/张", jobId, h.App.SysConfig.Jimeng.Power.Image)
case types.JMTaskTypeVideo:
remark = fmt.Sprintf("即梦视频生成任务ID%d%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.Video, req.Power/h.App.SysConfig.Jimeng.Power.Video)
case types.JMTaskTypeVirtualHuman:
remark = fmt.Sprintf("即梦数字人视频生成任务ID%d%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.VirtualHuman, req.Power/h.App.SysConfig.Jimeng.Power.VirtualHuman)
case types.JMTaskTypeActionTransfer:
remark = fmt.Sprintf("即梦视频动作迁移任务ID%d%d积分/秒, %d秒", jobId, h.App.SysConfig.Jimeng.Power.ActionTransfer, req.Power/h.App.SysConfig.Jimeng.Power.ActionTransfer)
}
return remark
}
// Jobs 获取任务列表
func (h *JimengHandler) Jobs(c *gin.Context) {
userId := h.GetLoginUserId(c)
@@ -219,7 +235,8 @@ func (h *JimengHandler) Remove(c *gin.Context) {
}
// 失败任务删除后退回算力
if job.Status != types.JMTaskStatusFailed {
if job.Status == types.JMTaskStatusFailed {
logger.Infof("delete jimeng job failed, refund power: %d", job.Power)
err = h.userService.IncreasePower(user.Id, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: job.ReqKey,
@@ -284,6 +301,7 @@ func (h *JimengHandler) Retry(c *gin.Context) {
// getPowerFromConfig 从配置中获取指定类型的算力消耗
func (h *JimengHandler) getTaskPower(req types.JimengTaskRequest) (int, error) {
logger.Debugf("getTaskPower req: %+v", req)
config := h.App.SysConfig.Jimeng
switch req.TaskType {
case types.JMTaskTypeImage:
@@ -295,10 +313,24 @@ func (h *JimengHandler) getTaskPower(req types.JimengTaskRequest) (int, error) {
return config.Power.Video * req.Duration, nil
case types.JMTaskTypeVirtualHuman:
// TODO 计算音频时长
return config.Power.VirtualHuman, nil
if req.AudioURL == "" {
return 0, errors.New("音频URL不能为空")
}
audioDuration, err := utils.AudioDurationFromURL(req.AudioURL)
if err != nil {
return 0, err
}
return config.Power.VirtualHuman * int(audioDuration.Seconds()), nil
case types.JMTaskTypeActionTransfer:
// TODO 计算视频时长
return config.Power.ActionTransfer, nil
if req.VideoURL == "" {
return 0, errors.New("视频URL不能为空")
}
videoDuration, err := utils.VideoDurationMP4FromURL(req.VideoURL)
if err != nil {
return 0, err
}
return config.Power.ActionTransfer * int(videoDuration.Seconds()), nil
default:
return 0, errors.New("任务类型不支持")
}

View File

@@ -3,11 +3,13 @@ package jimeng
import (
"context"
"encoding/json"
"errors"
"fmt"
"geekai/core/types"
"net/http"
"net/url"
"strings"
"time"
"github.com/volcengine/volc-sdk-golang/base"
"github.com/volcengine/volc-sdk-golang/service/visual"
@@ -54,6 +56,22 @@ func (c *Client) UpdateConfig(config types.JimengConfig) error {
"Version": []string{"2022-08-31"},
},
},
"CVSubmitTask": {
Method: http.MethodPost,
Path: "/",
Query: url.Values{
"Action": []string{"CVSubmitTask"},
"Version": []string{"2022-08-31"},
},
},
"CVGetResult": {
Method: http.MethodPost,
Path: "/",
Query: url.Values{
"Action": []string{"CVGetResult"},
"Version": []string{"2022-08-31"},
},
},
"CVProcess": {
Method: http.MethodPost,
Path: "/",
@@ -75,6 +93,22 @@ func (c *Client) UpdateConfig(config types.JimengConfig) error {
return c.testConnection()
}
// GetErrorMessage 根据错误代码获取对应的错误信息
func GetErrorMessage(code int) string {
if message, exists := errorCodeMessages[code]; exists {
return message
}
return fmt.Sprintf("未知错误代码: %d", code)
}
// HandleResponseError 处理响应错误,根据错误代码返回详细的错误信息
func HandleResponseError(code int, message string) error {
if code == ECSuccess {
return nil
}
return errors.New(GetErrorMessage(code))
}
// testConnection 测试即梦AI连接
func (c *Client) testConnection() error {
@@ -84,7 +118,7 @@ func (c *Client) testConnection() error {
TaskId: "test_task_id_12345",
}
_, err := c.QueryTask(testReq)
_, err := c.QueryTask(testReq, ASyncActionGetResult)
// 即使任务不存在,只要不是认证错误就说明连接正常
if err != nil {
// 检查是否是认证错误
@@ -105,17 +139,16 @@ func (c *Client) SubmitTask(req map[string]any) (*SubmitTaskResponse, error) {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
// 单独处理图片特效任务
if req["req_key"] == ImageEffectReqKey {
req["image_input1"] = req["image_urls"].([]any)[0]
delete(req, "image_urls")
}
// 直接使用序列化后的字节
jsonBody := reqBodyBytes
action := ASyncActionSubmit
if v, ok := req["action"]; ok {
action = v.(string)
delete(req, "action")
}
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncSubmitTask", nil, string(jsonBody))
respBody, statusCode, err := c.visual.Client.Json(action, nil, string(jsonBody))
if err != nil {
return nil, fmt.Errorf("submit task failed (status: %d): %w", statusCode, err)
}
@@ -128,11 +161,70 @@ func (c *Client) SubmitTask(req map[string]any) (*SubmitTaskResponse, error) {
return nil, fmt.Errorf("unmarshal response failed: %w", err)
}
// 检查响应错误代码
if err := HandleResponseError(result.Code, result.Message); err != nil {
return nil, err
}
return &result, nil
}
// 识别数字人主体
func (c *Client) AvatarRecognition(imgUrl string, reqKey string) error {
params := map[string]any{
"image_url": imgUrl,
"req_key": reqKey,
}
reqBodyBytes, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("marshal request failed: %w", err)
}
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json(SyncActionSubmit, nil, string(reqBodyBytes))
if err != nil {
return fmt.Errorf("submit task failed (status: %d): %w", statusCode, err)
}
// 解析响应
var result SubmitTaskResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("unmarshal response failed: %w", err)
}
// 检查响应错误代码
if err := HandleResponseError(result.Code, result.Message); err != nil {
return err
}
// 等待任务完成
for {
resp, err := c.QueryTask(&QueryTaskRequest{
ReqKey: reqKey,
TaskId: result.Data.TaskId,
}, SyncActionGetResult)
if err != nil {
return fmt.Errorf("query task failed: %w", err)
}
if resp.Data.Status != types.JMTaskStatusDone {
time.Sleep(time.Second * 3)
continue
}
var respData map[string]int
if err := json.Unmarshal([]byte(resp.Data.RespData), &respData); err != nil {
return fmt.Errorf("unmarshal response failed: %w", err)
}
logger.Debugf("Jimeng AvatarRecognition Response: %+v", resp)
if respData["status"] == 1 {
return nil
} else {
return errors.New("不包含人、类人、拟人等主体")
}
}
}
// QueryTask 查询任务结果
func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
func (c *Client) QueryTask(req *QueryTaskRequest, action string) (*QueryTaskResponse, error) {
// 序列化请求
jsonBody, err := json.Marshal(req)
if err != nil {
@@ -140,7 +232,7 @@ func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
}
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncGetResult", nil, string(jsonBody))
respBody, statusCode, err := c.visual.Client.Json(action, nil, string(jsonBody))
if err != nil {
return nil, fmt.Errorf("query task failed (status: %d): %w", statusCode, err)
}
@@ -153,6 +245,11 @@ func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
return nil, fmt.Errorf("unmarshal response failed: %w", err)
}
// 检查响应错误代码
if err := HandleResponseError(result.Code, result.Message); err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -160,7 +160,12 @@ func (s *Service) ProcessTask(jobId uint) error {
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
}
logger.Debugf("提交即梦任务: %+v", params)
// 数字人任务,先识别主体
if req.TaskType == types.JMTaskTypeVirtualHuman {
if err := s.client.AvatarRecognition(req.ImageUrls[0], req.RecognizeKey); err != nil {
return s.handleTaskError(job.Id, fmt.Sprintf("avatar recognition failed: %v", err))
}
}
// 同步任务 ,后台执行
if req.ReqKey == DoubaoSeedream40ReqKey {
@@ -195,6 +200,7 @@ func (s *Service) ProcessTask(jobId uint) error {
return nil
}
logger.Debugf("提交即梦任务: %+v", params)
// 异步任务 ,前台执行
resp, err := s.client.SubmitTask(params)
if err != nil {
@@ -245,6 +251,21 @@ func (s *Service) buildTaskRequest(req *types.JimengTaskRequest) (map[string]any
delete(params, "duration")
}
// 单独处理图片特效任务
if req.ReqKey == ImageEffectReqKey {
params["image_input1"] = req.ImageUrls[0]
delete(params, "image_urls")
}
// 动作迁移,数字人任务参数处理
if req.TaskType == types.JMTaskTypeVirtualHuman || req.TaskType == types.JMTaskTypeActionTransfer {
params["image_url"] = req.ImageUrls[0]
delete(params, "image_urls")
}
if req.RecognizeKey != "" {
delete(params, "recognize_key")
}
// 删除多余参数,剩下的就是各个任务自己专有参数了
delete(params, "type")
delete(params, "power")
@@ -280,7 +301,7 @@ func (s *Service) pollTaskStatus() {
ReqKey: job.ReqKey,
TaskId: job.TaskId,
ReqJson: `{"return_url":true}`,
})
}, ASyncActionGetResult)
if err != nil {
s.handleTaskError(job.Id, fmt.Sprintf("query task failed: %s", err.Error()))

View File

@@ -51,6 +51,68 @@ type QueryTaskResponse struct {
const CodeSuccess = 10000
// 即梦AI错误代码常量
const (
// 成功
ECSuccess = 10000
// 请求参数错误 (50200-50215)
ECReqInvalidArgs = 50200 // 参数错误
ECReqMissingArgs = 50201 // 缺少参数
ECParseArgs = 50204 // 参数类型错误/参数缺失
ECImageSizeLimited = 50205 // 图像尺寸超过限制
ECImageEmpty = 50206 // 请求参数中没有获取到图像
ECImageDecodeError = 50207 // 图像解码错误
ECVideoEmpty = 50209 // 请求参数中没有获取到视频
ECVideoDecodeError = 50210 // 视频解码错误
ECVideoSizeLimited = 50211 // 视频尺寸超过限制
ECReqBodySizeLimited = 50213 // 请求Body过大
ECVideoTimeTooLong = 50214 // 输入视频时长过大
ECRPCProcess = 50215 // 请求处理失败
// 算法服务错误 (60102-60208)
ECJPFaceDetect = 60102 // 算法服务需要输入人脸图,但未检测到
ECFSLeaderRiskError = 60208 // 输入图片中包含敏感信息,未通过审核
// 权限和系统错误 (50400-50501)
ECAuth = 50400 // 权限校验失败
ECReqMethod = 50402 // 访问的接口不存在
ECReqLimit = 50429 // 超过调用QPS限制
ECInternal = 50500 // 服务器内部错误
ECRPCInternal = 50501 // 服务器内部RPC错误
)
// 错误代码到错误信息的映射
var errorCodeMessages = map[int]string{
// 成功
ECSuccess: "请求成功",
// 请求参数错误
ECReqInvalidArgs: "参数错误检查入参及MIME类型",
ECReqMissingArgs: "缺少参数检查入参及MIME类型",
ECParseArgs: "参数类型错误/参数缺失检查入参及MIME类型",
ECImageSizeLimited: "图像尺寸超过限制,参考接口文档入参要求部分",
ECImageEmpty: "请求参数中没有获取到图像,检查入参",
ECImageDecodeError: "图像解码错误没有获取到图像或者通过image_base64参数传递图像是base64解码错误检查输出图片或检查base64是否错误携带前缀",
ECVideoEmpty: "请求参数中没有获取到视频。输入为视频时可能返回此错误,检查入参",
ECVideoDecodeError: "视频解码错误。输入为视频时可能返回此错误,检查输入视频是否不正确",
ECVideoSizeLimited: "视频尺寸超过限制。输入为视频时可能返回此错误,检查输入视频大小",
ECReqBodySizeLimited: "请求Body过大超出接口限制检查请求Body大小",
ECVideoTimeTooLong: "输入视频时长过大,检查输入视频时长",
ECRPCProcess: "由于输入的图片、视频、参数等不满足要求,导致请求处理失败。若接口文档中有具体说明,优先参考其具体含义,按照具体服务说明进行检查",
// 算法服务错误
ECJPFaceDetect: "算法服务需要输入人脸图,但未检测到,检查输入图片是否包含人脸",
ECFSLeaderRiskError: "输入图片中包含敏感信息,未通过审核",
// 权限和系统错误
ECAuth: "权限校验失败,请检查是否已创建应用并开通服务或签名,参考接入指南及快速接入",
ECReqMethod: "访问的接口不存在,检查入参",
ECReqLimit: "超过调用QPS限制购买QPS增项包",
ECInternal: "服务器内部错误,提工单",
ECRPCInternal: "服务器内部RPC错误提工单",
}
// CreateTaskRequest 创建任务请求
type CreateTaskRequest struct {
Type types.JMTaskType `json:"type"`
@@ -65,3 +127,10 @@ const (
ImageEffectReqKey = "i2i_multi_style_zx2x"
DoubaoSeedream40ReqKey = "doubao-seedream-4-0-250828"
)
const (
ASyncActionSubmit = "CVSync2AsyncSubmitTask" // 异步提交任务
SyncActionSubmit = "CVSubmitTask" // 同步提交任务
ASyncActionGetResult = "CVSync2AsyncGetResult" // 异步获取结果
SyncActionGetResult = "CVGetResult" // 同步获取结果
)

View File

@@ -0,0 +1,469 @@
<template>
<div class="file__upload-container">
<!-- 单文件模式 -->
<template v-if="props.maxCount === 1">
<div class="single-upload">
<div v-if="fileList.length === 0" class="upload-btn upload-btn-single">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="false"
:accept="accept"
class="uploader"
>
<div class="upload-placeholder">
<el-icon :size="20"><UploadFilled /></el-icon>
<span>上传文件</span>
</div>
</el-upload>
</div>
<div
v-else
class="relative inline-flex items-center border border-gray-200 rounded-xl bg-white dark:bg-[#2b2b2b] dark:border-gray-700 p-2 w-full"
>
<img :src="getFileImage(fileList[0].url)" class="w-10 h-10 mr-2" />
<div class="min-w-0 flex flex-col items-center gap-1 text-sm">
<a
:href="fileList[0].url"
target="_blank"
class="truncate block text-[var(--theme-text-color-primary,#0d0d0d)] max-w-[220px]"
>
{{ fileList[0].name }}
</a>
<div class="text-xs flex w-full justify-start text-gray-500">
{{ GetFileType(getFileExt(fileList[0].name)) }} ·
{{ FormatFileSize(fileList[0].size || 0) }}
</div>
</div>
<button
class="absolute -right-2 -top-2 w-5 h-5 rounded-full bg-rose-600 text-white flex items-center justify-center text-[10px]"
@click="removeFile(0)"
aria-label="remove"
>
×
</button>
</div>
</div>
</template>
<!-- 多文件模式 -->
<template v-else>
<div class="flex flex-col gap-2 px-2 pt-2 !items-start" v-if="fileList.length > 0">
<div
v-for="(file, index) in fileList"
:key="file.url || index"
class="relative inline-flex items-center border border-gray-200 rounded-xl bg-white dark:bg-[#2b2b2b] dark:border-gray-700 p-2 w-full"
>
<img :src="getFileImage(file.url)" class="w-10 h-10 mr-2" />
<div class="min-w-0 flex flex-col items-center gap-1 text-sm">
<a :href="file.url" target="_blank" class="truncate block max-w-[180px]">{{
file.name
}}</a>
<div class="text-xs flex w-full justify-start text-gray-500">
{{ GetFileType(getFileExt(file.name)) }} · {{ FormatFileSize(file.size || 0) }}
</div>
</div>
<button
class="absolute -right-2 -top-2 w-5 h-5 rounded-full bg-rose-600 text-white flex items-center justify-center text-[10px]"
@click="removeFile(index)"
aria-label="remove"
>
×
</button>
</div>
<!-- 上传按钮 -->
<div v-if="!multiple || fileList.length < maxCount" class="upload-btn">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
:accept="accept"
class="uploader"
:limit="maxCount"
>
<div class="upload-placeholder">
<el-icon :size="20"><UploadFilled /></el-icon>
<span>上传文件</span>
</div>
</el-upload>
</div>
</div>
<!-- 初始上传区域 -->
<div v-else class="upload-area">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
:accept="accept"
class="uploader"
:limit="maxCount"
>
<el-icon :size="40" class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽文件到此处 <em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip text-gray-500 text-sm">
支持 {{ accept }} 格式最多上传 {{ maxCount }} 单个最大 {{ maxSize }}MB
</div>
</template>
</el-upload>
</div>
</template>
<!-- 上传进度 -->
<el-progress
v-if="uploading"
:percentage="uploadProgress"
:stroke-width="4"
class="upload-progress"
/>
</div>
</template>
<script setup>
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { httpPost } from '@/utils/http'
import { isImage, replaceImg } from '@/utils/libs'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Array],
default: '',
},
multiple: {
type: Boolean,
default: false,
},
maxCount: {
type: Number,
default: 1,
},
maxSize: {
type: Number,
default: 10,
},
accept: {
type: String,
default: '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.zip,.rar,.7z',
},
})
const emit = defineEmits(['update:modelValue', 'upload-success', 'remove-file'])
// 上传状态
const uploading = ref(false)
const uploadProgress = ref(0)
const FileInfoList = ref([])
// 文件列表
const fileList = computed({
get() {
if (props.multiple || props.maxCount > 1) {
return FileInfoList.value
} else {
return FileInfoList.value && FileInfoList.value.length > 0 ? FileInfoList.value : []
}
},
set(value) {
const isMulti = props.multiple || props.maxCount > 1
const normalized = Array.isArray(value) ? value : value ? [value] : []
FileInfoList.value = normalized
if (isMulti) {
const urls = normalized.map((v) => v && v.url).filter((u) => !!u)
emit('update:modelValue', urls)
} else {
const url =
normalized.length > 0 && normalized[0] && normalized[0].url ? normalized[0].url : ''
emit('update:modelValue', url)
}
},
})
const uploadCount = ref(1)
// 获取文件扩展名
const getFileExt = (filename) => {
return '.' + filename.split('.').pop().toLowerCase()
}
const getFileName = (url) => {
return url.split('/').pop()
}
// 获取文件
const getFileImage = (url) => {
return isImage(url) ? url : GetFileIcon(getFileExt(url))
}
// 将外部 modelValue 同步为内部文件对象列表
const urlToFileInfo = (url) => ({
url,
name: getFileName(url),
size: 0,
ext: getFileExt(url),
})
// 通过 HEAD 请求尝试获取远程资源大小
const fetchRemoteFileSize = async (url) => {
try {
const res = await fetch(url, { method: 'HEAD' })
const len = res.headers.get('content-length')
return len ? parseInt(len, 10) : 0
} catch (e) {
return 0
}
}
// 对 size 为空或 0 的项进行补充
const updateUnknownSizes = async (items) => {
const tasks = items.map(async (it) => {
if (!it || !it.url) return it
if (!it.size || it.size === 0) {
const s = await fetchRemoteFileSize(it.url)
if (s > 0) {
it.size = s
}
}
return it
})
await Promise.all(tasks)
}
watch(
() => props.modelValue,
(newVal) => {
const isMulti = props.multiple || props.maxCount > 1
if (isMulti) {
const urls = Array.isArray(newVal) ? newVal : []
FileInfoList.value = urls.map((u) => urlToFileInfo(u))
// 异步补齐大小
updateUnknownSizes(FileInfoList.value)
} else {
const url = typeof newVal === 'string' ? newVal : ''
FileInfoList.value = url ? [urlToFileInfo(url)] : []
// 异步补齐大小
updateUnknownSizes(FileInfoList.value)
}
},
{ immediate: true }
)
// 处理上传
const handleUpload = async (uploadFile) => {
const file = uploadFile.file
// 检查文件大小
if (file.size > props.maxSize * 1024 * 1024) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB`)
return
}
// 检查数量限制
if (uploadCount.value > props.maxCount) {
ElMessage.error(`最多只能上传 ${props.maxCount} 个文件`)
return
}
uploadCount.value++
uploading.value = true
uploadProgress.value = 0
try {
const formData = new FormData()
formData.append('file', file)
// 模拟上传进度
const progressTimer = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += 10
}
}, 100)
const response = await httpPost('/api/upload', formData)
clearInterval(progressTimer)
uploadProgress.value = 100
const fileUrl = replaceImg(response.data.url)
const fileInfo = {
name: file.name,
url: fileUrl,
size: file.size,
ext: getFileExt(file.name),
}
// 更新文件列表
if (props.multiple || props.maxCount > 1) {
const newList = [...fileList.value, fileInfo]
fileList.value = newList
} else {
fileList.value = [fileInfo]
}
emit('upload-success', fileInfo)
ElMessage.success('上传成功')
} catch (error) {
ElMessage.error('上传失败: ' + (error.message || '网络错误'))
} finally {
uploading.value = false
uploadProgress.value = 0
}
}
// 移除文件
const removeFile = (index) => {
const file = fileList.value[index]
const newList = [...fileList.value]
newList.splice(index, 1)
fileList.value = newList
uploadCount.value--
emit('remove-file', file)
}
</script>
<style lang="scss">
.file__upload-container {
width: 100%;
.single-upload {
width: 100%;
position: relative;
.upload-btn-single {
.uploader {
width: 100%;
.el-upload-dragger {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
// .single-file-item {
// width: 100%;
// position: relative;
// border-radius: 6px;
// overflow: hidden;
// border: 1px solid #dcdfe6;
// background-color: var(--chat-content-bg, #f5f5f5);
// padding: 8px;
// }
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.upload-item {
position: relative;
width: 100%;
max-width: 300px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
background-color: var(--chat-content-bg, #f5f5f5);
padding: 8px;
.file-info {
display: flex;
flex-direction: row;
align-items: center;
.icon {
.el-image {
width: 40px;
height: 40px;
}
}
.body {
margin-left: 8px;
flex: 1;
font-size: 12px;
.title {
color: #0d0d0d;
margin-bottom: 4px;
}
.info {
color: #b4b4b4;
span {
margin-right: 10px;
}
}
}
}
.upload-overlay {
position: absolute;
top: -8px;
right: -8px;
opacity: 1;
.remove-btn {
background: rgba(245, 108, 108, 0.8);
border: none;
color: white;
}
}
}
.upload-btn {
.uploader {
width: 100%;
.el-upload-dragger {
display: flex;
align-items: center;
justify-content: center;
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 12px;
color: #8c939d;
}
}
.upload-area {
.el-upload-dragger {
width: 100%;
}
.uploader {
width: 100%;
}
}
.upload-progress {
margin-top: 10px;
}
:deep(.el-upload) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="image-upload">
<div class="image__upload-container">
<!-- 单图模式 -->
<template v-if="props.maxCount === 1">
<div class="single-upload">
@@ -59,7 +59,7 @@
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
accept="image/*"
:accept="accept"
class="uploader"
:limit="maxCount"
>
@@ -157,7 +157,7 @@ const imageList = computed({
},
})
const uploadCount = ref(1)
// 使用已选图片数量进行限制,不再使用全局计数
// 处理上传
const handleUpload = async (uploadFile) => {
const file = uploadFile.file
@@ -174,12 +174,11 @@ const handleUpload = async (uploadFile) => {
return
}
// 检查数量限制
if (uploadCount.value > props.maxCount) {
// 检查数量限制(单图或多图)
if ((props.multiple || props.maxCount > 1) && imageList.value.length >= props.maxCount) {
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
return
}
uploadCount.value++
uploading.value = true
uploadProgress.value = 0
@@ -225,37 +224,35 @@ const removeImage = (index) => {
const newList = [...imageList.value]
newList.splice(index, 1)
imageList.value = newList
uploadCount.value--
}
</script>
<style lang="scss">
.image-upload {
.image__upload-container {
width: 100%;
}
.single-upload {
.single-upload {
width: 100px;
height: 100px;
position: relative;
}
}
.single-image-item {
.single-image-item {
width: 100px;
height: 100px;
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
}
}
.upload-list {
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
}
.upload-item {
.upload-item {
position: relative;
width: 100px;
height: 100px;
@@ -286,9 +283,9 @@ const removeImage = (index) => {
color: white;
}
}
}
}
.upload-btn {
.upload-btn {
.uploader {
width: 100%;
@@ -309,9 +306,9 @@ const removeImage = (index) => {
font-size: 12px;
color: #8c939d;
}
}
}
.upload-area {
.upload-area {
.el-upload-dragger {
width: 100%;
}
@@ -319,17 +316,18 @@ const removeImage = (index) => {
.uploader {
width: 100%;
}
}
}
.upload-progress {
.upload-progress {
margin-top: 10px;
}
}
:deep(.el-upload) {
:deep(.el-upload) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -22,14 +22,15 @@
<el-option v-for="item in items" :key="item.name" :label="item.name" :value="item">
<div class="flex justify-start">
<span
class="flex items-center justify-center text-white !text-xl model-version mr-2 w-[40px] h-[40px] rounded-lg"
>{{ item.version }}</span
class="flex items-center justify-center text-white model-version mr-2 w-[40px] h-[40px] rounded-lg"
:class="item.icon.size ? item.icon.size : '!text-xl'"
>{{ item.icon.text }}</span
>
<div class="flex !items-start flex-col py-2 space-y-1">
<span class="label text-sm">{{ item.name }}</span>
<div class="whitespace-pre-line">
<span
class="text-xs text-gray-500 break-words line-clamp-1 max-w-[200px]"
class="text-xs text-gray-500 break-words line-clamp-1 max-w-[250px]"
:title="item.label"
>{{ item.label }}</span
>
@@ -47,7 +48,10 @@
</div>
<p v-if="param.info" class="text-xs text-gray-500 mb-1">{{ param.info }}</p>
</div>
<div class="w-full flex flex-col !items-start space-y-2" v-else>
<div
class="w-full flex flex-col !items-start space-y-2"
v-else-if="param.type !== 'hidden'"
>
<label class="label font-bold">
{{ param.label }}
<span v-if="param.required" class="text-red-500 ml-1">*</span>
@@ -139,6 +143,14 @@
:max-size="param.maxSize"
:accept="param.accept"
/>
<FileUpload
v-if="param.type === 'file'"
v-model="modelValue[param.name]"
:max-count="param.maxCount"
:multiple="param.multiple"
:max-size="param.maxSize"
:accept="param.accept"
/>
</div>
</div>
</div>
@@ -147,6 +159,7 @@
</template>
<script setup>
import FileUpload from './FileUpload.vue'
import ImageUpload from './ImageUpload.vue'
import ParamEmpty from './ui/ParamEmpty.vue'
@@ -225,7 +238,11 @@ const initModelValue = (model) => {
}
})
}
// 初始化 req_key 和 action
defaultValues.req_key = selectedModel.value.key
defaultValues.action = selectedModel.value.action
? selectedModel.value.action
: 'CVSync2AsyncSubmitTask'
return defaultValues
}

View File

@@ -42,8 +42,179 @@ import Spring_Festival_traditional_Chinese_architecture from '@/assets/img/jimen
export const JimengParams = {
image: [
{
name: '图片 4.0 文/图生图',
version: '4.0',
name: '即梦AI图片-4.0',
icon: { text: '4.0' },
label: '即梦4.0是即梦同源的图像生成能力,支持4K超高清输出',
key: 'jimeng_t2i_v40',
params: [
{
name: 'prompt',
label: '提示词',
type: 'textarea',
required: true,
showWordLimit: true,
maxlength: 800,
autosize: { minRows: 3, maxRows: 5 },
placeholder: '请输入用于编辑图像的提示词把xxx改成xxx删除xxx添加xxx等',
info: '最长不超过800字符prompt过长有概率出图异常或不生效',
},
{
name: 'image_urls',
label: '参考图片',
type: 'image',
required: false,
placeholder: '请上传图片',
maxSize: 10,
multiple: true,
maxCount: 10,
accept: '.png,.jpg,.jpeg',
info: '最大 15MB支持最多输入10张图',
},
// 图片比例
{
name: 'scale',
type: 'slider',
required: true,
info: '该值越大代表文本描述影响程度越大,且输入图片影响程度越小',
label: '文本影响力',
min: 0,
max: 1,
step: 0.1,
value: 0.5,
},
// 是否强制生成单图
{
name: 'force_single',
type: 'hidden',
required: true,
value: true,
},
// 图片尺寸
{
name: 'size',
type: 'select',
required: true,
placeholder: '请选择尺寸',
label: '图片尺寸',
prefix: 'icon-resize',
options: [
// 1K 分辨率
{
label: '1:1 (1024 x 1024)',
value: '1024x1024',
},
{
label: '4:3 (1152 x 864)',
value: '1152x864',
},
{
label: '3:4 (864 x 1152)',
value: '864x1152',
},
{
label: '3:2 (1248 x 832)',
value: '1248x832',
},
{
label: '2:3 (832 x 1248)',
value: '832x1248',
},
{
label: '16:9 (1280 x 720)',
value: '1280x720',
},
{
label: '9:16 (720 x 1280)',
value: '720x1280',
},
{
label: '21:9 (1512 x 648)',
value: '1512x648',
},
{
label: '9:21 (648 x 1512)',
value: '648x1512',
},
// 2K 分辨率
{
label: '1:1 (2048 x 2048)',
value: '2048x2048',
},
{
label: '4:3 (2304 x 1728)',
value: '2304x1728',
},
{
label: '3:4 (1728 x 2304)',
value: '1728x2304',
},
{
label: '3:2 (2496 x 1664)',
value: '2496x1664',
},
{
label: '2:3 (1664 x 2496)',
value: '1664x2496',
},
{
label: '16:9 (2560 x 1440)',
value: '2560x1440',
},
{
label: '9:16 (1440 x 2560)',
value: '1440x2560',
},
{
label: '21:9 (3024 x 1296)',
value: '3024x1296',
},
{
label: '9:21 (1296 x 3024)',
value: '1296x3024',
},
// 4K 分辨率
{
label: '1:1 (4096 x 4096)',
value: '4096x4096',
},
{
label: '4:3 (4736 x 3552)',
value: '4736x3552',
},
{
label: '3:4 (3552 x 4736)',
value: '3552x4736',
},
{
label: '3:2 (5024 x 3360)',
value: '5024x3360',
},
{
label: '2:3 (3360 x 5024)',
value: '3360x5024',
},
{
label: '16:9 (5472 x 2072)',
value: '5472x2072',
},
{
label: '9:16 (2072 x 5472)',
value: '2072x5472',
},
{
label: '21:9 (6272 x 2688)',
value: '6272x2688',
},
],
},
],
},
{
name: '豆包-seedream-4.0',
icon: { text: '4.0' },
label: '支持文本、单图和多图输入,实现基于主体一致性的多图融合创作、图像编辑等多样玩法',
key: 'doubao-seedream-4-0-250828',
params: [
@@ -105,7 +276,7 @@ export const JimengParams = {
{
name: '图片 3.0 文生图',
version: '3.0',
icon: { text: '3.0' },
label: '影视质感文字更准直出2k高清图',
key: 'jimeng_t2i_v30',
params: [
@@ -182,7 +353,7 @@ export const JimengParams = {
},
{
name: '图片 3.1 文生图',
version: '3.1',
icon: { text: '3.1' },
label: '丰富的美学多样性,画面更鲜明生动',
key: 'jimeng_t2i_v31',
params: [
@@ -260,7 +431,7 @@ export const JimengParams = {
{
name: '图片 3.0 图生图',
version: '3.0',
icon: { text: '3.0' },
label: '精准执行编辑指令,保持图像内容完整性',
key: 'jimeng_i2i_v30',
params: [
@@ -343,7 +514,7 @@ export const JimengParams = {
{
name: '图片 3.0 图像特效',
version: '3.0',
icon: { text: '3.0' },
label: '将输入的单人写真图片,进行有创意的特效化处理。',
key: 'i2i_multi_style_zx2x',
params: [
@@ -560,7 +731,7 @@ export const JimengParams = {
{
name: '图片 2.1 文生图',
version: '2.1',
icon: { text: '2.1' },
label: '平面绘感强,可生成文字海报',
key: 'jimeng_high_aes_general_v21_L',
params: [
@@ -633,7 +804,7 @@ export const JimengParams = {
// 视频 3.0 720P-文生视频
{
name: '视频 3.0 720P-文生视频',
version: '3.0',
icon: { text: '3.0' },
label: '生成效果与速度兼备',
key: 'jimeng_t2v_v30',
params: [
@@ -703,7 +874,7 @@ export const JimengParams = {
// 视频 3.0 图生视频-首帧
{
name: '视频 3.0 720P-图生视频-首帧',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 首帧图片生成视频',
key: 'jimeng_i2v_first_v30',
params: [
@@ -749,7 +920,7 @@ export const JimengParams = {
// 视频 3.0 图生视频-首尾帧
{
name: '视频 3.0 720P-图生视频-首尾帧',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 首尾帧图片生成视频',
key: 'jimeng_i2v_first_tail_v30',
params: [
@@ -796,7 +967,7 @@ export const JimengParams = {
// 视频 3.0 图生视频-运镜
{
name: '视频 3.0 720P-图生视频-运镜',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 运镜图片生成视频',
key: 'jimeng_i2v_recamera_v30',
params: [
@@ -934,7 +1105,7 @@ export const JimengParams = {
// 视频 3.0 1080P-文生视频
{
name: '视频 3.0 1080P-文生视频',
version: '3.0',
icon: { text: '3.0' },
label: '视觉表达流畅一致支持1080P高清渲染',
key: 'jimeng_t2v_v30_1080p',
params: [
@@ -1004,7 +1175,7 @@ export const JimengParams = {
// 视频 3.0 1080P-图生视频-首帧
{
name: '视频 3.0 1080P-图生视频-首帧',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 首帧图片生成1080P视频',
key: 'jimeng_i2v_first_v30_1080',
params: [
@@ -1050,7 +1221,7 @@ export const JimengParams = {
// 视频 3.0 1080P-图生视频-首尾帧
{
name: '视频 3.0 1080P-图生视频-首尾帧',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 首尾帧图片生成1080P视频',
key: 'jimeng_i2v_first_tail_v30_1080',
params: [
@@ -1097,7 +1268,7 @@ export const JimengParams = {
// 视频 3.0Pro 1080P-图生视频
{
name: '视频 3.0Pro 1080P-图生视频',
version: '3.0',
icon: { text: '3.0' },
label: '根据提示词 + 首帧图片生成1080P视频',
key: 'jimeng_ti2v_v30_pro',
params: [
@@ -1180,8 +1351,122 @@ export const JimengParams = {
],
},
],
virtualHuman: [],
actionTransfer: [],
virtual_human: [
{
name: '即梦AI数字人',
icon: { text: '即梦', size: '!text-base' },
label: '即梦同源数字人快速模型,单张图片+音频',
key: 'jimeng_realman_avatar_picture_omni_v2',
action: 'CVSubmitTask',
params: [
{
name: 'image_urls',
label: '人物主体图片',
required: true,
placeholder: '请上传图片',
type: 'image',
multiple: false,
maxCount: 1,
maxSize: 5,
accept: '.png,.jpg,.jpeg',
info: '建议JPG格式输入图中为单人、人脸占比大、正面效果较好其他类型图片效果不佳',
},
{
name: 'audio_url',
label: '驱动音频',
required: true,
placeholder: '请上传音频',
type: 'file',
multiple: false,
maxCount: 1,
maxSize: 5,
accept: '.mp3,.wav,.m4a',
info: '音频建议MP3/WAV格式时长建议小于15秒以保障生成效果音频过长可能有效果裂化问题',
},
{
name: 'recognize_key',
label: '识别主体请求Key',
required: true,
value: 'jimeng_realman_avatar_picture_create_role_omni',
type: 'hidden',
},
],
},
{
name: '火山引擎OmniHuman数字人',
icon: { text: '火山', size: '!text-base' },
label: '火山引擎OmniHuman数字人模型单张图片+音频',
key: 'realman_avatar_picture_omni_v2',
action: 'CVSubmitTask',
params: [
{
name: 'image_urls',
label: '人物主体图片',
required: true,
placeholder: '请上传图片',
type: 'image',
multiple: false,
maxCount: 1,
maxSize: 5,
accept: '.png,.jpg,.jpeg',
info: '建议JPG格式输入图中为单人、人脸占比大、正面效果较好其他类型图片效果不佳',
},
{
name: 'audio_url',
label: '驱动音频',
required: true,
placeholder: '请上传音频',
type: 'file',
multiple: false,
maxCount: 1,
maxSize: 5,
accept: '.mp3,.wav,.m4a',
info: '音频建议MP3/WAV格式时长建议小于15秒以保障生成效果音频过长可能有效果裂化问题',
},
{
name: 'recognize_key',
label: '识别主体请求Key',
required: true,
value: 'realman_avatar_picture_create_role_omni',
type: 'hidden',
},
],
},
],
action_transfer: [
{
name: '即梦AI-动作模仿-4.0',
icon: { text: '4.0', fontSize: '!text-xl' },
label: '即梦同源的视频动作模仿(生动模式)',
key: 'jimeng_dream_actor_m1_gen_video_cv',
params: [
{
name: 'image_urls',
label: '人物主体图片',
required: true,
placeholder: '请上传图片',
type: 'image',
multiple: false,
maxCount: 1,
maxSize: 5,
accept: '.png,.jpg,.jpeg',
info: '图片格式支持 jpegjpgpng ,分辨率需在 480x480 以上1920x1080 以内',
},
{
name: 'video_url',
label: '动作视频',
required: true,
placeholder: '请上传音频',
type: 'file',
multiple: false,
maxCount: 1,
maxSize: 100,
accept: '.mp4,.mov,.webm',
info: '输入的视频时长不可超过30s支持mp4movwebm格式视频分辨率须在480P以上2K以内',
},
],
},
],
}
export const JimengFunctions = [
@@ -1196,12 +1481,12 @@ export const JimengFunctions = [
name: '视频生成',
},
{
key: 'virtualHuman',
key: 'virtual_human',
icon: 'icon-shuziren',
name: '数字人',
},
{
key: 'actionTransfer',
key: 'action_transfer',
icon: 'icon-action',
name: '动作模仿',
},

View File

@@ -6,7 +6,7 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import { checkSession } from '@/store/cache'
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_data'
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
@@ -59,7 +59,6 @@ export const useJimengStore = defineStore('jimeng', () => {
// 切换功能
const switchFunction = (f) => {
activeFunction.value = f.key
formData.value = {}
setFunctionPowers()
}
@@ -200,10 +199,14 @@ export const useJimengStore = defineStore('jimeng', () => {
if (formData.value.duration) {
formData.value.duration = parseInt(formData.value.duration)
}
if (formData.value.image_urls && !Array.isArray(formData.value.image_urls)) {
formData.value.image_urls = [formData.value.image_urls]
const data = { ...formData.value }
if (data.image_urls && !Array.isArray(data.image_urls)) {
data.image_urls = [data.image_urls]
}
const response = await httpPost('/api/jimeng/task', formData.value)
const response = await httpPost('/api/jimeng/task', data)
showMessageOK('任务提交成功')
isOver.value = false
await fetchData(1)
@@ -279,12 +282,6 @@ export const useJimengStore = defineStore('jimeng', () => {
}
}
// 播放视频
const playVideo = (item) => {
currentVideoUrl.value = item.video_url
showDialog.value = true
}
const setFunctionPowers = () => {
if (activeFunction.value === 'image') {
currentPowerCost.value = `${powerConfig.image}积分/张`
@@ -333,7 +330,6 @@ export const useJimengStore = defineStore('jimeng', () => {
isLogin,
showDialog,
currentVideoUrl,
// 配置
functions,
activeFunction,
@@ -355,7 +351,6 @@ export const useJimengStore = defineStore('jimeng', () => {
downloadFile,
retryTask,
removeJob,
playVideo,
cleanup,
// 工具函数

View File

@@ -240,7 +240,7 @@
>
您的浏览器不支持视频播放
</video>
<div class="video-mask" @click="store.playVideo(item)">
<div class="video-mask" @click="playVideo(item)">
<div class="play-btn">
<img src="/images/play.svg" alt="播放" />
</div>
@@ -370,15 +370,15 @@
</div>
<!-- 视频预览对话框 -->
<el-dialog v-model="store.showDialog" title="视频预览" center>
<el-dialog v-model="store.showDialog" title="视频预览" @close="stopVideoPlay" center>
<div class="flex justify-center items-center">
<video
ref="videoPreviewRef"
:src="store.currentVideoUrl"
autoplay
controls
preload="auto"
loop
muted
style="max-height: calc(100vh - 100px); max-width: 100vw; object-fit: cover"
>
您的浏览器不支持视频播放
@@ -414,6 +414,23 @@ const templatePreview = ref('')
// 新增:提示词指南折叠面板状态(默认收起)
const guideActive = ref([])
const videoPreviewRef = ref(null)
// 播放视频
const playVideo = (item) => {
store.currentVideoUrl = item.video_url
store.showDialog = true
if (videoPreviewRef.value) {
videoPreviewRef.value.play()
}
}
// 停止视频播放
const stopVideoPlay = () => {
if (videoPreviewRef.value) {
videoPreviewRef.value.pause()
}
}
onMounted(() => {
store.init()
})

View File

@@ -41,7 +41,7 @@
<script setup>
import ParamBuilder from '@/components/ParamBuilder.vue'
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_data'
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
import { ref } from 'vue'
const functions = JimengFunctions