fix(*): 分镜生成设置 - 统一模型能力计价并支持项目默认模型

This commit is contained in:
ddCat
2026-08-30 13:02:55 +08:00
parent c8b60cea07
commit a52376da41
12 changed files with 342 additions and 68 deletions

View File

@@ -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 快照,避免源对象更新污染历史生成。

View File

@@ -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
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 |

View File

@@ -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、多宫格生成提交、批量视频任务、后端交付包”仍是后续阶段,界面不得显示为已完成或可用。
## 角色声音素材绑定

View File

@@ -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 .",

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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));
}

View 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",
});
});