feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理

This commit is contained in:
ddCat
2026-08-06 15:34:45 +08:00
parent a18673a101
commit 9ca9dd9df6
36 changed files with 1692 additions and 401 deletions

View File

@@ -7,6 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -45,7 +46,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const mode = node.metadata?.generationMode || "image";
const simpleMode = workspaceMode === "simple";
const config = buildNodeConfig(globalConfig, node, mode);
const operationOptions = node.metadata?.videoEditOperation === "concat" ? [...videoOperationOptions, { label: "合并成片", value: "concat" as const }] : videoOperationOptions;
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
@@ -53,7 +55,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : "";
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
@@ -153,6 +156,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</div>
)}
{capabilityError ? <div className="mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
@@ -213,16 +218,18 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined;
return {
...globalConfig,
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
size: normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false",
videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
@@ -238,6 +245,15 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
return { [key]: value };
}
function videoCapabilityError(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, seconds: string, prompt: string, input: CanvasConfigNodePanelProps["inputSummary"], operation?: string) {
if (!videoDurationAllowed(profile, Number(seconds))) return "当前模型不支持该视频时长";
if (Array.from(prompt).length > profile.references.promptMaxChars) return `提示词超过模型限制(最多 ${profile.references.promptMaxChars} 字)`;
if (input.imageCount > profile.references.maxImages || input.videoCount > profile.references.maxVideos || input.audioCount > profile.references.maxAudios) return "参考素材数量超过当前模型限制";
const resolvedOperation = operation || (input.audioCount > 0 && input.imageCount === 0 && input.videoCount === 0 ? "audio_to_video" : input.videoCount > 0 ? "extend" : input.imageCount > 0 ? "image_to_video" : "text_to_video");
if (!profile.operations.includes(resolvedOperation)) return "当前模型不支持该生成模式";
return "";
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };

View File

@@ -77,7 +77,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)";
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
const composerMinHeight = activeReferenceCount ? 82 : 58;
const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
const isSubmitDisabled = !isRunning && !prompt.trim();
const canExpandPrompt = mode === "image" || mode === "video";
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
@@ -248,7 +248,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
{renderComposerHeader(false)}
<div
className="relative mt-2 flex max-h-36 flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
className="relative mt-2 flex max-h-[var(--prompt-panel-input-max-height)] flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
style={{ height: composerHeight, background: composerSurface, boxShadow: composerShadow }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />

View File

@@ -462,18 +462,25 @@ export const CanvasNode = React.memo(function CanvasNode({
{data.metadata?.versionLabel ? (
<button
type="button"
className="absolute left-3 top-3 z-[var(--node-z-overlay)] inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur transition hover:brightness-110"
style={{ background: theme.toolbar.panel, borderColor: data.metadata.versionPrimary ? theme.node.activeStroke : theme.toolbar.border, color: data.metadata.versionPrimary ? theme.node.activeStroke : theme.node.text }}
title="查看版本对比"
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
style={{
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
boxShadow: data.metadata.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : "var(--shadow-sm)",
outlineColor: theme.accent.primary,
}}
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
>
<Star className={`size-3 ${data.metadata.versionPrimary ? "fill-current" : ""}`} />{data.metadata.versionLabel}
{data.metadata.versionLabel}
</button>
) : null}
{showStatusTrack ? (
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}

View File

@@ -12,10 +12,10 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
{versions.map((node) => (
<article key={node.id} className="overflow-hidden rounded-lg border" style={{ borderColor: node.metadata?.versionPrimary ? theme.node.activeStroke : theme.node.stroke, background: theme.node.panel }}>
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-bold" style={{ background: theme.toolbar.itemHover }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />主版本</span> : null}
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
</div>
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="size-full object-contain" muted preload="metadata" aria-label={node.title || "版本视频"} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}

View File

@@ -0,0 +1,118 @@
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
import type { ReactNode } from "react";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol } from "@/lib/model-protocols";
const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
const resolutionOptions = ["480p", "720p", "1080p", "2160p"];
const operationOptions = [
{ label: "文生视频", value: "text_to_video" },
{ label: "图生视频", value: "image_to_video" },
{ label: "视频续写", value: "extend" },
{ label: "局部修改", value: "inpaint" },
{ label: "元素替换", value: "replace_element" },
{ label: "运镜调整", value: "camera_motion" },
{ label: "风格迁移", value: "style_transfer" },
{ label: "音频生视频", value: "audio_to_video" },
];
type Props = {
value?: ModelCapabilityConfig;
onChange?: (value: ModelCapabilityConfig) => void;
protocol?: ModelProtocol;
disabled?: boolean;
};
export function ModelCapabilityEditor({ value, onChange, protocol, disabled = false }: Props) {
const profile = value?.video || defaultModelCapabilityConfig(protocol).video!;
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
const durationValues = (profile.duration.values || []).join(",");
return (
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
<div className="flex items-center justify-between gap-3">
<div><div className="text-sm font-medium">视频能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">这些参数会同步到创造页、画布和生成校验</div></div>
<span className="text-[var(--fs-tiny)] text-foreground/40">协议模板可继续调整</span>
</div>
<CapabilityGroup title="引用限制">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(value) => updateReferences({ promptMaxChars: value || 1 })} />
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImages: value || 0 })} />
<NumberField label="图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImageBytes: mbToBytes(value) })} />
<NumberField label="最大视频引用" value={profile.references.maxVideos} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideos: value || 0 })} />
<NumberField label="视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoBytes: mbToBytes(value) })} />
<NumberField label="视频最长秒数" value={profile.references.maxVideoDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoDurationSeconds: value || 0 })} />
<NumberField label="最大音频引用" value={profile.references.maxAudios} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudios: value || 0 })} />
<NumberField label="音频上限 MB" value={bytesToMB(profile.references.maxAudioBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioBytes: mbToBytes(value) })} />
<NumberField label="音频最长秒数" value={profile.references.maxAudioDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioDurationSeconds: value || 0 })} />
</div>
</CapabilityGroup>
<CapabilityGroup title="视频时长">
<Segmented block disabled={disabled} value={profile.duration.selection} options={[{ label: "范围", value: "range" }, { label: "固定值", value: "enum" }]} onChange={(value) => updateDuration(value === "enum" ? { selection: "enum", values: profile.duration.values?.length ? profile.duration.values : [profile.duration.default] } : { selection: "range", min: profile.duration.min || 1, max: profile.duration.max || 15, step: profile.duration.step || 1 })} />
{profile.duration.selection === "enum" ? (
<Field label="固定时长(秒)"><Input disabled={disabled} value={durationValues} placeholder="例如:5,10" onChange={(event) => updateDuration({ values: parseIntegerList(event.target.value), default: parseIntegerList(event.target.value)[0] || profile.duration.default })} /></Field>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NumberField label="最短" value={profile.duration.min} min={1} disabled={disabled} onChange={(value) => updateDuration({ min: value || 1 })} />
<NumberField label="最长" value={profile.duration.max} min={1} disabled={disabled} onChange={(value) => updateDuration({ max: value || 1 })} />
<NumberField label="步长" value={profile.duration.step} min={1} disabled={disabled} onChange={(value) => updateDuration({ step: value || 1 })} />
<NumberField label="默认" value={profile.duration.default} min={1} disabled={disabled} onChange={(value) => updateDuration({ default: value || 1 })} />
</div>
)}
</CapabilityGroup>
<CapabilityGroup title="输出参数">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
<Field label="输出分辨率"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.resolutions} options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "720p" })} /></Field>
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<BooleanField label="同步音频" value={profile.generateAudio} disabled={disabled} onChange={(generateAudio) => update({ generateAudio })} />
<BooleanField label="水印" value={profile.watermark} disabled={disabled} onChange={(watermark) => update({ watermark })} />
</div>
</CapabilityGroup>
<CapabilityGroup title="生成模式">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="支持模式"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.operations} options={operationOptions} onChange={(operations) => update({ operations, defaultOperation: operations.includes(profile.defaultOperation) ? profile.defaultOperation : operations[0] || "text_to_video" })} /></Field>
<Field label="默认模式"><Select className="w-full" disabled={disabled} value={profile.defaultOperation} options={operationOptions.filter((item) => profile.operations.includes(item.value))} onChange={(defaultOperation) => update({ defaultOperation })} /></Field>
</div>
</CapabilityGroup>
</div>
);
}
function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) {
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return <label className="block min-w-0"><span className="mb-1 block text-[var(--fs-tiny)] text-foreground/48">{label}</span>{children}</label>;
}
function NumberField({ label, value, min, disabled, onChange }: { label: string; value?: number; min: number; disabled: boolean; onChange: (value: number | null) => void }) {
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} precision={0} value={value} onChange={onChange} /></Field>;
}
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {
return <div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2"><div><div className="text-xs font-medium">{label}</div><div className="text-[var(--fs-tiny)] text-foreground/45">支持该参数</div></div><div className="flex items-center gap-2"><Switch size="small" disabled={disabled} checked={value.supported} onChange={(supported) => onChange({ ...value, supported })} /><Switch size="small" disabled={disabled || !value.supported} checked={value.default} onChange={(defaultValue) => onChange({ ...value, default: defaultValue })} /></div></div>;
}
function bytesToMB(value: number) {
return value ? Math.round((value / (1024 * 1024)) * 10) / 10 : 0;
}
function mbToBytes(value: number | null) {
return Math.max(0, Math.round(Number(value || 0) * 1024 * 1024));
}
function parseIntegerList(value: string) {
return Array.from(new Set(value.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item) && item > 0))).sort((left, right) => left - right);
}

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, Coins, Cpu } from "lucide-react";
import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -206,6 +207,8 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
function ModelLabel({ config, model, capability, theme, creationVariant, showPrice }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showPrice: boolean }) {
const meta = modelMenuMeta(model, capability);
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
const capabilitySummary = videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description;
return (
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
@@ -213,7 +216,7 @@ function ModelLabel({ config, model, capability, theme, creationVariant, showPri
</span>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>{capabilitySummary}</span>
</span>
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
@@ -221,6 +224,12 @@ function ModelLabel({ config, model, capability, theme, creationVariant, showPri
);
}
function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>) {
const values = videoDurationOptions(profile);
if (profile.duration.selection === "enum") return values.map((item) => `${item}s`).join("/");
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
function modelMenuPrice(config: AiConfig, model: string): { value: number; unit: "次" | "秒" } | null | undefined {
if (!model) return undefined;
const channel = resolveModelChannel(config, model);

View File

@@ -0,0 +1,168 @@
import { AudioLines, Check, Film, Flame, Image, MessageSquareText, Network, Settings2, Sparkles } from "lucide-react";
import { Button, Modal } from "antd";
import { useEffect, useState, type ReactNode } from "react";
import { ModelIcon } from "@/components/model-picker";
import { MODEL_PROTOCOLS, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition, type ProtocolCapability } from "@/lib/model-protocols";
import { cn } from "@/lib/utils";
export type ModelCapabilityChoice = ProtocolCapability;
const capabilityChoices: Array<{
value: ModelCapabilityChoice;
label: string;
description: string;
icon: ReactNode;
brands: string[];
}> = [
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
];
type PickerDensity = "comfortable" | "compact";
export function CapabilityCardPicker({ value, onChange, density = "comfortable" }: { value?: ModelCapabilityChoice; onChange?: (value: ModelCapabilityChoice) => void; density?: PickerDensity }) {
return (
<div className={cn("grid grid-cols-2 gap-2 sm:grid-cols-4", density === "compact" && "gap-1.5")} role="radiogroup" aria-label="模型能力">
{capabilityChoices.map((item) => {
const selected = value === item.value;
return (
<button
key={item.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(item.value)}
>
<span className={cn("grid place-items-center rounded-md", density === "compact" ? "size-6" : "size-8", selected ? "bg-foreground text-background" : "bg-muted text-foreground/65")}>{item.icon}</span>
{selected ? <span className={cn("absolute grid place-items-center rounded-full bg-foreground text-background", density === "compact" ? "right-1.5 top-1.5 size-4" : "right-2.5 top-2.5 size-5")}><Check className={density === "compact" ? "size-2.5" : "size-3"} /></span> : null}
<span className={cn("block font-semibold", density === "compact" ? "mt-1 text-xs" : "mt-2 text-sm")}>{item.label}</span>
<span className={cn("block text-foreground/48", density === "compact" ? "text-[var(--fs-micro)]" : "text-xs")}>{item.description}</span>
{density === "comfortable" ? <BrandIconRow models={item.brands} className="mt-auto pt-2" /> : null}
</button>
);
})}
</div>
);
}
export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable" }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity }) {
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
return (
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2", density === "compact" && "gap-1.5")} role="radiogroup" aria-label="模型请求协议">
{protocols.map((protocol) => {
const selected = value === protocol.value;
return (
<button
key={protocol.value}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
)}
onClick={() => onChange?.(protocol.value)}
>
<div className={cn("flex min-w-0 items-start", density === "compact" ? "gap-1.5 pr-4" : "gap-2.5 pr-6")}>
<ProtocolBrandMark protocol={protocol} compact={density === "compact"} />
<div className="min-w-0 flex-1">
<div className={cn("truncate font-semibold", density === "compact" ? "text-xs" : "text-sm")}>{protocol.label}</div>
<div className={cn("mt-0.5 truncate font-mono text-foreground/48", density === "compact" ? "text-[var(--fs-micro)]" : "text-[var(--fs-tiny)]")}>{protocol.create}</div>
</div>
</div>
{selected ? <span className={cn("absolute grid place-items-center rounded-full bg-foreground text-background", density === "compact" ? "right-1.5 top-1.5 size-4" : "right-2.5 top-2.5 size-5")}><Check className={density === "compact" ? "size-2.5" : "size-3"} /></span> : null}
{density === "comfortable" ? <>
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
<span className="truncate">{protocol.contentType}</span>
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
</div>
</> : null}
</button>
);
})}
</div>
);
}
export function ModelCapabilityProtocolModal({ value, onChange }: { value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }; onChange: (value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }) => void }) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => {
if (!open) setDraft(value);
}, [open, value]);
const updateCapability = (capability: ModelCapabilityChoice) => {
const protocol = draft.protocol && MODEL_PROTOCOLS.some((item) => item.value === draft.protocol && item.capability === capability)
? draft.protocol
: MODEL_PROTOCOLS.find((item) => item.capability === capability)?.value || "chat-completion";
setDraft({ capability, protocol });
};
return (
<>
<Button size="small" className="max-w-full justify-start" icon={<Settings2 className="size-3.5" />} onClick={() => { setDraft(value); setOpen(true); }}>
<span className="max-w-[min(56vw,360px)] truncate">{capabilityLabel(value.capability)} · {modelProtocolLabel(value.protocol)}</span>
</Button>
<Modal
title="配置模型能力与请求协议"
open={open}
width="min(720px, calc(100vw - 24px))"
centered
destroyOnHidden
onCancel={() => setOpen(false)}
okText="应用配置"
cancelText="取消"
onOk={() => { onChange(draft); setOpen(false); }}
>
<div className="space-y-4">
<section>
<div className="mb-2 text-xs font-semibold text-foreground/65">模型能力</div>
<CapabilityCardPicker value={draft.capability} onChange={updateCapability} />
</section>
<section>
<div className="mb-2 text-xs font-semibold text-foreground/65">请求协议</div>
<ProtocolCardPicker capability={draft.capability} value={draft.protocol} onChange={(protocol) => setDraft((current) => ({ ...current, protocol }))} />
</section>
</div>
</Modal>
</>
);
}
function ProtocolBrandMark({ protocol, compact = false }: { protocol: ModelProtocolDefinition; compact?: boolean }) {
const iconSize = compact ? "size-6" : "size-8";
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact={compact} />;
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Sparkles className={compact ? "size-3" : "size-4"} /></span>;
if (protocol.value.startsWith("volcengine-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Flame className={compact ? "size-3" : "size-4"} /></span>;
if (protocol.value.startsWith("newapi-channel-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Network className={compact ? "size-3" : "size-4"} /></span>;
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
return <BrandIconRow models={[brand]} compact={compact} />;
}
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
return (
<span className={cn("flex items-center -space-x-1", compact && "shrink-0", className)} aria-hidden="true">
{models.map((model) => <span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-6" : "size-6")} title={modelBrandLabel(model)}><ModelIcon model={model} /></span>)}
</span>
);
}
function modelBrandLabel(model: string) {
const labels: Record<string, string> = { openai: "OpenAI", deepseek: "DeepSeek", glm: "智谱 GLM", gemini: "Google Gemini", grok: "xAI Grok" };
return labels[model] || model;
}
function capabilityLabel(value: ModelCapabilityChoice) {
return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value];
}

View File

@@ -2,13 +2,12 @@ import { type ReactNode } from "react";
import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN, VIDEO_DURATION_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: `${value}P` }));
const sizeOptions = [
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
@@ -18,9 +17,6 @@ const sizeOptions = [
{ value: "auto", label: "auto", width: 0, height: 0 },
];
const defaultSecondOptions = VIDEO_DURATION_OPTIONS;
const jiMengRatioOptions = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"];
type VideoSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark", value: string) => void;
@@ -30,18 +26,23 @@ type VideoSettingsPanelProps = {
};
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) {
const profile = modelCapabilityConfigFor(config, config.model).video!;
if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") {
return <JiMengVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <JiMengVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
if (isSeedanceVideoConfig(config)) {
return <SeedanceVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <SeedanceVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
const seconds = normalizeVideoDuration(config.videoSeconds);
const secondOptions = defaultSecondOptions;
const secondOptions = videoDurationOptions(profile);
const durationColumns = secondOptions.length + (profile.duration.selection === "range" ? 1 : 0);
const size = normalizeVideoSizeValue(config.size);
const dimensions = readSizeDimensions(size);
const resolution = normalizeVideoResolutionValue(config.vquality);
const configuredResolutions = profile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value.toUpperCase() }));
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
const updateDimension = (key: "width" | "height", value: number | null) => {
const next = Math.max(1, Math.floor(value || dimensions[key] || 720));
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
@@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{resolutionOptions.map((item) => (
{configuredResolutions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
@@ -67,37 +68,38 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
<DimensionInput prefix="H" value={dimensions.height} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
</div>
<div className="grid grid-cols-3 gap-1.5">
{sizeOptions.map((item) => (
{profile.ratios.map((value) => (
<button
key={item.value}
key={value}
type="button"
className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-1 text-[var(--fs-label)] font-medium transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
style={{ background: size === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
style={{ background: normalizeRatioValue(config.size) === value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onConfigChange("size", item.value)}
onClick={() => onConfigChange("size", value)}
>
<SizePreview width={item.width} height={item.height} color={theme.node.text} />
<span>{item.label}</span>
<SizePreview width={ratioPreview(value).width} height={ratioPreview(value).height} color={theme.node.text} />
<span>{value}</span>
</button>
))}
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${secondOptions.length}, minmax(0, 1fr)) 72px` }}>
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
{secondOptions.map((value) => (
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
{value}s
</OptionPill>
))}
<DurationInput value={Number(seconds)} min={VIDEO_DURATION_MIN} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
{profile.duration.selection === "range" ? <DurationInput value={Number(seconds)} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
</div>
</SettingGroup>
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
</div>
</ImageSettingsTheme>
);
}
function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
const seconds = normalizeVideoDuration(config.videoSeconds);
return (
<ImageSettingsTheme theme={theme}>
@@ -105,12 +107,12 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
<SettingGroup title="比例" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{jiMengRatioOptions.map((value) => <OptionPill key={value} selected={config.size === value} theme={theme} onClick={() => onConfigChange("size", value)}>{value}</OptionPill>)}
{profile.ratios.map((value) => <OptionPill key={value} selected={config.size === value} theme={theme} onClick={() => onConfigChange("size", value)}>{value}</OptionPill>)}
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-1.5">
{[5, 10].map((value) => <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>{value}s</OptionPill>)}
{videoDurationOptions(profile).map((value) => <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>{value}s</OptionPill>)}
</div>
</SettingGroup>
</div>
@@ -118,13 +120,15 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl
);
}
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
const model = modelOptionName(config.model || config.videoModel);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const ratio = normalizeSeedanceRatio(config.size);
const duration = normalizeSeedanceDuration(config.videoSeconds);
const generateAudio = boolConfig(config.videoGenerateAudio, true);
const watermark = boolConfig(config.videoWatermark, false);
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
const durationOptions = videoDurationOptions(profile);
const durationColumns = durationOptions.length + (profile.duration.selection === "range" ? 1 : 0);
return (
<ImageSettingsTheme theme={theme}>
@@ -132,7 +136,8 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
<SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{seedanceResolutionOptions.map((item) => {
{profile.resolutions.map((value) => {
const item = { value, label: value.toUpperCase() };
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
return (
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
@@ -145,7 +150,9 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
</SettingGroup>
<SettingGroup title="比例" color={theme.node.muted}>
<div className="grid grid-cols-4 gap-1.5">
{seedanceRatioOptions.map((item) => (
{profile.ratios.map((value) => {
const item = { value, label: value };
return (
<button
key={item.value}
type="button"
@@ -159,23 +166,24 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
</span>
<span className="whitespace-nowrap">{item.label}</span>
</button>
))}
);
})}
</div>
</SettingGroup>
<SettingGroup title="时长" color={theme.node.muted}>
<div className="grid grid-cols-[repeat(4,minmax(0,1fr))_72px] gap-1.5">
{seedanceDurationOptions.map((value) => (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
{durationOptions.map((value) => (
<OptionPill key={value} selected={duration === value} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
{value}s
</OptionPill>
))}
<DurationInput value={duration} min={VIDEO_DURATION_MIN} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
{profile.duration.selection === "range" ? <DurationInput value={duration} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
</div>
</SettingGroup>
<SettingGroup title="输出" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
<SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} />
<SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} />
{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}
{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}
</div>
</SettingGroup>
</div>
@@ -239,9 +247,9 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix:
);
}
function DurationInput({ value, min, theme, onChange }: { value: number; min: number; theme: CanvasTheme; onChange: (value: number) => void }) {
function DurationInput({ value, min, max, theme, onChange }: { value: number; min: number; max?: number; theme: CanvasTheme; onChange: (value: number) => void }) {
const commit = (input: HTMLInputElement) => {
const next = Math.max(min, Math.floor(Number(input.value) || value || min));
const next = Math.min(max || Number.POSITIVE_INFINITY, Math.max(min, Math.floor(Number(input.value) || value || min)));
input.value = String(next);
onChange(next);
};
@@ -253,6 +261,7 @@ function DurationInput({ value, min, theme, onChange }: { value: number; min: nu
type="number"
inputMode="numeric"
min={min}
max={max}
defaultValue={value}
aria-label="视频时长(秒)"
className="min-w-0 flex-1 bg-transparent pl-2 text-right outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@@ -303,3 +312,9 @@ function readSizeDimensions(size: string) {
const match = size.match(/^(\d+)x(\d+)$/);
return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 };
}
function normalizeRatioValue(value: string) {
const match = String(value || "").match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/);
if (!match) return value;
return `${match[1]}:${match[2]}`;
}