fix(*): 分镜生成设置 - 统一模型能力计价并支持项目默认模型
This commit is contained in:
@@ -170,20 +170,22 @@ type CharacterVoiceBinding struct {
|
||||
|
||||
// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。
|
||||
type Project struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"`
|
||||
Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"`
|
||||
Type string `json:"type" gorm:"size:32;index"`
|
||||
AspectRatio string `json:"aspectRatio" gorm:"size:16"`
|
||||
SourceType string `json:"sourceType" gorm:"size:32"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
CoverResourceID string `json:"coverResourceId,omitempty" gorm:"index;size:36"`
|
||||
StylePresetID string `json:"stylePresetId" gorm:"size:64"`
|
||||
StyleProfileJSON string `json:"styleProfileJson" gorm:"type:text"`
|
||||
Status ProjectStatus `json:"status" gorm:"index;size:24"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"`
|
||||
Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"`
|
||||
Type string `json:"type" gorm:"size:32;index"`
|
||||
AspectRatio string `json:"aspectRatio" gorm:"size:16"`
|
||||
SourceType string `json:"sourceType" gorm:"size:32"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
CoverResourceID string `json:"coverResourceId,omitempty" gorm:"index;size:36"`
|
||||
StylePresetID string `json:"stylePresetId" gorm:"size:64"`
|
||||
StyleProfileJSON string `json:"styleProfileJson" gorm:"type:text"`
|
||||
DefaultImageModel string `json:"defaultImageModel,omitempty" gorm:"size:500"`
|
||||
DefaultVideoModel string `json:"defaultVideoModel,omitempty" gorm:"size:500"`
|
||||
Status ProjectStatus `json:"status" gorm:"index;size:24"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
|
||||
}
|
||||
|
||||
// StyleProfile 是用户可持续编辑的风格源;项目只保存应用当时的 JSON 快照,避免源对象更新污染历史生成。
|
||||
|
||||
@@ -1131,6 +1131,7 @@ func (r *Repository) UpdateProject(project *model.Project) error {
|
||||
"name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType,
|
||||
"description": project.Description, "cover_resource_id": project.CoverResourceID,
|
||||
"style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON,
|
||||
"default_image_model": project.DefaultImageModel, "default_video_model": project.DefaultVideoModel,
|
||||
"status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -14,25 +14,29 @@ import (
|
||||
)
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
SourceType string `json:"sourceType"`
|
||||
Description string `json:"description"`
|
||||
StylePresetID string `json:"stylePresetId"`
|
||||
StyleProfileJSON string `json:"styleProfileJson"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
SourceType string `json:"sourceType"`
|
||||
Description string `json:"description"`
|
||||
StylePresetID string `json:"stylePresetId"`
|
||||
StyleProfileJSON string `json:"styleProfileJson"`
|
||||
DefaultImageModel string `json:"defaultImageModel"`
|
||||
DefaultVideoModel string `json:"defaultVideoModel"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
SourceType string `json:"sourceType"`
|
||||
Description *string `json:"description"`
|
||||
CoverResourceID *string `json:"coverResourceId"`
|
||||
StylePresetID *string `json:"stylePresetId"`
|
||||
StyleProfileJSON *string `json:"styleProfileJson"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
SourceType string `json:"sourceType"`
|
||||
Description *string `json:"description"`
|
||||
CoverResourceID *string `json:"coverResourceId"`
|
||||
StylePresetID *string `json:"stylePresetId"`
|
||||
StyleProfileJSON *string `json:"styleProfileJson"`
|
||||
DefaultImageModel *string `json:"defaultImageModel"`
|
||||
DefaultVideoModel *string `json:"defaultVideoModel"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateProjectUnitRequest struct {
|
||||
@@ -240,7 +244,15 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.
|
||||
return model.Project{}, BadAuthRequest(err.Error())
|
||||
}
|
||||
now := time.Now()
|
||||
project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
defaultImageModel, err := normalizeProjectDefaultModel(req.DefaultImageModel)
|
||||
if err != nil {
|
||||
return model.Project{}, err
|
||||
}
|
||||
defaultVideoModel, err := normalizeProjectDefaultModel(req.DefaultVideoModel)
|
||||
if err != nil {
|
||||
return model.Project{}, err
|
||||
}
|
||||
project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, DefaultImageModel: defaultImageModel, DefaultVideoModel: defaultVideoModel, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.repo.CreateProject(&project); err != nil {
|
||||
return model.Project{}, err
|
||||
}
|
||||
@@ -301,6 +313,20 @@ func (s *Service) UpdateProject(userID string, id string, req UpdateProjectReque
|
||||
}
|
||||
project.StyleProfileJSON = styleProfileJSON
|
||||
}
|
||||
if req.DefaultImageModel != nil {
|
||||
defaultImageModel, modelErr := normalizeProjectDefaultModel(*req.DefaultImageModel)
|
||||
if modelErr != nil {
|
||||
return model.Project{}, modelErr
|
||||
}
|
||||
project.DefaultImageModel = defaultImageModel
|
||||
}
|
||||
if req.DefaultVideoModel != nil {
|
||||
defaultVideoModel, modelErr := normalizeProjectDefaultModel(*req.DefaultVideoModel)
|
||||
if modelErr != nil {
|
||||
return model.Project{}, modelErr
|
||||
}
|
||||
project.DefaultVideoModel = defaultVideoModel
|
||||
}
|
||||
if err := validateStyleProfilePreset(project.StylePresetID, project.StyleProfileJSON); err != nil {
|
||||
return model.Project{}, BadAuthRequest(err.Error())
|
||||
}
|
||||
@@ -318,6 +344,14 @@ func (s *Service) UpdateProject(userID string, id string, req UpdateProjectReque
|
||||
return *project, nil
|
||||
}
|
||||
|
||||
func normalizeProjectDefaultModel(value string) (string, error) {
|
||||
modelRef := strings.TrimSpace(value)
|
||||
if len(modelRef) > 500 {
|
||||
return "", BadAuthRequest("项目默认模型标识过长")
|
||||
}
|
||||
return modelRef, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteProject(userID string, id string) error {
|
||||
if _, err := s.repo.ProjectForUser(userID, id); err != nil {
|
||||
return err
|
||||
|
||||
@@ -82,6 +82,42 @@ func TestUpdateProjectCoverRequiresOwnedReadyImage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProjectPersistsDefaultGenerationModels(t *testing.T) {
|
||||
service, db := newProjectSettingsTestService(t)
|
||||
now := time.Now()
|
||||
project := model.Project{ID: "project-model-defaults", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
imageModel := " system-channel::image-model "
|
||||
videoModel := "user-channel::MiniMax-H3"
|
||||
updated, err := service.UpdateProject("user-1", project.ID, UpdateProjectRequest{DefaultImageModel: &imageModel, DefaultVideoModel: &videoModel})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.DefaultImageModel != "system-channel::image-model" || updated.DefaultVideoModel != videoModel {
|
||||
t.Fatalf("updated model defaults = %q / %q", updated.DefaultImageModel, updated.DefaultVideoModel)
|
||||
}
|
||||
|
||||
var persisted model.Project
|
||||
if err := db.First(&persisted, "id = ?", project.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.DefaultImageModel != updated.DefaultImageModel || persisted.DefaultVideoModel != updated.DefaultVideoModel {
|
||||
t.Fatalf("persisted model defaults = %q / %q", persisted.DefaultImageModel, persisted.DefaultVideoModel)
|
||||
}
|
||||
|
||||
empty := ""
|
||||
cleared, err := service.UpdateProject("user-1", project.ID, UpdateProjectRequest{DefaultVideoModel: &empty})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleared.DefaultVideoModel != "" {
|
||||
t.Fatalf("default video model = %q after clear", cleared.DefaultVideoModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectUnitSummaryPersistsWordCount(t *testing.T) {
|
||||
service, db := newProjectSettingsTestService(t)
|
||||
now := time.Now()
|
||||
|
||||
@@ -22,7 +22,7 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
| --- | --- | --- |
|
||||
| `projects` | 保存短剧项目基础设置、画幅、画风快照和项目主图 | `cover_resource_id` 只能引用当前用户已就绪的图片资源;资源删除检查会阻止仍被项目主图使用的图片被回收 |
|
||||
| `projects` | 保存短剧项目基础设置、画幅、画风快照、项目主图和默认图片/视频模型 | `default_image_model` / `default_video_model` 保存前端模型选择器的渠道限定引用;模型下架或渠道停用时前端回退当前全局默认。`cover_resource_id` 只能引用当前用户已就绪的图片资源;资源删除检查会阻止仍被项目主图使用的图片被回收 |
|
||||
| `project_units` | 保存项目章节或集数的正文、排序和状态 | `word_count` 在正文创建、导入和更新时同步维护,项目详情只返回摘要和字数,不批量返回长篇正文;升级时会为旧正文回填字数 |
|
||||
| `shots` | 保存章节内稳定的镜头身份、排序和当前版本指针 | 不保存完整生成历史;`current_revision_id` 指向当前采用的分镜脚本版本 |
|
||||
| `shot_revisions` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 |
|
||||
|
||||
@@ -40,12 +40,14 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
||||
- [x] 短剧工作流调整为“剧情与章节 → 资产拆分 → 分镜脚本 → 黑白动作预演 → 视频生成 → 交付与打包”,旧 v1 实例保留,新章节使用 v2。
|
||||
- [x] 项目工作台新增独立分镜工作流路由,支持初始化章节流程、新增镜头、保存分镜版本、绑定资产版本、查看预演/视频版本和交付缺失状态;前端生产构建通过。
|
||||
- [x] 镜头轨道按当前制作阶段显示待生成、生成中、已生成和生成失败状态,状态标签固定在卡片右下角;横向镜头只在轨道内部滚动,不再撑宽脚本区或挤出产物预览。制作概览六个流程节点改为独立可点击卡片;前端类型检查、生产构建与相关专项测试通过。
|
||||
- [x] 分镜生成设置接入统一模型选择、能力和计价链路,支持前台模型、系统模型与用户自定义模型;切换模型会归一画幅、分辨率/画质和时长,MiniMax-H3 的旧 `720` 会切换为 `768P`。项目设置可保存默认生图/视频模型;后端全量测试、MiniMax 回归测试和前端生产构建通过。
|
||||
- [ ] 在真实项目中初始化一个已有章节,确认不会误用旧 v1 工作流,刷新和切换章节后阶段、镜头和当前版本保持一致。
|
||||
- [ ] 在同一章节准备待生成、生成中、已生成和生成失败镜头,确认灰、蓝、绿、红标签始终显示在卡片右下角;增加到几十个镜头后确认只有底部轨道横向滚动,中间脚本和右侧产物预览仍完整可见。
|
||||
- [ ] 在浅色、深色、常规桌面和窄屏下检查制作概览六张流程卡片的边界、间距、完成态、悬停和键盘焦点,确认跳转目标正确。
|
||||
- [ ] 连续保存同一镜头多个版本并绑定不同资产版本,确认只有该镜头的动作预演和视频转为过期,其他镜头不受影响。
|
||||
- [ ] 使用画布任务和项目任务分别登记镜头产物,确认 `production_task_links` 正确记录 Project、Canvas、Unit、Shot 和 WorkflowStep,且账号归属校验有效。
|
||||
- [ ] 在浅色、深色、窄屏和常规桌面验证阶段导航、镜头列表、表单、检查器、媒体预览和长章节内容滚动。
|
||||
- [ ] 在项目设置分别选择系统、用户自定义的默认生图/视频模型,刷新后进入分镜图、动作预演和镜头视频,确认项目默认优先、已下架模型回退全局默认;切换 Seedance、MiniMax-H3 等能力不同的模型时,画幅、时长、分辨率和本次预计积分立即更新,并与实际账单一致。
|
||||
- [ ] 当前“资产拆分 AI、多宫格生成提交、批量视频任务、后端交付包”仍是后续阶段,界面不得显示为已完成或可用。
|
||||
|
||||
## 角色声音素材绑定
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "bun run build:bridge && tsc --noEmit && vite build",
|
||||
"pretest": "bun test test/admin-ui-regressions.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
15
web/test/model-capabilities.test.ts
Normal file
15
web/test/model-capabilities.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
|
||||
import { defaultModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
|
||||
|
||||
test("switching to MiniMax H3 replaces an unsupported 720p value with 768P", () => {
|
||||
const profile = defaultModelCapabilityConfig("minimax-video", "MiniMax-H3").video!;
|
||||
|
||||
assert.deepEqual(normalizeVideoValue(profile, { seconds: "11", ratio: "16:9", resolution: "720" }), {
|
||||
seconds: "11",
|
||||
ratio: "16:9",
|
||||
resolution: "768P",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user