feat: Video 平台控件配置驱动化 + UUID 模型标识 + 首尾帧双图上传
- Video 控件(proportion/time/ParamGroup)改为 config 驱动,根据 API 参数 schema 动态渲染选项 - 模型选择器改用 UUID(m.id)作为内部标识,避免同名 display_name 冲突导致错误模型配置 - getModelId 查找优先级:id → name → display_name,向下兼容 - imageUploadLimit 累加所有 imageUpload 参数 maxCount,支持首尾帧等双图模型 - buildTaskBody 将 referenceImages 按索引映射到 imageUpload 参数名 - 新增 ParamGroup(动态参数容器)+ SwitchControl(纯 CSS 开关)共享组件 - modelConfigHelper 扩展 resolution/duration 同步支持 - Select 组件 dropdown-item 添加 flex-shrink:0 防止 flex 压缩 - dialogBox 支持 beforeModel 控件分组渲染
This commit is contained in:
parent
0eee8b1f7f
commit
b8ff25a8d7
82
CLAUDE.md
82
CLAUDE.md
@ -58,10 +58,13 @@ src/
|
||||
│ │ └── index.vue # <component :is> 动态渲染平台控件,不含平台分支
|
||||
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||
│ ├── ParamGroup/ # 动态参数容器:遍历 config.params 中未被专用控件处理的 select/switch 参数
|
||||
│ ├── SwitchControl/ # 纯 CSS 布尔开关(不用外部组件库)
|
||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||
│ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||
├── views/ # 页面(home、login)
|
||||
├── model-configs/ # 运维参考:模型参数配置 JSON 文件(与 API 响应格式一致)
|
||||
└── utils/
|
||||
├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||
├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 20s HTTP 轮询直至完成/失败
|
||||
@ -87,6 +90,7 @@ const platform = {
|
||||
controls: [{ // 控件描述符数组
|
||||
name: 'proportion',
|
||||
component: markRaw(Component),
|
||||
beforeModel: false, // 设为 true 则渲染在 ModelSelector 之前
|
||||
show: (config) => boolean, // 根据 model config 决定是否显示
|
||||
props: (config) => ({ ... }) // 根据 model config 生成 v-model props
|
||||
}],
|
||||
@ -97,13 +101,14 @@ const platform = {
|
||||
promptPlaceholder, // 提示词占位文本(ref)
|
||||
|
||||
async loadModels() { ... }, // 获取模型列表
|
||||
async loadConfig(modelName, modelType) { ... }, // 加载模型参数配置
|
||||
getDefaultModel() { ... }, // 返回默认模型名
|
||||
async loadConfig(modelName, modelType) { ... }, // 加载模型参数配置(modelName 可以是 UUID/name/display_name)
|
||||
getDefaultModel() { ... }, // 返回默认模型标识(可返回 '' 交由 modelSelector 自动纠错)
|
||||
imageUploadLimit() { ... }, // 返回图片上传槽位数(应累加所有 imageUpload 参数的 maxCount)
|
||||
validateBeforeSubmit() { ... }, // 提交前校验,返回 null 表示通过
|
||||
getUploaderBindings() { ... }, // 图片上传组件的绑定参数
|
||||
getUploaderBindings() { ... }, // 图片上传组件的绑定参数(modelType + imagesCount)
|
||||
showImageUploader() { ... }, // 是否显示图片上传区域
|
||||
isImageRequired() { ... }, // 图片是否必填
|
||||
buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams
|
||||
buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams,需将 referenceImages 映射到 imageUpload 参数
|
||||
fillFromResult(resultData) { ... }, // 从历史结果回填参数
|
||||
}
|
||||
```
|
||||
@ -124,7 +129,7 @@ props: (config) => ({
|
||||
`src/components/dialogBox/index.vue` 是纯编排组件,不含任何平台分支:
|
||||
|
||||
- **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表
|
||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||
- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,按 `beforeModel` 拆分为两组,`beforeModel` 控件 → ModelSelector → 其余控件,用 `<component :is>` + `v-bind="ctrl.props(...)"` 渲染
|
||||
- **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value`
|
||||
- **模型切换**:`watch([model, modelType])` → `platform.loadConfig()` → `syncDefaults()` 将 schema 写入响应式 state → controls 的 `show()`/`props()` 读取 state → `visibleControls` 自动更新 → UI 重渲染
|
||||
- **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()`
|
||||
@ -140,6 +145,22 @@ props: (config) => ({
|
||||
4. `taskPolling.js` 直接读取 `data.body` → `getModelId(type, modelName)` 查找 UUID → POST `/suanli/v1/tasks`(`X-Session-Id` header)
|
||||
5. 20s 间隔轮询直至完成/失败
|
||||
|
||||
### 模型标识与查找
|
||||
|
||||
API 返回的模型对象包含三个标识字段:
|
||||
|
||||
| 字段 | 示例 | 用途 |
|
||||
|------|------|------|
|
||||
| `id` | `e7e1e743-7621-403d-b8fb-2b7f4fa1b4fc` | UUID 主键,**唯一且稳定**,用于 API 调用和内部追踪 |
|
||||
| `name` | `vidu-text-to-video-q3-turbo` | 内部标识名,通常也唯一 |
|
||||
| `display_name` | `Vidu q3-turbo` | 用户可见的显示名,**可能重复**(不同 pattern 下的同名模型) |
|
||||
|
||||
**Video 平台使用 `id`(UUID)作为 `model.value`**,避免 `display_name` 冲突导致的模型查找错误。`modelSelector.vue` 中 `modelGroups` 的 `value` 设为 `m.id`,`label` 仍用 `display_name` 显示。
|
||||
|
||||
`getModelId(type, modelName)` 查找优先级:`m.id === modelName` → `m.name === modelName` → `m.display_name === modelName`,向下兼容旧的 name/display_name 调用。
|
||||
|
||||
Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加载后由 `modelSelector` 的 watcher 自动选择第一个可用模型。
|
||||
|
||||
### 模型参数配置
|
||||
|
||||
模型参数配置通过后端 API `GET /suanli/v1/models/:id/config` 获取(60s localStorage 缓存),替代了旧的本地硬编码。参数通过 `ui` 字段映射到前端控件:
|
||||
@ -152,7 +173,8 @@ props: (config) => ({
|
||||
| `dimension` | `DimensionInput` | **组合模式**:单字段 `"W*H"` 格式,后端传 `dimension.separator`,前端生成 parse/format |
|
||||
| `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立字段(含 `min`/`max`),共享比例锁 |
|
||||
| `number` | proportion 控件内部 | 自定义宽高(如 Flux 的 customWidth/customHight),配合 `showWhen` 条件显示 |
|
||||
| `select` | `Select` | 通用下拉(如 quality) |
|
||||
| `select` | `Select`(通过 ParamGroup 或专用控件) | 通用下拉。专用控件直接使用 Select,其余由 ParamGroup 动态渲染 |
|
||||
| `switch` | `SwitchControl`(通过 ParamGroup) | 布尔开关,纯 CSS 实现,不用外部组件库 |
|
||||
| `quantity` | `Quantity` | 生成数量,`options` 必须为数字数组,上限由 `Math.max()` 派生 |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传,`maxCount` 控制上限 |
|
||||
| `hidden` | 无 | 静默写入默认值 |
|
||||
@ -167,12 +189,47 @@ props: (config) => ({
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。增强项:`dimension.separator` → parse/format 生成、`promptPlaceholder` 同步 |
|
||||
| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref 回写到 `paramValues` |
|
||||
| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。处理 `paramValues` 初始化、专用 ref 同步(proportion/resolution/quantity/quality/duration/dimension)、`promptPlaceholder` 同步。`resolution` 同时匹配 `ui:"resolution"` 和 `ui:"select"` |
|
||||
| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref(proportion/resolution/quantity/quality/duration/dimension)回写到 `paramValues` |
|
||||
| `getDimConfig(config)` | 检测 combined/split 模式 |
|
||||
| `checkShowWhen(param, paramValues)` | 检查 `showWhen` 条件 |
|
||||
|
||||
`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。各平台通过包装函数(如 `syncDefaults(config) { _syncDefaults(config, paintingState) }`)适配。
|
||||
`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。Video 平台额外包含 `duration`(用于 syncDefaults 同步时长默认值)。各平台通过包装函数适配。
|
||||
|
||||
### ParamGroup 动态参数渲染
|
||||
|
||||
当 API 返回的 params 中包含无专用控件的类型(如 `select`、`switch`),由 `ParamGroup` 统一处理:
|
||||
|
||||
- **容器定位**:作为平台 controls 数组的最后一项,`show()` 检查是否存在未被 `handledUis` 覆盖的参数
|
||||
- **`handledUis`**:`['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']` — 这些类型的参数由专用控件处理,ParamGroup 跳过
|
||||
- **`excludeNames` prop**:额外按参数名排除(如 `resolution`、`duration`),因为 VideoProportion/Time 已处理这些参数(即使其 `ui` 类型不在 handledUis 中)
|
||||
- **双层过滤**:平台 `show()` 做第一层(判断是否渲染 ParamGroup),ParamGroup 内部 `dynamicParams` 做第二层(判断具体渲染哪些参数)。**两层必须保持一致**,否则会出现重复控件
|
||||
|
||||
```js
|
||||
// Video 平台的 ParamGroup 配置
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
// 第一层:有未被处理的参数才显示
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration'] // 第二层过滤
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 组件规范
|
||||
|
||||
**禁止在项目组件中使用外部 UI 库**(Element Plus 等),图标除外。自定义组件使用项目自研的 `Select`、`Popover` 或纯 CSS 实现。`SwitchControl` 即为一例——纯 CSS 滑动开关,不依赖 `el-switch`。
|
||||
|
||||
### `$attrs` 穿透注意
|
||||
|
||||
@ -193,6 +250,9 @@ props: (config) => ({
|
||||
- **VirtualScroller 反旋转**:组件用外层 `transform: rotate(180deg)` 实现 reverse 底部锚定。所有作为 slot 插入的内容必须在根元素加 `transform: rotate(180deg)` 反旋转,否则文字/图片会颠倒显示。参考 `src/views/home/display/components/set.vue:2`。
|
||||
- **VirtualScroller 存在独立测试页**:`src/components/virtual-scroller/test.html` + `test-data.js`,可用于验证虚拟滚动行为。
|
||||
- **Element Plus `<el-input type="textarea">` 不响应 `autosize` 动态变化**:`ElInput` 只在 mount 时读取 `autosize`,prop 更新时不会重新计算高度。因此 `dialogBox` 中 `Sender` 必须绑定 `:key="useDisplay.Sender_variant"`,通过强制重挂载来使新的 `autosize`(`minRows`/`maxRows`)生效。
|
||||
- **Video modelSelector pattern→modelType 映射**:`getModelType()` 在 `modelSelector.vue` 中将 pattern tag 映射为 modelType——"文生视频"→`text`,"首尾帧"→`image`,"数字人"→`digitalHuman`。该值用于 imageUploader 的 `showEndFrame` 判断和上传槽位数量。
|
||||
- **Video `imageUploadLimit()` 累加逻辑**:对于有多个 `imageUpload` 参数的模型(如首尾帧模型的 `firstImageUrl` + `lastImageUrl`),应累加所有 `imageUpload` 参数的 `maxCount`,而非只取第一个。否则首尾帧模型只显示一个上传槽位,尾帧上传无法触发。
|
||||
- **`buildTaskBody` 参考图映射**:Video 平台在 `buildTaskBody` 中需将 `referenceImages` 按索引顺序写入 `imageUpload` 参数(`referenceImages[0]` → `firstImageUrl`,`referenceImages[1]` → `lastImageUrl`),否则图片数据不会包含在任务请求中。
|
||||
|
||||
### VirtualScroller 坐标系统
|
||||
|
||||
@ -229,7 +289,7 @@ props: (config) => ({
|
||||
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建任务(带 `X-Session-Id` header) |
|
||||
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
|
||||
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize`) |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, display_name, tags, disabled?}`) |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id(UUID), name, display_name, tags, disabled?}`)。`id`=UUID 主键,`name`=内部标识,`display_name`=用户可见名 |
|
||||
| `requestModelConfigsBatch` | POST `/suanli/v1/models/configs` | 批量获取模型配置(body: `{ modelIds: [...] }`) |
|
||||
| `requestModelConfig` | GET `/suanli/v1/models/:id/config` | 单条模型配置(60s 缓存优先) |
|
||||
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
|
||||
@ -275,5 +335,5 @@ props: (config) => ({
|
||||
### 自动导入
|
||||
|
||||
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API
|
||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件
|
||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件,**生成 `components.d.ts`(勿手动编辑)**
|
||||
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
||||
|
||||
39
model-configs/hailuo-02-fast.json
Normal file
39
model-configs/hailuo-02-fast.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"modelName": "海螺 02-fast 图生视频",
|
||||
"modelDescription": "RunningHub MiniMax 海螺 02-fast 图生视频模型,基于参考图生成快节奏电影感动画",
|
||||
"endpoint": "/minimax/hailuo-02/fast",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "enablePromptExpansion",
|
||||
"ui": "switch",
|
||||
"label": "提示词扩展",
|
||||
"default": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "imageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "参考图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 10
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "6",
|
||||
"options": ["6", "10"],
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述基于原图的动画效果(可选)。",
|
||||
"inputType": "image",
|
||||
"maxImages": 1
|
||||
}
|
||||
47
model-configs/ltx-2.3image.json
Normal file
47
model-configs/ltx-2.3image.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"modelName": "LTX-2.3 图生视频",
|
||||
"modelDescription": "RunningHub LTX-2.3 图生视频模型,基于图片生成视频",
|
||||
"endpoint": "/rhart-video/ltx-2.3/image-to-video",
|
||||
"params": [
|
||||
{
|
||||
"name": "imageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "参考图片",
|
||||
"maxCount": 1,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["9:16", "16:9"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["480p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": 5,
|
||||
"options": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述图片和期望的视频运动效果。",
|
||||
"inputType": "image",
|
||||
"maxImages": 1
|
||||
}
|
||||
40
model-configs/ltx-2.3text.json
Normal file
40
model-configs/ltx-2.3text.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"modelName": "LTX-2.3 文生视频",
|
||||
"modelDescription": "RunningHub LTX-2.3 文生视频模型(Text-to-Video),基于文本生成视频",
|
||||
"endpoint": "/rhart-video/ltx-2.3/text-to-video",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "select",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["1080p", "720p", "480p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["16:9", "9:16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "number",
|
||||
"label": "时长(秒)",
|
||||
"default": 5,
|
||||
"min": 5,
|
||||
"max": 15,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述你想生成的画面和动作。",
|
||||
"inputType": "text"
|
||||
}
|
||||
64
model-configs/vidu-start-end-to-video-q3-turbo.json
Normal file
64
model-configs/vidu-start-end-to-video-q3-turbo.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"modelName": "Vidu 首尾帧生视频 q3-turbo",
|
||||
"modelDescription": "RunningHub Vidu 首尾帧生视频 q3-turbo 模型,通过首尾帧图片驱动视频生成,支持音视频直出",
|
||||
"endpoint": "/vidu/start-end-to-video-q3-turbo",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true,
|
||||
"maxLength": 4000
|
||||
},
|
||||
{
|
||||
"name": "firstImageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "首帧图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 50
|
||||
},
|
||||
{
|
||||
"name": "lastImageUrl",
|
||||
"ui": "imageUpload",
|
||||
"label": "尾帧图片",
|
||||
"maxCount": 1,
|
||||
"required": true,
|
||||
"maxSizeMB": 50
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "5",
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["540p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "movementAmplitude",
|
||||
"ui": "select",
|
||||
"label": "运动幅度",
|
||||
"default": "auto",
|
||||
"options": ["auto", "small", "medium", "large"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"ui": "switch",
|
||||
"label": "音频直出",
|
||||
"default": true,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述首尾帧之间的动作补全。文本长度限制 1-4000。",
|
||||
"inputType": "image",
|
||||
"maxImages": 2
|
||||
}
|
||||
55
model-configs/vidu-text-to-video-q3-turbo.json
Normal file
55
model-configs/vidu-text-to-video-q3-turbo.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"modelName": "Vidu 文生视频 q3-turbo",
|
||||
"modelDescription": "RunningHub Vidu 文生视频 q3-turbo 模型,支持音视频直出",
|
||||
"endpoint": "/vidu/text-to-video-q3-turbo",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"ui": "textarea",
|
||||
"label": "提示词",
|
||||
"required": true,
|
||||
"maxLength": 4000
|
||||
},
|
||||
{
|
||||
"name": "style",
|
||||
"ui": "select",
|
||||
"label": "风格",
|
||||
"default": "general",
|
||||
"options": ["general", "anime"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"ui": "proportion",
|
||||
"label": "画面比例",
|
||||
"default": "16:9",
|
||||
"options": ["4:3", "3:4", "16:9", "9:16", "1:1"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"ui": "resolution",
|
||||
"label": "分辨率",
|
||||
"default": "720p",
|
||||
"options": ["540p", "720p", "1080p"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"ui": "select",
|
||||
"label": "时长(秒)",
|
||||
"default": "5",
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"ui": "switch",
|
||||
"label": "音频直出",
|
||||
"default": true,
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"promptPlaceholder": "描述你想生成的视频画面和音频。文本长度限制 1-4000。",
|
||||
"inputType": "text"
|
||||
}
|
||||
69
src/components/ParamGroup/index.vue
Normal file
69
src/components/ParamGroup/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<template v-for="param in dynamicParams" :key="param.name">
|
||||
<Select
|
||||
v-if="param.ui === 'select' || param.type === 'select'"
|
||||
:model-value="paramValues[param.name]"
|
||||
:options="(param.options || []).map(o => (typeof o === 'object' ? o : { value: o, label: String(o) }))"
|
||||
class="param-select"
|
||||
position="top"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="param-label">{{ param.label || param.name }}</span>
|
||||
</template>
|
||||
</Select>
|
||||
<SwitchControl
|
||||
v-else-if="param.ui === 'switch' || param.type === 'boolean' || param.type === 'Boolean'"
|
||||
:model-value="paramValues[param.name] === true || paramValues[param.name] === 'true'"
|
||||
:label="param.label || param.name"
|
||||
@update:model-value="(v) => paramValues[param.name] = v"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import SwitchControl from '@/components/SwitchControl/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
config: { type: Object, default: null },
|
||||
paramValues: { type: Object, default: () => ({}) },
|
||||
excludeNames: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
|
||||
const dynamicParams = computed(() => {
|
||||
if (!props.config?.params) return []
|
||||
return props.config.params.filter((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (props.excludeNames.includes(p.name)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.param-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
&:hover { background: #e9eaeb; }
|
||||
}
|
||||
:deep(.select-text) { font-size: 14px; }
|
||||
:deep(.dropdown-menu) { min-width: 136px; }
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.param-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -313,6 +313,7 @@ onBeforeUnmount(() => {
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
|
||||
81
src/components/SwitchControl/index.vue
Normal file
81
src/components/SwitchControl/index.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
class="switch-control"
|
||||
:class="{ active: modelValue }"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="switch-label">{{ label }}</span>
|
||||
<span class="switch-track">
|
||||
<span class="switch-thumb" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.switch-control {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
.switch-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.switch-track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #c0c4cc;
|
||||
transition: background 0.25s;
|
||||
flex-shrink: 0;
|
||||
|
||||
.active & {
|
||||
background: #000F33;
|
||||
}
|
||||
}
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.active & {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -32,6 +32,12 @@
|
||||
>
|
||||
<template v-if="useDisplay.Sender_variant !== 'default'" #prefix>
|
||||
<div class="prefix-self-wrap">
|
||||
<template v-for="ctrl in beforeModelControls" :key="ctrl.name">
|
||||
<component
|
||||
:is="ctrl.component"
|
||||
v-bind="ctrl.props(getCurrentConfig())"
|
||||
/>
|
||||
</template>
|
||||
<component
|
||||
:is="platform.ModelSelector"
|
||||
:model-value="platform.model.value"
|
||||
@ -40,7 +46,7 @@
|
||||
@update:model-value="platform.model.value = $event"
|
||||
@update:type-value="platform.modelType.value = $event"
|
||||
/>
|
||||
<template v-for="ctrl in visibleControls" :key="ctrl.name">
|
||||
<template v-for="ctrl in afterModelControls" :key="ctrl.name">
|
||||
<component
|
||||
:is="ctrl.component"
|
||||
v-bind="ctrl.props(getCurrentConfig())"
|
||||
@ -101,6 +107,9 @@ const visibleControls = computed(() => {
|
||||
return platform.value.controls.filter((c) => c.show(config))
|
||||
})
|
||||
|
||||
const beforeModelControls = computed(() => visibleControls.value.filter((c) => c.beforeModel))
|
||||
const afterModelControls = computed(() => visibleControls.value.filter((c) => !c.beforeModel))
|
||||
|
||||
const showUploader = computed(() => {
|
||||
return platform.value.showImageUploader()
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import VideoProportion from './controls/proportion.vue'
|
||||
import Time from './controls/time.vue'
|
||||
import VideoImageUploader from './imageUploader.vue'
|
||||
import VideoModelSelector from './modelSelector.vue'
|
||||
import ParamGroup from '@/components/ParamGroup/index.vue'
|
||||
|
||||
export function defineVideoPlatform() {
|
||||
const model = ref('LTX2.0')
|
||||
@ -68,7 +69,8 @@ export function defineVideoPlatform() {
|
||||
customHight,
|
||||
dimWidth,
|
||||
dimHeight,
|
||||
promptPlaceholder
|
||||
promptPlaceholder,
|
||||
duration
|
||||
}
|
||||
|
||||
function syncDefaults(config) {
|
||||
@ -83,6 +85,7 @@ export function defineVideoPlatform() {
|
||||
{
|
||||
name: 'pattern',
|
||||
component: markRaw(Pattern),
|
||||
beforeModel: true,
|
||||
show: () => true,
|
||||
props: () => ({
|
||||
'modelValue': videoPattern.value,
|
||||
@ -92,24 +95,62 @@ export function defineVideoPlatform() {
|
||||
{
|
||||
name: 'proportion',
|
||||
component: markRaw(VideoProportion),
|
||||
show: () => true,
|
||||
props: () => ({
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'proportionOptions': proportionOptions.value,
|
||||
'resolutionOptions': resolutionOptions.value
|
||||
})
|
||||
show: (config) => !!config?.params?.find((p) => p.ui === 'proportion'),
|
||||
props: (config) => {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
return {
|
||||
'modelValue': proportion.value,
|
||||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||
'resolution': resolution.value,
|
||||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||
'proportionOptions': (ratioParam?.options || []).map((o) => ({ value: o, label: o })),
|
||||
'resolutionOptions': (resParam?.options || []).map((o) => ({ value: o, label: o }))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
component: markRaw(Time),
|
||||
show: () => true,
|
||||
props: () => ({
|
||||
'modelValue': duration.value,
|
||||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||
'options': durationOptions.value
|
||||
show: (config) => !!config?.params?.find((p) => p.name === 'duration'),
|
||||
props: (config) => {
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
let options = []
|
||||
if (durationParam) {
|
||||
if (durationParam.ui === 'select' && durationParam.options) {
|
||||
options = durationParam.options.map((o) => ({ value: Number(o), label: `${o}s` }))
|
||||
} else if (durationParam.ui === 'number') {
|
||||
const min = durationParam.min || 1
|
||||
const max = durationParam.max || 16
|
||||
for (let i = min; i <= max; i++) {
|
||||
options.push({ value: i, label: `${i}s` })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
'modelValue': duration.value,
|
||||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||
'options': options.length > 0 ? options : durationOptions.value
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'paramGroup',
|
||||
component: markRaw(ParamGroup),
|
||||
show: (config) => {
|
||||
if (!config?.params) return false
|
||||
const handledUis = ['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']
|
||||
return config.params.some((p) => {
|
||||
if (handledUis.includes(p.ui)) return false
|
||||
if (p.name === 'resolution') return false
|
||||
if (p.name === 'duration') return false
|
||||
return true
|
||||
})
|
||||
},
|
||||
props: (config) => ({
|
||||
config,
|
||||
paramValues,
|
||||
excludeNames: ['resolution', 'duration']
|
||||
})
|
||||
}
|
||||
]
|
||||
@ -146,7 +187,7 @@ export function defineVideoPlatform() {
|
||||
},
|
||||
|
||||
getDefaultModel() {
|
||||
return 'LTX2.0'
|
||||
return '' // 模型列表加载后由 modelSelector 自动纠错设置
|
||||
},
|
||||
|
||||
validateBeforeSubmit() {
|
||||
@ -166,8 +207,11 @@ export function defineVideoPlatform() {
|
||||
|
||||
imageUploadLimit() {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParam = modelConfig.value.params.find((p) => p.ui === 'imageUpload')
|
||||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||||
const imageParams = modelConfig.value.params.filter((p) => p.ui === 'imageUpload')
|
||||
if (imageParams.length > 0) {
|
||||
return imageParams.reduce((sum, p) => sum + (p.maxCount || 1), 0)
|
||||
}
|
||||
return modelConfig.value.maxImages || 4
|
||||
},
|
||||
|
||||
isImageRequired() {
|
||||
@ -178,6 +222,17 @@ export function defineVideoPlatform() {
|
||||
syncParamValues()
|
||||
const modelParams = { ...paramValues }
|
||||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||||
|
||||
// 将上传的参考图映射到 imageUpload 参数(如首尾帧模型的 firstImageUrl / lastImageUrl)
|
||||
if (shared.referenceImages?.value?.length > 0) {
|
||||
const imageParams = modelConfig.value?.params?.filter((p) => p.ui === 'imageUpload') || []
|
||||
imageParams.forEach((p, i) => {
|
||||
if (shared.referenceImages.value[i]) {
|
||||
modelParams[p.name] = shared.referenceImages.value[i].url || shared.referenceImages.value[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return modelParams
|
||||
},
|
||||
|
||||
@ -188,6 +243,7 @@ export function defineVideoPlatform() {
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@ -31,30 +32,28 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const videoConfig = ref({})
|
||||
const platformModels = ref([])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/video.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
videoConfig.value = data
|
||||
const code = getPlatformCode('Video')
|
||||
const models = await fetchPlatformModels(code)
|
||||
platformModels.value = models || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video config:', error)
|
||||
console.error('加载视频模型列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchConfig()
|
||||
loadModels()
|
||||
|
||||
watch(() => videoConfig.value, (newConfig) => {
|
||||
const models = newConfig[props.videoPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find((m) => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
// 模型列表加载完成后,如果当前模型不可用则自动纠错
|
||||
watch(() => platformModels.value, (models) => {
|
||||
const tagModels = models.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find((m) => m.id === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
@ -69,8 +68,12 @@ const model = computed({
|
||||
})
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
return models
|
||||
const models = platformModels.value.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
return models.map((m) => ({
|
||||
value: m.id, // UUID 唯一标识
|
||||
label: m.display_name || m.name,
|
||||
disabled: m.disabled || false
|
||||
}))
|
||||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
@ -86,26 +89,26 @@ const getModelType = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
// pattern 切换时自动纠错
|
||||
watch(() => props.videoPattern, (newPattern) => {
|
||||
const models = videoConfig.value[newPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find((m) => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
const tagModels = platformModels.value.filter((m) => m.tags?.includes(newPattern))
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find((m) => m.id === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 外部 modelValue 变化时检查是否被禁用
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
const currentModel = models.find((m) => m.value === newValue)
|
||||
const tagModels = platformModels.value.filter((m) => m.tags?.includes(props.videoPattern))
|
||||
const currentModel = tagModels.find((m) => m.id === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const enabledModels = models.filter((m) => !m.disabled)
|
||||
const enabledModels = tagModels.filter((m) => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
model.value = enabledModels[0].value
|
||||
model.value = enabledModels[0].id
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@ -93,7 +93,7 @@ export async function getModelId(type, modelName) {
|
||||
const code = getPlatformCode(type)
|
||||
const models = await fetchPlatformModels(code)
|
||||
|
||||
const found = models.find((m) => m.name === modelName || m.display_name === modelName)
|
||||
const found = models.find((m) => m.id === modelName || m.name === modelName || m.display_name === modelName)
|
||||
return found?.id || ''
|
||||
}
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@ export function syncDefaults(config, state) {
|
||||
const ratioParam = config.params.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||||
|
||||
const resParam = config.params.find((p) => p.ui === 'resolution')
|
||||
const resParam = config.params.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) resolution.value = resParam.default || '2k'
|
||||
|
||||
const qtyParam = config.params.find((p) => p.ui === 'quantity')
|
||||
@ -95,6 +95,9 @@ export function syncDefaults(config, state) {
|
||||
const qualityParam = config.params.find((p) => p.name === 'quality')
|
||||
if (qualityParam) quality.value = qualityParam.default || 'medium'
|
||||
|
||||
const durationParam = config.params.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) state.duration.value = durationParam.default ?? 5
|
||||
|
||||
// 4. dimension 初始化
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
@ -134,7 +137,7 @@ export function syncParamValues(config, state) {
|
||||
const ratioParam = config?.params?.find((p) => p.ui === 'proportion')
|
||||
if (ratioParam) paramValues[ratioParam.name] = proportion.value
|
||||
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution')
|
||||
const resParam = config?.params?.find((p) => p.ui === 'resolution' || (p.ui === 'select' && p.name === 'resolution'))
|
||||
if (resParam) paramValues[resParam.name] = resolution.value
|
||||
|
||||
const qtyParam = config?.params?.find((p) => p.ui === 'quantity')
|
||||
@ -150,6 +153,9 @@ export function syncParamValues(config, state) {
|
||||
paramValues.quality = quality.value
|
||||
}
|
||||
|
||||
const durationParam = config?.params?.find((p) => p.name === 'duration')
|
||||
if (durationParam && state.duration) paramValues[durationParam.name] = state.duration.value
|
||||
|
||||
const dc = getDimConfig(config)
|
||||
if (dc?.type === 'split') {
|
||||
paramValues[dc.wParam.name] = dimWidth.value
|
||||
|
||||
Loading…
Reference in New Issue
Block a user