fix(canvas): 视频设置 - 按渠道能力隐藏尺寸并联动分辨率宽高

This commit is contained in:
ddCat
2026-08-29 11:05:59 +08:00
parent 6d77c250c1
commit 4b75504f75
15 changed files with 196 additions and 50 deletions

View File

@@ -585,10 +585,10 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
taskWorkflowProvider: "model",
model,
quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality,
size: generationDefaults.size || globalConfig.size || defaultConfig.size,
size: generationDefaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: generationDefaults.transparentBackground || "false",
videoSeconds: generationDefaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: generationDefaults.vquality || normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
vquality: generationDefaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark || String(videoProfile.watermark.default) : "false",
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,

View File

@@ -738,10 +738,10 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
...globalConfig,
model,
quality: defaults.quality || globalConfig.quality || defaultConfig.quality,
size: defaults.size || globalConfig.size || defaultConfig.size,
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: defaults.transparentBackground || "false",
videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: defaults.vquality || normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: defaults.videoGenerateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: defaults.videoWatermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,

View File

@@ -5,7 +5,7 @@ import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, resolveVideoResolutionValue } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue } from "@/lib/model-capabilities";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -24,10 +24,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const videoProfile = modelCapabilityConfigFor(config, config.model).video;
const resolutionSupported = Boolean(videoProfile?.resolutions.length);
const sizeSupported = Boolean(videoProfile?.ratios.length);
const resolution = videoProfile ? resolveVideoResolutionValue(videoProfile, config.vquality) : "";
const size = videoProfile ? resolveVideoRatioValue(videoProfile, config.size) : "";
const summary = [
...(resolutionSupported ? [videoResolutionLabel(resolution)] : []),
videoSizeLabel(config.size),
...(sizeSupported ? [videoSizeLabel(size)] : []),
videoSecondsLabel(config.videoSeconds),
].join(" · ");

View File

@@ -4,8 +4,8 @@ import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { formatVideoResolutionLabel, normalizeVideoDuration, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { formatVideoResolutionLabel, normalizeVideoDuration, videoDimensionsForRatioAndResolution, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
const sizeOptions = [
@@ -36,16 +36,13 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
}
const seconds = normalizeVideoDuration(config.videoSeconds);
const size = normalizeVideoSizeValue(config.size);
const dimensions = readSizeDimensions(size);
const resolution = resolveVideoResolutionValue(profile, config.vquality);
const ratio = resolveVideoRatioValue(profile, config.size);
const dimensions = videoDimensionsForRatioAndResolution(ratio, resolution);
const sizeSupported = profile.ratios.length > 0;
const configuredResolutions = profile.resolutions.map((value) => ({ value, label: formatVideoResolutionLabel(value) }));
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}`);
};
return (
<ImageSettingsTheme theme={theme}>
@@ -60,19 +57,19 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
))}
</div>
</SettingGroup> : null}
<SettingGroup title="尺寸" color={theme.node.muted}>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
{sizeSupported ? <SettingGroup title="尺寸" color={theme.node.muted}>
{dimensions ? <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<DimensionValue prefix="W" value={dimensions.width} theme={theme} />
<span className="text-xs opacity-45">×</span>
<DimensionInput prefix="H" value={dimensions.height} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
</div>
<DimensionValue prefix="H" value={dimensions.height} theme={theme} />
</div> : null}
<div className="grid grid-cols-3 gap-1.5">
{profile.ratios.map((value) => (
<button
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: normalizeRatioValue(config.size) === value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
style={{ background: ratio === value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onConfigChange("size", value)}
>
@@ -81,7 +78,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
</button>
))}
</div>
</SettingGroup>
</SettingGroup> : null}
<SettingGroup title="秒数" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
@@ -224,14 +221,14 @@ function SettingGroup({ title, color, children }: { title: string; color: string
);
}
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: CanvasTheme; onChange: (value: number | null) => void }) {
function DimensionValue({ prefix, value, theme }: { prefix: string; value: number; theme: CanvasTheme }) {
return (
<label className="flex h-8 overflow-hidden rounded-md text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
<div className="flex h-8 overflow-hidden rounded-md text-[var(--fs-label)]" style={{ background: theme.toolbar.itemHover, color: theme.node.text }}>
<span className="grid w-7 place-items-center" style={{ color: theme.node.muted }}>
{prefix}
</span>
<input type="number" min={1} disabled={disabled} className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" value={value || ""} onChange={(event) => onChange(Number(event.target.value) || null)} onMouseDown={(event) => event.stopPropagation()} />
</label>
<span className="min-w-0 flex-1 px-2 leading-8 tabular-nums">{value}</span>
</div>
);
}
@@ -365,15 +362,3 @@ function SwitchRow({ label, checked, theme, onChange }: { label: string; checked
</div>
);
}
function readSizeDimensions(size: string) {
if (size === "auto") return { width: 0, height: 0 };
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]}`;
}

View File

@@ -498,10 +498,10 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
comfyBridge,
model,
quality: generationDefaults.quality || requestedConfig.quality,
size: generationDefaults.size || requestedConfig.size,
size: generationDefaults.size ?? requestedConfig.size,
transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
videoSeconds: generationDefaults.videoSeconds || requestedConfig.videoSeconds,
vquality: generationDefaults.vquality || requestedConfig.vquality,
vquality: generationDefaults.vquality ?? requestedConfig.vquality,
videoGenerateAudio: generationDefaults.videoGenerateAudio || requestedConfig.videoGenerateAudio,
videoWatermark: generationDefaults.videoWatermark || requestedConfig.videoWatermark,
videoArkPrivateAssetUpload: requestedConfig.videoArkPrivateAssetUpload,

View File

@@ -1,4 +1,4 @@
import type { ModelProtocol } from "@/lib/model-protocols";
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
export type ModelCapabilityConfig = {
version: number;
@@ -335,6 +335,22 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
}
export function pluginWorkflowCapabilityConfig(protocol: ModelProtocol, workflow: ModelProtocolWorkflow): ModelCapabilityConfig | undefined {
if (workflow.capability !== "image" && workflow.capability !== "video") return undefined;
const fallback = defaultModelCapabilityConfig(protocol, workflow.id);
const fields: WorkflowVideoFieldLike[] = workflow.parameters.map((parameter) => ({
fieldName: parameter.name,
source: parameter.mapping,
fieldType: parameter.type,
options: parameter.values,
defaultValue: workflow.defaults?.[parameter.name],
}));
if (workflow.capability === "image") {
return { ...fallback, image: workflowImageCapabilityConfig(fields, fallback.image!) };
}
return { ...fallback, video: workflowVideoCapabilityConfig(fields, fallback.video!) };
}
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
const separator = model.indexOf("::");
const channelId = separator >= 0 ? model.slice(0, separator) : "";
@@ -900,12 +916,16 @@ export function imageSizeRequest(profile: ImageCapabilityConfig, value?: string)
export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) {
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
const ratio = resolveVideoRatioValue(profile, value.ratio);
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
const resolution = resolveVideoResolutionValue(profile, value.resolution);
return { seconds: String(duration), ratio, resolution };
}
export function resolveVideoRatioValue(profile: VideoCapabilityConfig, value: string | undefined) {
return profile.ratios.includes(value || "") ? value! : profile.defaultRatio || profile.ratios[0] || "";
}
export function resolveVideoResolutionValue(profile: VideoCapabilityConfig, value: string | undefined) {
return videoResolutionRequest(profile, value) || profile.defaultResolution || profile.resolutions[0] || "";
}

View File

@@ -38,3 +38,29 @@ export function formatVideoResolutionLabel(value: string | number | undefined) {
if (/^\d+$/.test(normalized)) return `${normalized}P`;
return normalized.replace(/^(\d+)p/i, "$1P");
}
export function videoDimensionsForRatioAndResolution(ratio: string | undefined, resolution: string | number | undefined) {
const ratioMatch = String(ratio || "")
.trim()
.toLowerCase()
.replace("×", "x")
.match(/^(\d+(?:\.\d+)?)(?::|x)(\d+(?:\.\d+)?)$/);
if (!ratioMatch) return undefined;
const ratioWidth = Number(ratioMatch[1]);
const ratioHeight = Number(ratioMatch[2]);
if (!Number.isFinite(ratioWidth) || !Number.isFinite(ratioHeight) || ratioWidth <= 0 || ratioHeight <= 0) return undefined;
const normalizedResolution = normalizeVideoResolution(resolution);
const resolutionMatch = normalizedResolution.match(/^(\d+)(?:p)?/i);
const shortEdge = Number(resolutionMatch?.[1]);
if (!Number.isFinite(shortEdge) || shortEdge <= 0) return undefined;
const aspect = ratioWidth / ratioHeight;
if (aspect >= 1) return { width: evenDimension(shortEdge * aspect), height: shortEdge };
return { width: shortEdge, height: evenDimension(shortEdge / aspect) };
}
function evenDimension(value: number) {
return Math.max(2, Math.round(value / 2) * 2);
}

View File

@@ -612,7 +612,7 @@ export default function CreatePage() {
...(mode === "image"
? { size: normalizedImage?.size || ratio, quality: normalizedImage?.quality || quality, count: normalizedImage?.count || count, videoSeconds: config.videoSeconds }
: mode === "video"
? { size: normalizedVideo?.ratio || ratio, videoSeconds: normalizedVideo?.seconds || seconds, vquality: (normalizedVideo?.resolution || videoQuality).replace(/p$/i, "") }
? { size: normalizedVideo?.ratio ?? ratio, videoSeconds: normalizedVideo?.seconds || seconds, vquality: (normalizedVideo?.resolution ?? videoQuality).replace(/p$/i, "") }
: {}),
};
try {
@@ -1519,9 +1519,10 @@ function GenerationSettingsMenu(props: ComposerProps) {
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
].join(" · ");
const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
const videoRatioSupported = props.mode === "video" && ratios.length > 0;
const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
const panel = <div className="creation-parameter-menu">
{props.mode === "video" || mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={referenceImageSizeSelected ? referenceImageSizeLabel : props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{referenceImageSizeValue ? <button type="button" aria-pressed={referenceImageSizeSelected} aria-label={"使用参考图尺寸 " + referenceImageSizeLabel} title={"使用参考图尺寸 " + referenceImageSizeLabel} className={"creation-reference-size-choice" + (referenceImageSizeSelected ? " is-selected" : "")} onClick={selectReferenceImageSize}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(referenceImageSizeRatio)} /></span><span>参考图</span></button> : null}{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
{videoRatioSupported || props.mode !== "video" && mergedProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={referenceImageSizeSelected ? referenceImageSizeLabel : props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{referenceImageSizeValue ? <button type="button" aria-pressed={referenceImageSizeSelected} aria-label={"使用参考图尺寸 " + referenceImageSizeLabel} title={"使用参考图尺寸 " + referenceImageSizeLabel} className={"creation-reference-size-choice" + (referenceImageSizeSelected ? " is-selected" : "")} onClick={selectReferenceImageSize}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(referenceImageSizeRatio)} /></span><span>参考图</span></button> : null}{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}

View File

@@ -8,7 +8,7 @@ import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog";
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
import { fetchChannelModels } from "@/services/api/image";
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { pluginWorkflowCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
import {
createModelChannel,
@@ -84,7 +84,7 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
protocol: provider.value,
billingMode: "fixed_request" as const,
unitPriceMicrocredits: 0,
capabilityConfig: workflow.capability === "image" || workflow.capability === "video" ? defaultModelCapabilityConfig(provider.value, workflow.id) : undefined,
capabilityConfig: pluginWorkflowCapabilityConfig(provider.value, workflow),
defaultOptions: workflow.defaults,
}))
: channel.modelCosts;