feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)
三栏布局 - ResizeObserver 等比例分配替代固定默认值恢复 - 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算) - containerRef.current 动态读取 DOM 替代闭包 el Seedance2Content - 从占位文本重构为内容编排控件(文本+图片/视频/音频上传) - 根据 spec.image_files/video_files/audio_files 动态渲染上传区域 - 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url) 图片上传兼容视频文件 - buildAccept 根据 file_filter 区分 image/video 前缀 - createMediaBeforeUpload 动态放行 video/* MIME - 公共模块 media-upload-utils.ts 本地存储基建 - sql.js (WASM SQLite) + safeStorage 加密持久化 - 8个IPC文件操作通道 + resolveSafe 路径穿越防护 - <userData>/heixiu-data/ 沙箱 + JWT用户隔离 - 7表DDL + 游标分页 + LRU媒体淘汰 其他 - 修复 errorMessageRef 渲染期写入警告 - 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充 - CSP wasm-unsafe-eval + https: CDN媒体源
This commit is contained in:
@@ -108,6 +108,8 @@ export function HomeContent() {
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const rightWidthRef = useRef(rightWidth);
|
||||
const draggingRef = useRef(dragging);
|
||||
// 标记拖拽期间被跳过的重算,拖拽结束后补算
|
||||
const needsRecalcRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
leftWidthRef.current = leftWidth;
|
||||
@@ -147,6 +149,7 @@ export function HomeContent() {
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
draggingRef.current = null; // 即时清除 —— 优先级高于 React 状态,避免 ResizeObserver 被永久阻塞
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
@@ -159,41 +162,97 @@ export function HomeContent() {
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// ======== 视口缩小自动收窄面板 ========
|
||||
// ======== 视口缩放时按比例自动调整面板宽度 ========
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// 拖拽中由 mousemove handler 处理约束,避免冲突
|
||||
if (draggingRef.current) return;
|
||||
// 拖拽中由 mousemove handler 处理约束,避免冲突;
|
||||
// 若跳过则标记 needsRecalcRef,拖拽结束后补算
|
||||
if (draggingRef.current) {
|
||||
needsRecalcRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次回调从 ref 读取当前 DOM 元素(避免闭包捕获 mount 时的 el)
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const containerWidth = el.clientWidth;
|
||||
|
||||
const currentLeft = leftWidthRef.current;
|
||||
const currentRight = rightWidthRef.current;
|
||||
|
||||
// 检查当前总宽度是否超出容器
|
||||
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
|
||||
if (totalNeeded <= containerWidth) return;
|
||||
// 左右面板可用的总空间(中列至少保留 CENTER_MIN)
|
||||
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
|
||||
const sideTotal = currentLeft + currentRight;
|
||||
|
||||
// 超出容器 → 优先收缩右侧面板,再收左侧
|
||||
const deficit = totalNeeded - containerWidth;
|
||||
const rightSlack = currentRight - RIGHT_MIN;
|
||||
if (sideAvailable <= 0 || sideTotal <= 0) return;
|
||||
|
||||
if (rightSlack >= deficit) {
|
||||
setRightWidth(currentRight - deficit);
|
||||
} else {
|
||||
setRightWidth(RIGHT_MIN);
|
||||
const remainingDeficit = deficit - rightSlack;
|
||||
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
|
||||
// ---- 按当前比例分配空间 ----
|
||||
const ratio = currentLeft / sideTotal;
|
||||
let newLeft = Math.round(sideAvailable * ratio);
|
||||
let newRight = sideAvailable - newLeft;
|
||||
|
||||
// ---- 最小值约束(保持比例的前提下不跌破下限) ----
|
||||
if (newLeft < LEFT_MIN) {
|
||||
newLeft = LEFT_MIN;
|
||||
newRight = sideAvailable - newLeft;
|
||||
}
|
||||
if (newRight < RIGHT_MIN) {
|
||||
newRight = RIGHT_MIN;
|
||||
newLeft = sideAvailable - newRight;
|
||||
}
|
||||
// 两侧同时触底 → 保持最小值,中列由 CSS min-width 兜底
|
||||
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
|
||||
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
|
||||
|
||||
// 即时更新 ref,避免 ResizeObserver 连续触发时读到过期值
|
||||
leftWidthRef.current = newLeft;
|
||||
rightWidthRef.current = newRight;
|
||||
|
||||
if (newLeft !== currentLeft) setLeftWidth(newLeft);
|
||||
if (newRight !== currentRight) setRightWidth(newRight);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 拖拽结束后,若 ResizeObserver 曾因拖拽跳过重算,立即补算
|
||||
useEffect(() => {
|
||||
if (dragging !== null) return; // 仍在拖拽中
|
||||
if (!needsRecalcRef.current) return;
|
||||
|
||||
needsRecalcRef.current = false;
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const containerWidth = el.clientWidth;
|
||||
const currentLeft = leftWidthRef.current;
|
||||
const currentRight = rightWidthRef.current;
|
||||
|
||||
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
|
||||
const sideTotal = currentLeft + currentRight;
|
||||
if (sideAvailable <= 0 || sideTotal <= 0) return;
|
||||
|
||||
const ratio = currentLeft / sideTotal;
|
||||
let newLeft = Math.round(sideAvailable * ratio);
|
||||
let newRight = sideAvailable - newLeft;
|
||||
|
||||
if (newLeft < LEFT_MIN) { newLeft = LEFT_MIN; newRight = sideAvailable - newLeft; }
|
||||
if (newRight < RIGHT_MIN) { newRight = RIGHT_MIN; newLeft = sideAvailable - newRight; }
|
||||
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
|
||||
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
|
||||
|
||||
leftWidthRef.current = newLeft;
|
||||
rightWidthRef.current = newRight;
|
||||
|
||||
if (newLeft !== currentLeft) setLeftWidth(newLeft);
|
||||
if (newRight !== currentRight) setRightWidth(newRight);
|
||||
}, [dragging]);
|
||||
|
||||
// ======== 分隔条样式工厂 ========
|
||||
|
||||
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
||||
|
||||
@@ -14,45 +14,22 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||
import { Upload, Switch, Space, Typography } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
import { buildAccept, createMediaBeforeUpload } from './media-upload-utils';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 创建图片上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||
*/
|
||||
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||
return (file: RcFile) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建图片 accept 字符串 */
|
||||
function buildAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||
return filters.map((ext) => `image/${ext}`).join(',');
|
||||
}
|
||||
|
||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||
const ENABLED_EMPTY: UploadFile[] = [];
|
||||
|
||||
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||
|
||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||
@@ -95,7 +72,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
maxCount={1}
|
||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||
accept={buildAccept(filters)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
@@ -105,7 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
}
|
||||
}}
|
||||
disabled={isUploadDisabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||
>
|
||||
{fileList.length < 1 && (
|
||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// ============================================================
|
||||
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
||||
// 用于图生图模型的多图输入场景
|
||||
// ImageListInput — imageList → Dragger(多张图片/视频拖拽上传)
|
||||
// 用于图生图/图生视频模型的多图输入场景
|
||||
// 当 file_filter 包含视频扩展名时,自动支持视频文件上传
|
||||
// ============================================================
|
||||
|
||||
import { Upload } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './ImageInput';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from './media-upload-utils';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -17,32 +18,31 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
const hasVideo = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
const mediaLabel = hasVideo ? '图片/视频' : '图片';
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
listType="picture"
|
||||
accept={
|
||||
filters
|
||||
? filters.map((ext) => `image/${ext}`).join(',')
|
||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||
}
|
||||
accept={buildAccept(filters)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||
<p className="ant-upload-text">点击或拖拽{mediaLabel}到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||
,单张不超过 {maxSizeMb}MB
|
||||
,单文件不超过 {maxSizeMb}MB
|
||||
</p>
|
||||
</Dragger>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,248 @@
|
||||
// ============================================================
|
||||
// SeedanceContent — seedance2Content → 自定义内容编排控件
|
||||
// SeedanceContent — seedance2Content → 内容编排控件
|
||||
//
|
||||
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)
|
||||
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
|
||||
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)。
|
||||
// 内部值结构(JSON 字符串 ⟷ 表单字段值):
|
||||
// { text: string; imageUrls: string[]; videoUrls: string[]; audioUrls: string[] }
|
||||
//
|
||||
// TODO: 当前为占位实现,后续需完善拖拽编排能力
|
||||
// 从 param_schema.spec 读取 image_files / video_files / audio_files
|
||||
// 决定各媒体类型的上传数量上限和是否显示对应上传区域。
|
||||
// ============================================================
|
||||
|
||||
import { Input, Typography, theme as antTheme } from 'antd';
|
||||
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Upload, Input, Typography, message, theme as antTheme } from 'antd';
|
||||
import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
const { Text } = Typography;
|
||||
|
||||
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const { token } = antTheme.useToken();
|
||||
// ---------- 内部值类型 ----------
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
background: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
内容编排 — 描述动作 + 参考素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
(config.placeholder as string) ||
|
||||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
|
||||
}
|
||||
maxLength={(config.maxLength as number) || 5000}
|
||||
showCount
|
||||
/>
|
||||
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
|
||||
💡 更多内容编排能力即将上线:支持文本 + 参考图 + 参考视频自由组合
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
interface ContentValue {
|
||||
text: string;
|
||||
imageUrls: string[];
|
||||
videoUrls: string[];
|
||||
audioUrls: string[];
|
||||
}
|
||||
|
||||
// ---------- 辅助函数 ----------
|
||||
|
||||
function parseValue(raw: unknown): ContentValue {
|
||||
if (typeof raw === 'string' && raw.startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<ContentValue>;
|
||||
return {
|
||||
text: obj.text || '',
|
||||
imageUrls: Array.isArray(obj.imageUrls) ? obj.imageUrls : [],
|
||||
videoUrls: Array.isArray(obj.videoUrls) ? obj.videoUrls : [],
|
||||
audioUrls: Array.isArray(obj.audioUrls) ? obj.audioUrls : [],
|
||||
};
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return { text: typeof raw === 'string' ? raw : '', imageUrls: [], videoUrls: [], audioUrls: [] };
|
||||
}
|
||||
|
||||
function serializeValue(cv: ContentValue): string {
|
||||
return JSON.stringify(cv);
|
||||
}
|
||||
|
||||
/** URL 数组 → UploadFile 数组(全部标记为 done) */
|
||||
function urlsToFileList(urls: string[], type: string): UploadFile[] {
|
||||
return urls.map((url, i) => ({
|
||||
uid: `${type}-${i}-${url.slice(-20)}`,
|
||||
name: url.split('/').pop() || `${type}_${i + 1}`,
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 从 UploadFile 数组中提取已上传成功的 URL */
|
||||
function extractUrls(fileList: UploadFile[]): string[] {
|
||||
return fileList
|
||||
.filter(f => f.status === 'done')
|
||||
.map(f => (f.response as { url?: string })?.url || f.url || '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- 上传区域子组件 ----------
|
||||
|
||||
interface UploadSectionProps {
|
||||
type: 'image' | 'video' | 'audio';
|
||||
urls: string[];
|
||||
maxCount: number;
|
||||
maxSizeMb: number;
|
||||
disabled: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
customRequest: ReturnType<typeof useFileUpload>['customRequest'];
|
||||
onChange: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
function UploadSection({
|
||||
type, urls, maxCount, maxSizeMb, disabled, icon, label, customRequest, onChange,
|
||||
}: UploadSectionProps) {
|
||||
const fileList = useMemo(() => urlsToFileList(urls, type), [urls, type]);
|
||||
|
||||
const beforeUpload = useCallback((file: RcFile) => {
|
||||
const mimePrefix = type === 'image' ? 'image/' : type === 'video' ? 'video/' : 'audio/';
|
||||
if (!file.type.startsWith(mimePrefix)) {
|
||||
message.error(`只能上传${label}文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`${label}大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}, [type, label, maxSizeMb]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(info: { fileList: UploadFile[] }) => {
|
||||
onChange(extractUrls(info.fileList));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
if (maxCount <= 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
{icon} {label}(最多 {maxCount} 个)
|
||||
</Text>
|
||||
<Dragger
|
||||
multiple={maxCount > 1}
|
||||
listType="picture"
|
||||
accept={type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'audio/*'}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
beforeUpload={beforeUpload}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽{label}到此区域上传</p>
|
||||
<p className="ant-upload-hint">单文件不超过 {maxSizeMb}MB</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 主组件 ----------
|
||||
|
||||
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const { token } = antTheme.useToken();
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
const cv = useMemo(() => parseValue(value), [value]);
|
||||
|
||||
const imageFiles = (config.imageFiles as number) || 0;
|
||||
const videoFiles = (config.videoFiles as number) || 0;
|
||||
const audioFiles = (config.audioFiles as number) || 0;
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
|
||||
|
||||
const emit = useCallback(
|
||||
(patch: Partial<ContentValue>) => {
|
||||
const next = { ...cv, ...patch };
|
||||
onChange?.(serializeValue(next));
|
||||
},
|
||||
[cv, onChange],
|
||||
);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
emit({ text: e.target.value });
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
background: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
{(imageFiles > 0) && <PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
{(videoFiles > 0) && <VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
{(audioFiles > 0) && <AudioOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
内容编排 — 描述动作 + 参考素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={cv.text}
|
||||
onChange={handleTextChange}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
(config.placeholder as string) ||
|
||||
'输入视频内容的文字描述'
|
||||
}
|
||||
maxLength={(config.maxLength as number) || 5000}
|
||||
showCount
|
||||
/>
|
||||
|
||||
{/* 参考图片上传 */}
|
||||
{imageFiles > 0 && (
|
||||
<UploadSection
|
||||
type="image"
|
||||
urls={cv.imageUrls}
|
||||
maxCount={imageFiles}
|
||||
maxSizeMb={maxSizeMb}
|
||||
disabled={disabled ?? false}
|
||||
icon={<PictureOutlined />}
|
||||
label="参考图片"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ imageUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参考视频上传 */}
|
||||
{videoFiles > 0 && (
|
||||
<UploadSection
|
||||
type="video"
|
||||
urls={cv.videoUrls}
|
||||
maxCount={videoFiles}
|
||||
maxSizeMb={Math.max(maxSizeMb, 100)}
|
||||
disabled={disabled ?? false}
|
||||
icon={<VideoCameraOutlined />}
|
||||
label="参考视频"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ videoUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参考音频上传 */}
|
||||
{audioFiles > 0 && (
|
||||
<UploadSection
|
||||
type="audio"
|
||||
urls={cv.audioUrls}
|
||||
maxCount={audioFiles}
|
||||
maxSizeMb={maxSizeMb}
|
||||
disabled={disabled ?? false}
|
||||
icon={<AudioOutlined />}
|
||||
label="参考音频"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ audioUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
src/pages/home/controls/VideoInput.tsx
Normal file
119
src/pages/home/controls/VideoInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// ============================================================
|
||||
// VideoInput — videoInput → Upload(单个视频)
|
||||
// 用于视频参考等单视频上传场景
|
||||
//
|
||||
// enable_switch 支持:
|
||||
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
||||
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
||||
// 开关关闭时清除已上传文件。
|
||||
//
|
||||
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
||||
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
||||
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
||||
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||
import { VideoCameraOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 创建视频上传前的类型 & 大小校验函数。
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 50
|
||||
*/
|
||||
export function createVideoBeforeUpload(maxSizeMb: number = 50) {
|
||||
return (file: RcFile) => {
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
if (!isVideo) {
|
||||
message.error('只能上传视频文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建视频 accept 字符串 */
|
||||
function buildVideoAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'video/mp4,video/webm,video/quicktime,video/x-msvideo';
|
||||
return filters.map((ext) => `video/${ext}`).join(',');
|
||||
}
|
||||
|
||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||
const ENABLED_EMPTY: UploadFile[] = [];
|
||||
|
||||
export function VideoInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
|
||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||
|
||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
// 有 enable_switch 时默认禁用,需手动开启
|
||||
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||
const isUploadDisabled = disabled || !switchOn;
|
||||
|
||||
const handleSwitchChange = useCallback(
|
||||
(on: boolean) => {
|
||||
setSwitchOn(on);
|
||||
if (!on) {
|
||||
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
||||
onChange?.(undefined);
|
||||
} else {
|
||||
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
||||
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||
{hasEnableSwitch && (
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={switchOn}
|
||||
onChange={handleSwitchChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{switchOn ? '已启用' : '点击开关启用上传'}
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
maxCount={1}
|
||||
accept={buildVideoAccept(config.fileFilter as string[] | undefined)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
// 仅当开关开启时上报文件变化
|
||||
if (switchOn || !hasEnableSwitch) {
|
||||
onChange?.(newList as unknown as UploadFile[]);
|
||||
}
|
||||
}}
|
||||
disabled={isUploadDisabled}
|
||||
beforeUpload={createVideoBeforeUpload(maxSizeMb)}
|
||||
>
|
||||
{fileList.length < 1 && (
|
||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||
<VideoCameraOutlined />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传视频</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { DoubleSpinBox } from './DoubleSpinBox';
|
||||
import { ImageInput } from './ImageInput';
|
||||
import { ImageListInput } from './ImageListInput';
|
||||
import { AudioListInput } from './AudioListInput';
|
||||
import { VideoInput } from './VideoInput';
|
||||
import { SeedanceContent } from './SeedanceContent';
|
||||
|
||||
export type { WidgetProps } from './types';
|
||||
@@ -45,6 +46,7 @@ export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
||||
imageInput: ImageInput,
|
||||
imageList: ImageListInput,
|
||||
audioList: AudioListInput,
|
||||
videoInput: VideoInput,
|
||||
seedance2Content: SeedanceContent,
|
||||
};
|
||||
|
||||
|
||||
62
src/pages/home/controls/media-upload-utils.ts
Normal file
62
src/pages/home/controls/media-upload-utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// ============================================================
|
||||
// media-upload-utils — ImageInput / ImageListInput / VideoInput 共享工具
|
||||
// ============================================================
|
||||
|
||||
import { message, Upload } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
|
||||
/** 常见视频扩展名,用于 buildAccept / createMediaBeforeUpload 区分图片/视频 */
|
||||
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']);
|
||||
|
||||
/**
|
||||
* 构建媒体文件 accept 字符串,自动区分 image/ video/ 前缀。
|
||||
*
|
||||
* @example
|
||||
* buildAccept(['png', 'jpg']) → "image/png,image/jpg"
|
||||
* buildAccept(['mp4', 'webm']) → "video/mp4,video/webm"
|
||||
* buildAccept([]) → "image/png,image/jpg,image/jpeg,image/webp"
|
||||
*/
|
||||
export function buildAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||
return filters.map((ext) => {
|
||||
if (VIDEO_EXTENSIONS.has(ext.toLowerCase())) return `video/${ext}`;
|
||||
return `image/${ext}`;
|
||||
}).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)。
|
||||
*
|
||||
* 当 file_filter 包含视频扩展名时,自动放行 video/* MIME 文件。
|
||||
* 不传入 filters 时仅接受 image/*(向后兼容)。
|
||||
*
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||
* @param filters API spec.file_filter 数组
|
||||
*/
|
||||
export function createMediaBeforeUpload(maxSizeMb: number = 10, filters?: string[]) {
|
||||
const hasVideoFilters = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||
|
||||
return (file: RcFile) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isVideo && hasVideoFilters) {
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isImage) {
|
||||
message.error(hasVideoFilters ? '只能上传图片或视频文件' : '只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||
// ============================================================
|
||||
|
||||
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {useState, useLayoutEffect, useMemo, useCallback, useRef, useEffect} from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
@@ -156,7 +156,9 @@ export function ModelInputForm() {
|
||||
const submitCountRef = useRef(0);
|
||||
/** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */
|
||||
const errorMessageRef = useRef(errorMessage);
|
||||
errorMessageRef.current = errorMessage;
|
||||
useEffect(() => {
|
||||
errorMessageRef.current = errorMessage;
|
||||
}, [errorMessage]);
|
||||
|
||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||
|
||||
@@ -234,6 +236,35 @@ export function ModelInputForm() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// seedance2Content:将内部 JSON 值序列化为 API 要求的 ContentItem[]
|
||||
if (field.widgetName === 'seedance2Content') {
|
||||
try {
|
||||
const cv = JSON.parse(value as string) as {
|
||||
text?: string;
|
||||
imageUrls?: string[];
|
||||
videoUrls?: string[];
|
||||
audioUrls?: string[];
|
||||
};
|
||||
const items: Record<string, unknown>[] = [];
|
||||
if (cv.text) {
|
||||
items.push({ type: 'text', text: cv.text });
|
||||
}
|
||||
for (const url of (cv.imageUrls || [])) {
|
||||
items.push({ type: 'image_url', role: 'reference_image', image_url: { url } });
|
||||
}
|
||||
for (const url of (cv.videoUrls || [])) {
|
||||
items.push({ type: 'video_url', role: 'reference_video', video_url: { url } });
|
||||
}
|
||||
for (const url of (cv.audioUrls || [])) {
|
||||
items.push({ type: 'audio_url', role: 'reference_audio', audio_url: { url } });
|
||||
}
|
||||
if (items.length > 0) {
|
||||
params[field.name] = JSON.stringify(items);
|
||||
}
|
||||
} catch { /* ignore parse error */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
params[field.name] = String(value);
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// ============================================================
|
||||
// OutputPreview — 任务输出预览(图片 / 视频)
|
||||
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
||||
//
|
||||
// 媒体加载策略:
|
||||
// - <img>/<video> 直接加载远程 URL(无 CORS 限制,与 axios/fetch 不同)
|
||||
// - 类型检测:URL 扩展名正则 + output_type 补充(无扩展名 CDN 链接)
|
||||
// - 加载失败:<Image fallback> 显示占位图;<video onError> 静默(浏览器内置错误提示)
|
||||
//
|
||||
// 注意:CDN 签名过期(403 SignatureExpired)的 HTTP 级检测需要 CORS
|
||||
// 响应头支持,当前 CDN 不具备。后续可通过主进程 Node.js HTTP(无 CORS)
|
||||
// 实现精确检测。
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
@@ -29,17 +38,41 @@ const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }>
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
/**
|
||||
* 通过 URL 扩展名判断是否为视频。
|
||||
* 对 CDN 直连链接(无扩展名)返回 false,此时由 output_type 兜底。
|
||||
*/
|
||||
function isVideoUrl(url: string): boolean {
|
||||
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
|
||||
/**
|
||||
* 分离图片/视频 URL。
|
||||
*
|
||||
* 策略(优先级从高到低):
|
||||
* 1. URL 正则匹配(对带扩展名的链接精确识别)
|
||||
* 2. output_type 兜底(CDN 无扩展名链接):
|
||||
* - output_type='video' → 首个资产按视频处理,其余按图片
|
||||
* - output_type='image' / 'audio' → 全部按图片处理
|
||||
*/
|
||||
function splitAssets(
|
||||
assets: Array<{ url: string }>,
|
||||
outputType?: string,
|
||||
): { images: string[]; videos: string[] } {
|
||||
const images: string[] = [];
|
||||
const videos: string[] = [];
|
||||
|
||||
assets.forEach(({ url }) => {
|
||||
if (isVideoUrl(url)) videos.push(url);
|
||||
else images.push(url);
|
||||
if (isVideoUrl(url)) {
|
||||
videos.push(url);
|
||||
} else if (outputType === 'video' && videos.length === 0) {
|
||||
// CDN 无扩展名链接,由 output_type 提示首个为视频
|
||||
videos.push(url);
|
||||
} else {
|
||||
images.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
return { images, videos };
|
||||
}
|
||||
|
||||
@@ -53,16 +86,12 @@ export function OutputPreview() {
|
||||
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
|
||||
|
||||
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
|
||||
// 不再独立轮询 getTaskStatus(避免与 TaskHistory 的批量轮询重复请求),
|
||||
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
|
||||
|
||||
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||
const prev = prevStatusRef.current;
|
||||
|
||||
// 从非终态变为终态 → 刷新详情获取完整数据(output_assets 等)
|
||||
if (
|
||||
prev &&
|
||||
POLLING_STATUSES.includes(prev) &&
|
||||
@@ -75,12 +104,12 @@ export function OutputPreview() {
|
||||
prevStatusRef.current = status;
|
||||
}, [selectedTask?.status, refetch]);
|
||||
|
||||
// 从 output_assets 提取图片/视频
|
||||
// 按类型分类(URL 正则 + output_type 兜底)
|
||||
const { images, videos } = useMemo(() => {
|
||||
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
||||
return { images: [], videos: [] };
|
||||
}
|
||||
return splitAssets(detail.output_assets);
|
||||
return splitAssets(detail.output_assets, detail.output_type);
|
||||
}, [detail]);
|
||||
|
||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||
@@ -123,7 +152,7 @@ export function OutputPreview() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 加载中 ----
|
||||
// ---- 加载中(任务详情) ----
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
@@ -133,7 +162,7 @@ export function OutputPreview() {
|
||||
}
|
||||
|
||||
// ---- 无结果 ----
|
||||
if (!detail || images.length + videos.length === 0) {
|
||||
if (!detail || (images.length + videos.length === 0)) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
@@ -167,22 +196,28 @@ export function OutputPreview() {
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{videos.map((url, i) => (
|
||||
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
|
||||
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
|
||||
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}
|
||||
onError={() => console.debug('[OutputPreview] 视频加载失败:', url)}
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
{images.length === 1 ? (
|
||||
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
|
||||
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }}
|
||||
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNjMGMwYzAiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
|
||||
/>
|
||||
) : (
|
||||
<Image.PreviewGroup>
|
||||
{images.map((url, i) => (
|
||||
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
|
||||
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
|
||||
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }}
|
||||
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNjMGMwYzAiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
|
||||
/>
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface UIFieldConfig {
|
||||
index: number;
|
||||
/** 表单字段名(map_to),对应 Form.Item name */
|
||||
name: string;
|
||||
/** API 声明的 widget 名称(如 "seedance2Content"),供提交时做特殊序列化 */
|
||||
widgetName: string;
|
||||
/** 要渲染的 React 组件 */
|
||||
component: ComponentType<WidgetProps>;
|
||||
/** 显示标签(ui.label → Form.Item label) */
|
||||
@@ -58,7 +60,7 @@ export interface UIFieldConfig {
|
||||
|
||||
// ---------- 文件类型 widget 集合 ----------
|
||||
|
||||
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
|
||||
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList', 'videoInput']);
|
||||
|
||||
// ---------- 约束解析 ----------
|
||||
|
||||
@@ -138,6 +140,11 @@ export function useModelUI(
|
||||
minFiles: spec.min_files,
|
||||
maxFiles: spec.max_files,
|
||||
enableSwitch: ui.enable_switch,
|
||||
// seedance2Content:spec 中的媒体文件数量 + 上传供应商
|
||||
imageFiles: (spec.image_files as number) ?? 0,
|
||||
videoFiles: (spec.video_files as number) ?? 0,
|
||||
audioFiles: (spec.audio_files as number) ?? 0,
|
||||
uploadProvider: spec.upload_provider as string | undefined,
|
||||
};
|
||||
|
||||
const isFile = FILE_WIDGETS.has(widget);
|
||||
@@ -148,6 +155,7 @@ export function useModelUI(
|
||||
return {
|
||||
index,
|
||||
name,
|
||||
widgetName: widget,
|
||||
component,
|
||||
label: (ui.label as string) || name,
|
||||
must: (spec.required as boolean) || false,
|
||||
|
||||
Reference in New Issue
Block a user