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:
2026-06-18 19:22:28 +08:00
parent e1c8eff946
commit e4c9ae8fc6
42 changed files with 2640 additions and 126 deletions

View File

@@ -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 => {

View File

@@ -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 }}>

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = 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>
);
}

View File

@@ -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,
};

View 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;
};
}

View File

@@ -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]);
// ======== useModelUIparam_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;

View File

@@ -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>
);
}
}

View File

@@ -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,
// seedance2Contentspec 中的媒体文件数量 + 上传供应商
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,