fix(*): 分镜生成设置 - 统一模型能力计价并支持项目默认模型
This commit is contained in:
@@ -5,12 +5,13 @@ import { Archive, Check, Eye, FolderOpen, Image as ImageIcon, Palette, Pencil, S
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { listProjectAssetsPage, updateProject } from "@/services/api/projects";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
import type { ProjectDetailViewProps } from "./shared";
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
const [sourceType, setSourceType] = useState(project.sourceType);
|
||||
const [stylePresetId, setStylePresetId] = useState(project.stylePresetId || "");
|
||||
const [styleProfileJson, setStyleProfileJson] = useState(project.styleProfileJson || "");
|
||||
const [defaultImageModel, setDefaultImageModel] = useState(project.defaultImageModel || "");
|
||||
const [defaultVideoModel, setDefaultVideoModel] = useState(project.defaultVideoModel || "");
|
||||
const [styleDetail, setStyleDetail] = useState<CanvasStylePreset | null>(null);
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
const [styleEditorRequested, setStyleEditorRequested] = useState(false);
|
||||
@@ -39,8 +42,8 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
enabled: coverPickerOpen,
|
||||
});
|
||||
const projectCoverAssets = coverAssetsQuery.data?.assets || [];
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setStyleProfileJson(project.styleProfileJson || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || styleProfileJson !== (project.styleProfileJson || ""), [aspectRatio, description, name, project, sourceType, stylePresetId, styleProfileJson]);
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setStyleProfileJson(project.styleProfileJson || ""); setDefaultImageModel(project.defaultImageModel || ""); setDefaultVideoModel(project.defaultVideoModel || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || styleProfileJson !== (project.styleProfileJson || "") || defaultImageModel !== (project.defaultImageModel || "") || defaultVideoModel !== (project.defaultVideoModel || ""), [aspectRatio, defaultImageModel, defaultVideoModel, description, name, project, sourceType, stylePresetId, styleProfileJson]);
|
||||
const selectedStyle = useMemo(() => resolveProjectCanvasStyle(stylePresetId, styleProfileJson), [stylePresetId, styleProfileJson]);
|
||||
const styleProfile = useMemo(() => parseStyleProfile(styleProfileJson) || selectedStyle?.profile || (selectedStyle ? createStyleProfileSnapshot(selectedStyle) : null), [selectedStyle, styleProfileJson]);
|
||||
const enabledStyleAssets = styleProfile?.assets.filter((asset) => asset.enabled !== false) || [];
|
||||
@@ -91,7 +94,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
for (const [itemId, resourceId] of coverResourceByItemId) coverResourceByItemIdRef.current.set(itemId, resourceId);
|
||||
}, [coverResourceByItemId]);
|
||||
const currentCoverItemId = coverPickerItems.find((item) => coverResourceByItemId.get(item.id) === project.coverResourceId)?.id;
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, styleProfileJson }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, styleProfileJson, defaultImageModel, defaultVideoModel }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") });
|
||||
const coverMutation = useMutation({ mutationFn: (coverResourceId: string) => updateProject(project.id, { coverResourceId }), onSuccess: (_, coverResourceId) => { setCoverPickerOpen(false); refreshProject(); message.success(coverResourceId ? "项目主图已更新" : "项目主图已移除"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目主图更新失败") });
|
||||
|
||||
@@ -109,6 +112,24 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-border/70 py-5">
|
||||
<div className="mb-3"><h3 className="text-sm font-semibold">默认生成模型</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">分镜图、动作预演与镜头视频优先使用项目默认;未设置或模型不可用时跟随工作台全局默认。</p></div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="默认生图模型">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelPicker config={effectiveConfig} value={defaultImageModel} capability="image" onChange={setDefaultImageModel} fullWidth placeholder={`跟随全局 · ${modelDisplayName(effectiveConfig, effectiveConfig.imageModel) || "未配置"}`} showSelectedPrice />
|
||||
{defaultImageModel ? <Button type="text" size="small" onClick={() => setDefaultImageModel("")}>跟随全局</Button> : null}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="默认视频模型">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelPicker config={effectiveConfig} value={defaultVideoModel} capability="video" onChange={setDefaultVideoModel} fullWidth placeholder={`跟随全局 · ${modelDisplayName(effectiveConfig, effectiveConfig.videoModel) || "未配置"}`} showSelectedPrice />
|
||||
{defaultVideoModel ? <Button type="text" size="small" onClick={() => setDefaultVideoModel("")}>跟随全局</Button> : null}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-border/70 py-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目主图</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">项目列表优先展示这张图片;未设置时使用项目画风示意图</p></div><span className="text-[var(--fs-label)] text-foreground/40">建议使用与项目画幅一致的图片</span></div>
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-surface-active p-3 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Empty, Form, Image, Input, InputNumber, Segmented, Select, Tag } from "antd";
|
||||
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import { quoteLogicalModel } from "@/services/api/logical-models";
|
||||
import { type GenerationTask } from "@/services/api/task-center";
|
||||
import {
|
||||
createUnitWorkflow,
|
||||
@@ -25,7 +32,8 @@ import {
|
||||
} from "@/services/api/projects";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { skillRuntime } from "@/services/skill-runtime";
|
||||
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { configuredModelMatchesCapability, modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/skill-runtime-picker";
|
||||
|
||||
import {
|
||||
@@ -69,6 +77,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
const navigate = useNavigate();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const [form] = Form.useForm<ShotEditorValues>();
|
||||
const watchedDuration = Form.useWatch("durationSeconds", form);
|
||||
const watchedTitle = Form.useWatch("title", form);
|
||||
@@ -93,24 +102,110 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
const shotTaskElapsed = shotTask ? formatTaskElapsed(Date.parse(shotTask.startedAt || shotTask.createdAt), taskClock) : "";
|
||||
const newestArtifact = artifacts.find((item) => item.selected) || artifacts[0];
|
||||
const previewArtifact = artifacts.find((item) => item.id === previewArtifactId) || newestArtifact;
|
||||
const modelOptions = (activeStage === "video" ? effectiveConfig.videoModels : effectiveConfig.imageModels) || [];
|
||||
const defaultModel = activeStage === "video" ? effectiveConfig.videoModel : effectiveConfig.imageModel;
|
||||
const [selectedModel, setSelectedModel] = useState(defaultModel || modelOptions[0] || "");
|
||||
const generationCapability = activeStage === "video" ? "video" as const : "image" as const;
|
||||
const modelOptions = useMemo(() => selectableModelsByCapability(effectiveConfig, generationCapability), [effectiveConfig, generationCapability]);
|
||||
const projectDefaultModel = generationCapability === "video" ? detail.project.defaultVideoModel : detail.project.defaultImageModel;
|
||||
const globalDefaultModel = generationCapability === "video" ? effectiveConfig.videoModel : effectiveConfig.imageModel;
|
||||
const defaultModel = projectDefaultModel && configuredModelMatchesCapability(effectiveConfig, projectDefaultModel, generationCapability) ? projectDefaultModel : globalDefaultModel;
|
||||
const initialModel = defaultModel || modelOptions[0] || "";
|
||||
const [selectedModel, setSelectedModel] = useState(initialModel);
|
||||
const selectedModelRef = useRef(initialModel);
|
||||
const [aspectRatio, setAspectRatio] = useState(detail.project.aspectRatio || "16:9");
|
||||
const [resolution, setResolution] = useState(effectiveConfig.vquality || "720");
|
||||
const [imageQuality, setImageQuality] = useState(effectiveConfig.quality || "auto");
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||
const { skills: availableSkills, loading: skillsLoading } = useSkillRuntimeCatalog();
|
||||
const shotAssetReferenceContext = useMemo(() => buildShotAssetReferenceContext(detail, selectedShot?.id || ""), [detail, selectedShot?.id]);
|
||||
const referenceByVersionId = useMemo(() => new Map((detail.shotReferences || []).filter((reference) => reference.shotId === selectedShot?.id && reference.role === "reference" && reference.status === "linked").map((reference) => [reference.assetVersionId, reference])), [detail.shotReferences, selectedShot?.id]);
|
||||
const modelSummary = selectedModel ? modelDisplayName(effectiveConfig, selectedModel) : "未选择模型";
|
||||
const currentDurationSeconds = Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000));
|
||||
const generationSeconds = String(Math.max(1, Math.round(currentDurationSeconds)));
|
||||
const modelRequirements = useMemo<ModelRequirements>(() => ({
|
||||
capability: generationCapability,
|
||||
input: { textCount: 1, imageCount: shotAssetReferenceContext.referenceImages.length, videoCount: 0, audioCount: 0, characterCount: 0 },
|
||||
videoSeconds: generationCapability === "video" ? generationSeconds : undefined,
|
||||
imageSize: generationCapability === "image" ? aspectRatio : undefined,
|
||||
options: generationCapability === "video"
|
||||
? { size: aspectRatio, vquality: resolution, videoSeconds: Number(generationSeconds) }
|
||||
: { size: aspectRatio, quality: imageQuality },
|
||||
}), [aspectRatio, generationCapability, generationSeconds, imageQuality, resolution, shotAssetReferenceContext.referenceImages.length]);
|
||||
const routedModel = resolveCompatibleModel(effectiveConfig, selectedModel, modelRequirements) || selectedModel;
|
||||
const activeProfile = useMemo(() => modelCapabilityConfigFor(effectiveConfig, routedModel), [effectiveConfig, routedModel]);
|
||||
const videoProfile = generationCapability === "video" ? activeProfile.video : undefined;
|
||||
const imageProfile = generationCapability === "image" ? activeProfile.image : undefined;
|
||||
const generationConfig = useMemo(() => ({
|
||||
...effectiveConfig,
|
||||
model: routedModel,
|
||||
imageModel: generationCapability === "image" ? routedModel : effectiveConfig.imageModel,
|
||||
videoModel: generationCapability === "video" ? routedModel : effectiveConfig.videoModel,
|
||||
size: aspectRatio,
|
||||
quality: imageQuality,
|
||||
vquality: resolution,
|
||||
videoSeconds: generationSeconds,
|
||||
}), [aspectRatio, effectiveConfig, generationCapability, generationSeconds, imageQuality, resolution, routedModel]);
|
||||
const priceChannel = resolveModelChannel(generationConfig, routedModel);
|
||||
const configuredCredits = requestCreditCost({
|
||||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||||
modelCosts: priceChannel.modelCosts,
|
||||
model: modelOptionName(routedModel),
|
||||
count: 1,
|
||||
seconds: generationCapability === "video" ? generationSeconds : 1,
|
||||
capability: generationCapability,
|
||||
config: generationConfig,
|
||||
requirements: modelRequirements,
|
||||
});
|
||||
const quoteRequest = useMemo(() => modelQuoteRequest(generationConfig, routedModel, generationCapability, modelRequirements), [generationCapability, generationConfig, modelRequirements, routedModel]);
|
||||
const quoteRequestKey = JSON.stringify(quoteRequest || null);
|
||||
const [quotedCredits, setQuotedCredits] = useState<number | null>(null);
|
||||
const generationCredits = quotedCredits ?? configuredCredits;
|
||||
const formattedGenerationCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const modelSummary = routedModel ? modelDisplayName(effectiveConfig, routedModel) : "未选择模型";
|
||||
const durationSummary = `${Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000))}s`;
|
||||
const resolutionSummary = `${resolution.replace(/p$/i, "")}p`;
|
||||
const resolutionSummary = generationCapability === "video" ? formatVideoResolutionLabel(resolution) : imageQuality.toUpperCase();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(defaultModel || modelOptions[0] || "");
|
||||
}, [activeStage, defaultModel, modelOptions]);
|
||||
selectedModelRef.current = initialModel;
|
||||
setSelectedModel(initialModel);
|
||||
if (!initialModel) return;
|
||||
const profile = modelCapabilityConfigFor(effectiveConfig, initialModel);
|
||||
if (generationCapability === "video" && profile.video) {
|
||||
const normalized = normalizeVideoValue(profile.video, {
|
||||
seconds: effectiveConfig.videoSeconds,
|
||||
ratio: detail.project.aspectRatio || effectiveConfig.size,
|
||||
resolution: effectiveConfig.vquality,
|
||||
});
|
||||
setAspectRatio(normalized.ratio);
|
||||
setResolution(normalized.resolution);
|
||||
form.setFieldValue("durationSeconds", Number(normalized.seconds));
|
||||
} else if (generationCapability === "image" && profile.image) {
|
||||
const normalized = normalizeImageValue(profile.image, { size: detail.project.aspectRatio || effectiveConfig.size, quality: effectiveConfig.quality, count: "1" });
|
||||
setAspectRatio(normalized.size);
|
||||
setImageQuality(normalized.quality);
|
||||
}
|
||||
}, [detail.project.aspectRatio, effectiveConfig, form, generationCapability, initialModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!creditsEnabled || !quoteRequest) {
|
||||
setQuotedCredits(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQuotedCredits(null);
|
||||
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
|
||||
.then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setQuotedCredits(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
// quoteRequestKey captures the normalized request without retriggering on object identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [creditsEnabled, quoteRequestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const shotDurationSeconds = Math.max(0.5, (revision?.durationMs || selectedShot?.durationMs || 3000) / 1000);
|
||||
const currentModel = selectedModelRef.current || initialModel;
|
||||
const normalizedDurationSeconds = generationCapability === "video" && currentModel
|
||||
? Number(normalizeVideoValue(modelCapabilityConfigFor(effectiveConfig, currentModel).video!, { seconds: String(shotDurationSeconds) }).seconds)
|
||||
: shotDurationSeconds;
|
||||
form.setFieldsValue({
|
||||
title: selectedShot?.title || "",
|
||||
plotDescription: revision?.plotDescription || selectedShot?.description || "",
|
||||
@@ -119,7 +214,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
shotSize: revision?.shotSize || "",
|
||||
cameraAngle: revision?.cameraAngle || "",
|
||||
cameraMovement: revision?.cameraMovement || "",
|
||||
durationSeconds: Math.max(0.5, (revision?.durationMs || selectedShot?.durationMs || 3000) / 1000),
|
||||
durationSeconds: normalizedDurationSeconds,
|
||||
imagePrompt: revision?.imagePrompt || "",
|
||||
videoPrompt: revision?.videoPrompt || "",
|
||||
negativePrompt: revision?.negativePrompt || "",
|
||||
@@ -127,7 +222,29 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
});
|
||||
setPreviewArtifactId("");
|
||||
setEditorDirty(!revision);
|
||||
}, [form, revision?.id, selectedShot?.id]);
|
||||
}, [effectiveConfig, form, generationCapability, initialModel, revision?.id, selectedShot?.id]);
|
||||
|
||||
const changeGenerationModel = (nextModel: string) => {
|
||||
selectedModelRef.current = nextModel;
|
||||
setSelectedModel(nextModel);
|
||||
const profile = modelCapabilityConfigFor(effectiveConfig, nextModel);
|
||||
if (generationCapability === "video" && profile.video) {
|
||||
const normalized = normalizeVideoValue(profile.video, {
|
||||
seconds: String(form.getFieldValue("durationSeconds") || generationSeconds),
|
||||
ratio: aspectRatio,
|
||||
resolution,
|
||||
});
|
||||
setAspectRatio(normalized.ratio);
|
||||
setResolution(normalized.resolution);
|
||||
form.setFieldValue("durationSeconds", Number(normalized.seconds));
|
||||
return;
|
||||
}
|
||||
if (generationCapability === "image" && profile.image) {
|
||||
const normalized = normalizeImageValue(profile.image, { size: aspectRatio, quality: imageQuality, count: "1" });
|
||||
setAspectRatio(normalized.size);
|
||||
setImageQuality(normalized.quality);
|
||||
}
|
||||
};
|
||||
|
||||
const saveShot = useMutation({
|
||||
mutationFn: async (values: ShotEditorValues) => {
|
||||
@@ -178,8 +295,10 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
}
|
||||
if (!productionStep) throw new Error("当前生成阶段不可用,请刷新页面后重试");
|
||||
if (productionStep.status === "failed") throw new Error("当前生成阶段失败,请刷新后重试");
|
||||
if (!selectedModel) throw new Error(activeStage === "video" ? "请先配置视频模型" : "请先配置图片模型");
|
||||
if (selectedModel.startsWith("local:dreamina-cli")) throw new Error("本机即梦任务暂不能登记到分镜产物,请选择后端模型渠道");
|
||||
if (!routedModel) throw new Error(activeStage === "video" ? "请先配置视频模型" : "请先配置图片模型");
|
||||
if (routedModel.startsWith("local:dreamina-cli")) throw new Error("本机即梦任务暂不能登记到分镜产物,请选择后端模型渠道");
|
||||
const compatibilityError = modelCompatibilityError(effectiveConfig, routedModel, modelRequirements);
|
||||
if (compatibilityError) throw new Error(`当前模型配置不可用:${compatibilityError}`);
|
||||
const values = await form.validateFields();
|
||||
const saved = await saveProjectShot(projectId, {
|
||||
id: selectedShot.id,
|
||||
@@ -191,17 +310,9 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
status: selectedShot.status,
|
||||
revision: revisionInput(values),
|
||||
});
|
||||
const mode = activeStage === "video" ? "video" as const : "image" as const;
|
||||
const config = {
|
||||
...effectiveConfig,
|
||||
model: selectedModel,
|
||||
imageModel: mode === "image" ? selectedModel : effectiveConfig.imageModel,
|
||||
videoModel: mode === "video" ? selectedModel : effectiveConfig.videoModel,
|
||||
size: aspectRatio,
|
||||
vquality: resolution,
|
||||
videoSeconds: String(Math.max(1, Math.round(values.durationSeconds))),
|
||||
};
|
||||
if (!isAiConfigReady(config, selectedModel)) throw new Error("当前模型渠道配置不完整,请先到设置中补齐");
|
||||
const mode = generationCapability;
|
||||
const config = { ...generationConfig, videoSeconds: String(Math.max(1, Math.round(values.durationSeconds))) };
|
||||
if (!isAiConfigReady(config, routedModel)) throw new Error("当前模型渠道配置不完整,请先到设置中补齐");
|
||||
const basePrompt = mode === "video"
|
||||
? [values.videoPrompt || values.plotDescription, values.action, values.continuityNotes].filter(Boolean).join("\n")
|
||||
: [values.imagePrompt || values.plotDescription, values.action, "黑白分镜草图,清晰动作节拍,电影构图"].filter(Boolean).join("\n");
|
||||
@@ -349,12 +460,39 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
>
|
||||
<div className="workflow-settings-section">
|
||||
<div className="workflow-settings-section-title">生成规格</div>
|
||||
<Form.Item label="生成模型"><Select showSearch value={selectedModel || undefined} placeholder={activeStage === "video" ? "选择视频模型" : "选择图片模型"} onChange={setSelectedModel} options={modelOptions.map((value) => ({ value, label: modelDisplayName(effectiveConfig, value) }))} /></Form.Item>
|
||||
<Form.Item label="生成模型">
|
||||
<ModelPicker
|
||||
config={generationConfig}
|
||||
value={selectedModel}
|
||||
capability={generationCapability}
|
||||
requirements={modelRequirements}
|
||||
onChange={changeGenerationModel}
|
||||
fullWidth
|
||||
className="workflow-model-picker"
|
||||
placeholder={activeStage === "video" ? "选择视频模型" : "选择图片模型"}
|
||||
showSelectedPrice
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="技能库"><SkillRuntimePicker profile="shortDrama" skills={availableSkills} loading={skillsLoading} value={selectedSkillIds} onChange={setSelectedSkillIds} /></Form.Item>
|
||||
<div className="workflow-form-grid is-three">
|
||||
<Form.Item name="durationSeconds" label="镜头时长(秒)"><InputNumber className="w-full" min={0.5} max={60} step={0.5} /></Form.Item>
|
||||
<Form.Item label="画幅"><Select value={aspectRatio} onChange={setAspectRatio} options={[detail.project.aspectRatio, "16:9", "9:16", "1:1"].filter((value, index, array) => value && array.indexOf(value) === index).map((value) => ({ value, label: value }))} /></Form.Item>
|
||||
<Form.Item label="分辨率"><Select value={resolution} onChange={setResolution} options={[{ value: "480", label: "480p" }, { value: "720", label: "720p" }, { value: "1080", label: "1080p" }]} /></Form.Item>
|
||||
<Form.Item name="durationSeconds" label="镜头时长(秒)">
|
||||
{generationCapability === "video" && videoProfile?.duration.selection === "enum"
|
||||
? <Select options={videoDurationOptions(videoProfile).map((value) => ({ value, label: `${value} 秒` }))} />
|
||||
: <InputNumber className="w-full" min={generationCapability === "video" ? videoProfile?.duration.min || 1 : 0.5} max={generationCapability === "video" ? videoProfile?.duration.max || 60 : 60} step={generationCapability === "video" ? videoProfile?.duration.step || 1 : 0.5} />}
|
||||
</Form.Item>
|
||||
<Form.Item label={generationCapability === "video" ? "画幅" : "尺寸 / 画幅"}>
|
||||
<Select
|
||||
showSearch
|
||||
value={aspectRatio}
|
||||
onChange={setAspectRatio}
|
||||
options={(generationCapability === "video" ? videoProfile?.ratios || [] : imageProfile?.size.values.filter((value) => value !== "*") || []).map((value) => ({ value, label: value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{generationCapability === "video" ? (
|
||||
<Form.Item label="分辨率"><Select value={resolution} onChange={setResolution} options={(videoProfile?.resolutions || []).map((value) => ({ value, label: formatVideoResolutionLabel(value) }))} /></Form.Item>
|
||||
) : imageProfile?.quality.supported ? (
|
||||
<Form.Item label="生成画质"><Select value={imageQuality} onChange={setImageQuality} options={imageProfile.quality.values.map((value) => ({ value, label: value.toUpperCase() }))} /></Form.Item>
|
||||
) : <div />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workflow-settings-section">
|
||||
@@ -382,6 +520,9 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
</WorkflowDisclosure>
|
||||
</div>
|
||||
<footer className="workflow-editor-actions">
|
||||
<div className="workflow-generation-cost" aria-live="polite">
|
||||
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || generateArtifact.isPending || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={generateArtifact.isPending || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => generateArtifact.mutate()}>{shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
|
||||
</footer>
|
||||
</Form>
|
||||
|
||||
@@ -364,6 +364,15 @@
|
||||
.workflow-disclosure-content { padding: 13px 14px 4px; }
|
||||
.workflow-disclosure .ant-form-item { margin-bottom: 10px; }
|
||||
|
||||
.workflow-model-picker.canvas-composer-model-picker {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
border-color: var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--workspace-surface-strong);
|
||||
}
|
||||
|
||||
.workflow-settings-section + .workflow-settings-section {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
@@ -530,7 +539,7 @@
|
||||
min-height: 58px;
|
||||
flex: 0 0 58px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
padding: 9px 16px;
|
||||
@@ -539,6 +548,17 @@
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workflow-generation-cost {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.workflow-generation-cost svg { color: var(--status-warning); }
|
||||
|
||||
.workflow-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,6 +14,8 @@ export type Project = {
|
||||
coverResourceId?: string;
|
||||
stylePresetId: string;
|
||||
styleProfileJson?: string;
|
||||
defaultImageModel?: string;
|
||||
defaultVideoModel?: string;
|
||||
status: "active" | "archived" | string;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
@@ -411,11 +413,11 @@ function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
|
||||
};
|
||||
}
|
||||
|
||||
export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string; styleProfileJson?: string }) {
|
||||
export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string; styleProfileJson?: string; defaultImageModel?: string; defaultVideoModel?: string }) {
|
||||
return request<{ project: Project }>(api.post("/projects", input));
|
||||
}
|
||||
|
||||
export function updateProject(projectId: string, input: Partial<Pick<Project, "name" | "type" | "aspectRatio" | "sourceType" | "description" | "coverResourceId" | "stylePresetId" | "styleProfileJson" | "status">>) {
|
||||
export function updateProject(projectId: string, input: Partial<Pick<Project, "name" | "type" | "aspectRatio" | "sourceType" | "description" | "coverResourceId" | "stylePresetId" | "styleProfileJson" | "defaultImageModel" | "defaultVideoModel" | "status">>) {
|
||||
return request<{ project: Project }>(api.patch(`/projects/${encodeURIComponent(projectId)}`, input));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user