From a52376da41e0b496a7c975fc4700b96a3d65d32a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 30 Aug 2026 13:02:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E5=88=86=E9=95=9C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20-=20=E7=BB=9F=E4=B8=80=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E8=AE=A1=E4=BB=B7=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/models_project.go | 30 +-- backend/internal/repository/repository.go | 1 + backend/internal/service/project.go | 68 ++++-- .../internal/service/project_settings_test.go | 36 ++++ .../content/docs/backend/backend-database.mdx | 2 +- docs/content/docs/progress/pending-test.mdx | 2 + web/package.json | 2 +- web/src/pages/projects/detail/settings.tsx | 29 ++- .../detail/workflow-production-workbench.tsx | 197 +++++++++++++++--- web/src/pages/projects/detail/workflow.css | 22 +- web/src/services/api/projects.ts | 6 +- web/test/model-capabilities.test.ts | 15 ++ 12 files changed, 342 insertions(+), 68 deletions(-) create mode 100644 web/test/model-capabilities.test.ts diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 9428b4f32..2353b7eea 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -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 快照,避免源对象更新污染历史生成。 diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 3fe7c3209..6fd7e2cda 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 } diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index bf5330249..26d18a121 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -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 diff --git a/backend/internal/service/project_settings_test.go b/backend/internal/service/project_settings_test.go index d9ab57d4e..d0d90702b 100644 --- a/backend/internal/service/project_settings_test.go +++ b/backend/internal/service/project_settings_test.go @@ -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() diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index a684077aa..93879ed98 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -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` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index eee6d2869..cea92d362 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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、多宫格生成提交、批量视频任务、后端交付包”仍是后续阶段,界面不得显示为已完成或可用。 ## 角色声音素材绑定 diff --git a/web/package.json b/web/package.json index 146ba0b5e..ce09e839a 100644 --- a/web/package.json +++ b/web/package.json @@ -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 .", diff --git a/web/src/pages/projects/detail/settings.tsx b/web/src/pages/projects/detail/settings.tsx index 452c57cb8..f76966805 100644 --- a/web/src/pages/projects/detail/settings.tsx +++ b/web/src/pages/projects/detail/settings.tsx @@ -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(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 +
+

默认生成模型

分镜图、动作预演与镜头视频优先使用项目默认;未设置或模型不可用时跟随工作台全局默认。

+
+ +
+ + {defaultImageModel ? : null} +
+
+ +
+ + {defaultVideoModel ? : null} +
+
+
+
+

项目主图

项目列表优先展示这张图片;未设置时使用项目画风示意图

建议使用与项目画幅一致的图片
diff --git a/web/src/pages/projects/detail/workflow-production-workbench.tsx b/web/src/pages/projects/detail/workflow-production-workbench.tsx index 1c8cecff3..f4835b6fa 100644 --- a/web/src/pages/projects/detail/workflow-production-workbench.tsx +++ b/web/src/pages/projects/detail/workflow-production-workbench.tsx @@ -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(); 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([]); 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(() => ({ + 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(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) { >
生成规格
- value && array.indexOf(value) === index).map((value) => ({ value, label: value }))} /> - ({ value, label: `${value} 秒` }))} /> + : } + + + ({ value, label: formatVideoResolutionLabel(value) }))} /> + ) : imageProfile?.quality.supported ? ( +