diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 83e119dee..e744a341a 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -5,13 +5,16 @@ import ( "fmt" "time" + "infinite-canvas/backend/internal/model" + "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 2 +const CurrentSchemaVersion int64 = 3 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" +const assetTaxonomyCandidateIdentityChecksum = "sha256:asset-taxonomy-candidate-identity-v3-20260831-r1" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -40,12 +43,52 @@ type migration struct { var schemaMigrations = []migration{ {version: 1, name: "baseline_gorm_schema", checksum: baselineSchemaChecksum, apply: migrateSchemaV1}, {version: 2, name: "schema_migrations_applied_at_index", checksum: schemaMigrationAppliedAtIndexChecksum, apply: migrateSchemaV2}, + {version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3}, } func migrateSchemaV2(tx *gorm.DB) error { return tx.Exec("CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at ON schema_migrations (applied_at)").Error } +func migrateSchemaV3(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.ProjectAssetCandidate{}); err != nil { + return fmt.Errorf("扩展资产候选身份字段:%w", err) + } + if err := tx.Exec("UPDATE assets SET category = 'prop' WHERE category IN ('wardrobe', 'weapon', 'accessory')").Error; err != nil { + return fmt.Errorf("合并资产道具分类:%w", err) + } + if err := tx.Exec("UPDATE assets SET category = 'material' WHERE category = 'style' OR (category = 'other' AND kind IN ('image', 'video', 'audio', 'model'))").Error; err != nil { + return fmt.Errorf("迁移资产素材分类:%w", err) + } + if err := tx.Exec("UPDATE project_asset_candidates SET category = 'prop' WHERE category IN ('wardrobe', 'weapon', 'accessory')").Error; err != nil { + return fmt.Errorf("合并候选道具分类:%w", err) + } + if err := tx.Exec("UPDATE project_asset_candidates SET category = 'material' WHERE category = 'style'").Error; err != nil { + return fmt.Errorf("迁移候选素材分类:%w", err) + } + var candidates []model.ProjectAssetCandidate + if err := tx.Order("created_at asc, id asc").Find(&candidates).Error; err != nil { + return fmt.Errorf("读取资产候选身份:%w", err) + } + seenPending := make(map[string]string, len(candidates)) + for _, candidate := range candidates { + nameKey := model.AssetCandidateNameKey(candidate.Name) + updates := map[string]any{"name_key": nameKey} + identity := candidate.ProjectID + ":" + string(candidate.Category) + ":" + nameKey + if candidate.Status == "pending_confirmation" && nameKey != "" { + if _, exists := seenPending[identity]; exists { + updates["status"] = "ignored" + } else { + seenPending[identity] = candidate.ID + } + } + if err := tx.Model(&model.ProjectAssetCandidate{}).Where("id = ?", candidate.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("回填资产候选身份 %s:%w", candidate.ID, err) + } + } + return tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_asset_candidates_pending_identity ON project_asset_candidates(project_id, category, name_key) WHERE status = 'pending_confirmation' AND name_key <> ''").Error +} + func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 3843ccc6f..5a5e03ce4 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "infinite-canvas/backend/internal/model" + "gorm.io/gorm" ) @@ -26,6 +28,9 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) { if !db.Migrator().HasIndex(&schemaMigration{}, "idx_schema_migrations_applied_at") { t.Fatal("schema migration v2 did not create the applied_at index") } + if !db.Migrator().HasIndex(&model.ProjectAssetCandidate{}, "idx_project_asset_candidates_pending_identity") { + t.Fatal("schema migration v3 did not create candidate identity index") + } if err := MigrateSchema(db); err != nil { t.Fatalf("migration should be idempotent: %v", err) } @@ -50,6 +55,39 @@ func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) { } } +func TestMigrateSchemaV3NormalizesLegacyAccessoryCategory(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-asset-taxonomy?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Asset{}, &model.ProjectAssetCandidate{}); err != nil { + t.Fatal(err) + } + asset := model.Asset{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategory("accessory"), Title: "旧配饰"} + candidate := model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", Name: "旧配饰候选", Category: model.AssetCategory("accessory"), Status: "pending_confirmation"} + if err := db.Create(&asset).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&candidate).Error; err != nil { + t.Fatal(err) + } + if err := migrateSchemaV3(db); err != nil { + t.Fatal(err) + } + if err := db.First(&asset, "id = ?", asset.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&candidate, "id = ?", candidate.ID).Error; err != nil { + t.Fatal(err) + } + if asset.Category != model.AssetCategoryProp || candidate.Category != model.AssetCategoryProp { + t.Fatalf("legacy accessory categories = %q/%q, want prop/prop", asset.Category, candidate.Category) + } + if candidate.NameKey != model.AssetCandidateNameKey(candidate.Name) { + t.Fatalf("candidate name key = %q", candidate.NameKey) + } +} + func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 9739da38c..0a03b21d3 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -879,7 +879,7 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { fail(c, http.StatusBadRequest, err) return } - result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category")) + result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unit_id"), c.Query("status"), c.Query("category"), c.Query("q")) if err != nil { failService(c, err) return diff --git a/backend/internal/model/asset_category.go b/backend/internal/model/asset_category.go new file mode 100644 index 000000000..14739ec94 --- /dev/null +++ b/backend/internal/model/asset_category.go @@ -0,0 +1,28 @@ +package model + +import "strings" + +// NormalizeAssetCategory keeps persisted and synced assets inside the current +// five-category contract while preserving the intended meaning of legacy data. +func NormalizeAssetCategory(value AssetCategory, kind string) AssetCategory { + switch strings.ToLower(strings.TrimSpace(string(value))) { + case string(AssetCategoryCharacter): + return AssetCategoryCharacter + case string(AssetCategoryEnvironment): + return AssetCategoryEnvironment + case string(AssetCategoryProp), "wardrobe", "weapon", "accessory": + return AssetCategoryProp + case string(AssetCategoryMaterial), "style": + return AssetCategoryMaterial + case string(AssetCategoryOther): + return AssetCategoryOther + } + switch strings.ToLower(strings.TrimSpace(kind)) { + case "entity": + return AssetCategoryCharacter + case "image", "video", "audio", "model": + return AssetCategoryMaterial + default: + return AssetCategoryOther + } +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index f6f2def51..9e75a1527 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -144,10 +144,8 @@ const ( AssetCategoryCharacter AssetCategory = "character" AssetCategoryEnvironment AssetCategory = "environment" - AssetCategoryWardrobe AssetCategory = "wardrobe" AssetCategoryProp AssetCategory = "prop" - AssetCategoryWeapon AssetCategory = "weapon" - AssetCategoryStyle AssetCategory = "style" + AssetCategoryMaterial AssetCategory = "material" AssetCategoryOther AssetCategory = "other" AssetVersionStatusDraft AssetVersionStatus = "draft" diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 2353b7eea..620b821aa 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -112,8 +112,10 @@ type ProjectAssetCandidate struct { UnitID string `json:"unitId,omitempty" gorm:"index;size:36;index:idx_project_asset_candidates_project_unit_status,priority:2"` ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` Name string `json:"name" gorm:"size:240"` + NameKey string `json:"-" gorm:"index;size:240"` Category AssetCategory `json:"category" gorm:"index;size:32;index:idx_project_asset_candidates_project_status_category,priority:3"` Status string `json:"status" gorm:"index;size:32;index:idx_project_asset_candidates_project_unit_status,priority:3;index:idx_project_asset_candidates_project_status_category,priority:2"` + Source string `json:"source,omitempty" gorm:"index;size:48"` DetailsJSON string `json:"detailsJson" gorm:"type:text"` ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"` CreatedAt time.Time `json:"createdAt"` diff --git a/backend/internal/model/project_asset_identity.go b/backend/internal/model/project_asset_identity.go new file mode 100644 index 000000000..e75d51e85 --- /dev/null +++ b/backend/internal/model/project_asset_identity.go @@ -0,0 +1,17 @@ +package model + +import ( + "strings" + "unicode" +) + +// AssetCandidateNameKey removes presentation differences from a candidate name. +// The key is only an identity guard; the original name remains the display value. +func AssetCandidateNameKey(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + return unicode.ToLower(r) + } + return -1 + }, strings.TrimSpace(value)) +} diff --git a/backend/internal/repository/project_workbench_read.go b/backend/internal/repository/project_workbench_read.go index 340f5c6fc..3fbb55b9d 100644 --- a/backend/internal/repository/project_workbench_read.go +++ b/backend/internal/repository/project_workbench_read.go @@ -175,7 +175,7 @@ func (r *Repository) ProjectCanvasUnitLinksForCanvases(projectID string, canvasI return links, err } -func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string) ([]model.ProjectAssetCandidate, int64, error) { +func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, pageSize int, unitID string, status string, category string, queryText string) ([]model.ProjectAssetCandidate, int64, error) { var candidates []model.ProjectAssetCandidate var total int64 query := r.db.Model(&model.ProjectAssetCandidate{}).Where("project_id = ?", projectID) @@ -188,6 +188,9 @@ func (r *Repository) ProjectAssetCandidatesPage(projectID string, page int, page if value := strings.TrimSpace(category); value != "" { query = query.Where("category = ?", value) } + if value := strings.TrimSpace(queryText); value != "" { + query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(value)+"%") + } if err := query.Count(&total).Error; err != nil { return nil, 0, err } diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index eb5b4adc7..13453e931 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -2041,6 +2041,11 @@ func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAsse return r.db.Create(&candidates).Error } +func (r *Repository) CreateProjectAssetCandidate(candidate *model.ProjectAssetCandidate) (bool, error) { + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(candidate) + return result.RowsAffected == 1, result.Error +} + // ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。 func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error { return r.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/service/project_asset.go b/backend/internal/service/project_asset.go index f7f68c322..0ee9fd1f1 100644 --- a/backend/internal/service/project_asset.go +++ b/backend/internal/service/project_asset.go @@ -462,7 +462,7 @@ func containsString(values []string, target string) bool { func validAssetCategory(category model.AssetCategory) bool { switch category { - case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryWardrobe, model.AssetCategoryProp, model.AssetCategoryWeapon, model.AssetCategoryStyle, model.AssetCategoryOther: + case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryProp, model.AssetCategoryMaterial, model.AssetCategoryOther: return true default: return false diff --git a/backend/internal/service/project_shot.go b/backend/internal/service/project_shot.go index b9400bd0b..61625f5ad 100644 --- a/backend/internal/service/project_shot.go +++ b/backend/internal/service/project_shot.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "errors" + "fmt" "strings" "time" @@ -61,8 +62,11 @@ type AssetCandidateInput struct { type CreateAssetCandidatesRequest struct { Candidates []AssetCandidateInput `json:"candidates"` + Source string `json:"source"` } +const assetCandidateSourceChapterCharacter = "chapter_character_extract" + func (s *Service) CreateProjectShot(userID string, projectID string, req CreateProjectShotRequest) (model.Shot, error) { if _, err := s.activeProjectForUser(userID, projectID); err != nil { return model.Shot{}, err @@ -301,14 +305,31 @@ func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, if len(req.Candidates) == 0 || len(req.Candidates) > 100 { return nil, BadAuthRequest("资产候选数量必须在 1 到 100 之间") } + source := strings.TrimSpace(req.Source) + existingCandidates, err := s.repo.ProjectAssetCandidates(projectID) + if err != nil { + return nil, err + } + projectAssets, err := s.repo.ProjectAssets(userID, projectID) + if err != nil { + return nil, err + } + knownKeys := projectAssetCandidateIdentityKeys(existingCandidates, projectAssets) now := time.Now() candidates := make([]model.ProjectAssetCandidate, 0, len(req.Candidates)) for _, input := range req.Candidates { name := strings.TrimSpace(input.Name) + nameKey := model.AssetCandidateNameKey(name) category := model.AssetCategory(strings.TrimSpace(input.Category)) - if name == "" || !validAssetCategory(category) { + if name == "" || nameKey == "" || !validAssetCategory(category) { return nil, BadAuthRequest("资产候选名称或分类无效") } + if category == model.AssetCategoryCharacter && source != assetCandidateSourceChapterCharacter { + return nil, BadAuthRequest("角色候选只能从剧情章节的角色提取流程创建") + } + if category == model.AssetCategoryCharacter && strings.TrimSpace(input.UnitID) == "" { + return nil, BadAuthRequest("角色候选必须关联剧情章节") + } if input.UnitID != "" { if _, err := s.repo.ProjectUnit(projectID, input.UnitID); err != nil { return nil, err @@ -328,17 +349,106 @@ func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, return nil, err } } - candidates = append(candidates, model.ProjectAssetCandidate{ID: newID(), ProjectID: projectID, UnitID: strings.TrimSpace(input.UnitID), ShotID: strings.TrimSpace(input.ShotID), Name: name, Category: category, Status: "pending_confirmation", DetailsJSON: detailsJSON, CreatedAt: now, UpdatedAt: now}) + identityKeys := assetCandidateInputIdentityKeys(name, input.Details) + if assetCandidateIdentityExists(knownKeys, category, identityKeys) { + continue + } + candidate := model.ProjectAssetCandidate{ID: newID(), ProjectID: projectID, UnitID: strings.TrimSpace(input.UnitID), ShotID: strings.TrimSpace(input.ShotID), Name: name, NameKey: nameKey, Category: category, Status: "pending_confirmation", Source: source, DetailsJSON: detailsJSON, CreatedAt: now, UpdatedAt: now} + inserted, createErr := s.repo.CreateProjectAssetCandidate(&candidate) + if createErr != nil { + return nil, createErr + } + if !inserted { + continue + } + candidates = append(candidates, candidate) + addAssetCandidateIdentityKeys(knownKeys, category, identityKeys) } - if err := s.repo.CreateProjectAssetCandidates(candidates); err != nil { - return nil, err - } - if err := s.repo.BumpProjectRevision(projectID); err != nil { - return nil, err + if len(candidates) > 0 { + if err := s.repo.BumpProjectRevision(projectID); err != nil { + return nil, err + } } return candidates, nil } +func projectAssetCandidateIdentityKeys(candidates []model.ProjectAssetCandidate, assets []model.Asset) map[string]struct{} { + known := make(map[string]struct{}, len(candidates)+len(assets)) + for _, candidate := range candidates { + keys := []string{candidate.NameKey} + if strings.TrimSpace(candidate.DetailsJSON) != "" { + var details map[string]any + if json.Unmarshal([]byte(candidate.DetailsJSON), &details) == nil { + keys = append(keys, assetCandidateAliases(details)...) + } + } + addAssetCandidateIdentityKeys(known, candidate.Category, keys) + } + for _, asset := range assets { + keys := []string{model.AssetCandidateNameKey(asset.Title)} + if asset.Category == model.AssetCategoryCharacter { + keys = append(keys, characterAssetAliasKeys(asset.PayloadJSON)...) + } + addAssetCandidateIdentityKeys(known, asset.Category, keys) + } + return known +} + +func assetCandidateInputIdentityKeys(name string, details map[string]any) []string { + return append([]string{model.AssetCandidateNameKey(name)}, assetCandidateAliases(details)...) +} + +func assetCandidateAliases(details map[string]any) []string { + if details == nil { + return nil + } + values, ok := details["aliases"].([]any) + if !ok { + if stringsValue, stringsOK := details["aliases"].([]string); stringsOK { + values = make([]any, len(stringsValue)) + for index, value := range stringsValue { + values[index] = value + } + } + } + keys := make([]string, 0, len(values)) + for _, value := range values { + if key := model.AssetCandidateNameKey(fmt.Sprint(value)); key != "" { + keys = append(keys, key) + } + } + return keys +} + +func characterAssetAliasKeys(payloadJSON string) []string { + var payload struct { + Data struct { + Definition map[string]any `json:"definition"` + } `json:"data"` + } + if json.Unmarshal([]byte(payloadJSON), &payload) != nil { + return nil + } + return assetCandidateAliases(payload.Data.Definition) +} + +func assetCandidateIdentityExists(known map[string]struct{}, category model.AssetCategory, keys []string) bool { + for _, key := range keys { + if _, exists := known[string(category)+":"+key]; exists { + return true + } + } + return false +} + +func addAssetCandidateIdentityKeys(known map[string]struct{}, category model.AssetCategory, keys []string) { + for _, key := range keys { + if key != "" { + known[string(category)+":"+key] = struct{}{} + } + } +} + func validShotAssetRole(role string) bool { switch role { case "reference", "start_frame", "end_frame", "keyframe", "storyboard", "output": diff --git a/backend/internal/service/project_workbench_read.go b/backend/internal/service/project_workbench_read.go index c32539a81..a2c5537d2 100644 --- a/backend/internal/service/project_workbench_read.go +++ b/backend/internal/service/project_workbench_read.go @@ -354,12 +354,12 @@ func (s *Service) ProjectCanvasesPage(userID string, projectID string, page int, return ProjectCanvasPage{Canvases: canvases, CanvasUnitLinks: links, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil } -func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string) (ProjectAssetCandidatePage, error) { +func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string, query string) (ProjectAssetCandidatePage, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return ProjectAssetCandidatePage{}, err } page, pageSize = normalizeProjectPage(page, pageSize, 200) - candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category) + candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category, query) if err != nil { return ProjectAssetCandidatePage{}, err } diff --git a/backend/internal/service/project_workbench_read_test.go b/backend/internal/service/project_workbench_read_test.go index c1a1668ad..66c210ed7 100644 --- a/backend/internal/service/project_workbench_read_test.go +++ b/backend/internal/service/project_workbench_read_test.go @@ -232,3 +232,23 @@ func TestProjectAssetsPagePaginatesAndReturnsFacets(t *testing.T) { t.Fatalf("unexpected folder facets: %+v", page.FolderCounts) } } + +func TestProjectAssetCandidatesPageSearchesNamesWithinFilters(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + project := seedWorkbenchProject(t, db) + candidates := []model.ProjectAssetCandidate{ + {ID: "candidate-1", ProjectID: project.ID, UnitID: "unit-1", Name: "红色雨伞", Category: model.AssetCategoryProp, Status: "pending_confirmation"}, + {ID: "candidate-2", ProjectID: project.ID, UnitID: "unit-1", Name: "蓝色雨衣", Category: model.AssetCategoryProp, Status: "pending_confirmation"}, + {ID: "candidate-3", ProjectID: project.ID, UnitID: "unit-2", Name: "红色雨伞场景", Category: model.AssetCategoryEnvironment, Status: "pending_confirmation"}, + } + if err := db.Create(&candidates).Error; err != nil { + t.Fatal(err) + } + page, err := service.ProjectAssetCandidatesPage("user-1", project.ID, 1, 20, "unit-1", "pending_confirmation", "prop", "雨伞") + if err != nil { + t.Fatal(err) + } + if page.Total != 1 || len(page.Candidates) != 1 || page.Candidates[0].ID != "candidate-1" { + t.Fatalf("unexpected candidate search result: %+v", page) + } +} diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go index 9bcc4e681..2c1e93699 100644 --- a/backend/internal/service/project_workflow.go +++ b/backend/internal/service/project_workflow.go @@ -602,12 +602,12 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, } title += " · " + label payload, _ := json.Marshal(map[string]any{ - "id": assetID, "kind": mediaType, "category": model.AssetCategoryOther, "status": model.AssetVersionStatusConfirmed, + "id": assetID, "kind": mediaType, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed, "primaryVersionId": versionID, "title": title, "data": map[string]any{"storageKey": "resource:" + resourceID, "url": "/api/resources/" + resourceID + "/file", "mimeType": resource.MimeType, "bytes": resource.Size}, "metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}}, }) - asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now} + asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now} version := &model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: "{}", Prompt: task.Prompt, Note: "工作流生成产物", CreatedAt: now, UpdatedAt: now} folderID, err := s.resolveProjectAssetFolderID(projectID, nil) if err != nil { diff --git a/backend/internal/service/project_workflow_v2_test.go b/backend/internal/service/project_workflow_v2_test.go index c8f929827..07004ef4e 100644 --- a/backend/internal/service/project_workflow_v2_test.go +++ b/backend/internal/service/project_workflow_v2_test.go @@ -20,7 +20,7 @@ func newProjectWorkflowV2TestService(t *testing.T) (*Service, *gorm.DB) { } if err := db.AutoMigrate( &model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{}, - &model.AssetRepresentation{}, + &model.AssetRepresentation{}, &model.CharacterVoiceBinding{}, &model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{}, &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{}, &model.ProductionTaskLink{}, &model.Task{}, &model.Resource{}, @@ -131,6 +131,79 @@ func TestShortDramaWorkflowV2UsesProductionOrder(t *testing.T) { } } +func TestCreateProjectCharacterCandidatesIsIdempotentAcrossPendingAndConfirmed(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + request := CreateAssetCandidatesRequest{ + Source: assetCandidateSourceChapterCharacter, + Candidates: []AssetCandidateInput{{ + UnitID: unit.ID, Name: " 小红帽 ", Category: string(model.AssetCategoryCharacter), + Details: validCharacterCandidateDetails([]any{"小红"}), + }}, + } + + created, err := service.CreateProjectAssetCandidates("user-1", project.ID, request) + if err != nil { + t.Fatal(err) + } + if len(created) != 1 { + t.Fatalf("created candidates = %d, want 1", len(created)) + } + created, err = service.CreateProjectAssetCandidates("user-1", project.ID, request) + if err != nil { + t.Fatal(err) + } + if len(created) != 0 { + t.Fatalf("duplicate pending candidates = %d, want 0", len(created)) + } + + if _, err := service.ConfirmProjectAssetCandidate("user-1", project.ID, firstProjectCandidateID(t, db), ConfirmProjectAssetCandidateRequest{}); err != nil { + t.Fatal(err) + } + created, err = service.CreateProjectAssetCandidates("user-1", project.ID, request) + if err != nil { + t.Fatal(err) + } + if len(created) != 0 { + t.Fatalf("confirmed character was extracted again: %d", len(created)) + } + var assetCount int64 + if err := db.Model(&model.Asset{}).Where("user_id = ? AND category = ?", "user-1", model.AssetCategoryCharacter).Count(&assetCount).Error; err != nil { + t.Fatal(err) + } + if assetCount != 1 { + t.Fatalf("character assets = %d, want 1", assetCount) + } +} + +func TestCreateProjectCharacterCandidatesRejectsNonChapterSource(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + _, err := service.CreateProjectAssetCandidates("user-1", project.ID, CreateAssetCandidatesRequest{ + Source: "agent", + Candidates: []AssetCandidateInput{{UnitID: unit.ID, Name: "猎人", Category: string(model.AssetCategoryCharacter), Details: validCharacterCandidateDetails(nil)}}, + }) + if err == nil { + t.Fatal("agent source unexpectedly created a character candidate") + } +} + +func validCharacterCandidateDetails(aliases []any) map[string]any { + return map[string]any{ + "role": "主角", "aliases": aliases, "appearance": "红色斗篷", "clothing": "红色兜帽与斗篷", "physique": "儿童体型", + "personality": "勇敢", "voiceLanguage": "普通话", "voiceAge": "儿童", "voiceTimbre": "清亮", + } +} + +func firstProjectCandidateID(t *testing.T, db *gorm.DB) string { + t.Helper() + var candidate model.ProjectAssetCandidate + if err := db.Order("created_at asc").First(&candidate).Error; err != nil { + t.Fatal(err) + } + return candidate.ID +} + func TestWorkflowCompletionRequiresStageGate(t *testing.T) { service, db := newProjectWorkflowV2TestService(t) project, unit := seedWorkflowProject(t, db) diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 177c18a61..7dbaccf51 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -292,10 +292,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { if utf8.RuneCountInString(primaryVersionID) > 36 { return model.Asset{}, BadAuthRequest("素材主版本 ID 不能超过 36 个字符") } - category := model.AssetCategory(strings.TrimSpace(payload.Category)) - if category == "" { - category = model.AssetCategoryOther - } + category := model.NormalizeAssetCategory(model.AssetCategory(payload.Category), payload.Kind) status := model.AssetVersionStatus(strings.TrimSpace(payload.Status)) if status == "" { status = model.AssetVersionStatusConfirmed diff --git a/backend/internal/service/user_data_test.go b/backend/internal/service/user_data_test.go index 76445545c..db74e2658 100644 --- a/backend/internal/service/user_data_test.go +++ b/backend/internal/service/user_data_test.go @@ -27,6 +27,35 @@ func TestAssetFromJSONAcceptsDeterministicGenerationID(t *testing.T) { } } +func TestAssetFromJSONNormalizesLegacyAndUnclassifiedMediaCategories(t *testing.T) { + tests := []struct { + name string + kind string + category string + want model.AssetCategory + }{ + {name: "legacy accessory", kind: "image", category: "accessory", want: model.AssetCategoryProp}, + {name: "legacy style", kind: "image", category: "style", want: model.AssetCategoryMaterial}, + {name: "unclassified video", kind: "video", want: model.AssetCategoryMaterial}, + {name: "unclassified entity", kind: "entity", want: model.AssetCategoryCharacter}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := json.Marshal(map[string]any{"id": "asset-1", "kind": test.kind, "category": test.category, "title": "测试资产"}) + if err != nil { + t.Fatal(err) + } + asset, err := assetFromJSON("user-1", raw) + if err != nil { + t.Fatal(err) + } + if asset.Category != test.want { + t.Fatalf("category = %q, want %q", asset.Category, test.want) + } + }) + } +} + func TestAssetFromJSONRejectsIDOverLimit(t *testing.T) { raw, err := json.Marshal(map[string]any{"id": strings.Repeat("a", model.AssetIDMaxLength+1), "kind": "image"}) if err != nil { diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts index b3100b85c..ba22a0842 100644 --- a/canvas-agent/src/schemas.ts +++ b/canvas-agent/src/schemas.ts @@ -5,8 +5,10 @@ const positionSchema = z.object({ x: z.number(), y: z.number() }); const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() }); const nodeTypeSchema = z.enum(["image", "text", "script", "video", "audio", "frame"]); const generationModeSchema = z.enum(["text", "image", "video", "audio"]); +const assetCategorySchema = z.enum(["character", "environment", "prop", "material", "other"]); +const agentCandidateCategorySchema = z.enum(["environment", "prop", "material", "other"]); const projectIdSchema = z.string().min(1).optional(); -const projectCandidateSchema = z.object({ unitId: z.string().optional(), shotId: z.string().optional(), name: z.string().min(1), category: z.string().min(1), details: recordSchema.optional() }); +const projectCandidateSchema = z.object({ unitId: z.string().optional(), shotId: z.string().optional(), name: z.string().min(1), category: agentCandidateCategorySchema, details: recordSchema.optional() }); const projectShotSchema = z.object({ id: z.string().optional(), unitId: z.string().optional(), title: z.string().min(1), description: z.string().optional(), position: z.number().int().min(0).optional(), durationMs: z.number().int().min(0).optional(), status: z.string().optional() }); export const toolNames = [ @@ -158,7 +160,7 @@ export const toolInputSchemas = { project_create_or_update_shots: z.object({ projectId: projectIdSchema, shots: z.array(projectShotSchema).min(1).max(100) }), project_link_shot_asset: z.object({ projectId: projectIdSchema, shotId: z.string().min(1), assetVersionId: z.string().min(1), role: z.enum(["reference", "start_frame", "end_frame", "keyframe", "storyboard", "output"]) }), project_start_workflow_step: z.object({ projectId: projectIdSchema, stepId: z.string().min(1) }), - project_link_asset: z.object({ projectId: projectIdSchema, assetId: z.string().min(1), category: z.string().min(1) }), + project_link_asset: z.object({ projectId: projectIdSchema, assetId: z.string().min(1), category: assetCategorySchema }), project_upsert_asset_version: z.object({ projectId: projectIdSchema, assetId: z.string().min(1), prompt: z.string().optional(), definitionJson: z.string().optional(), note: z.string().optional() }), project_register_task_output: z.object({ projectId: projectIdSchema, stepId: z.string().min(1), taskId: z.string().min(1), assetVersionId: z.string().optional(), resourceId: z.string().optional(), mediaType: z.string().optional(), role: z.enum(["reference", "start_frame", "end_frame", "keyframe", "storyboard", "output"]).optional(), metadataJson: z.string().optional(), outputJson: z.string().optional() }), } satisfies Record; @@ -196,7 +198,7 @@ export const toolDescriptions: Record = { canvas_run_generation: "触发指定节点生成;对已有生成任务明确重试时传 retry=true,以复用产品级重试幂等身份。", project_get_context: "读取当前短剧项目的章节、画布、资产、镜头、候选和工作流事实。", project_list_units: "按类型或状态筛选当前短剧项目的章节/项目单元。", - project_extract_asset_candidates: "将分镜识别出的角色、场景、服饰、道具或武器需求登记为待确认资产候选。", + project_extract_asset_candidates: "将分镜识别出的场景、道具、素材或其他需求登记为待确认资产候选;角色只在剧情章节提取,不通过此工具重复创建。", project_confirm_asset_candidate: "确认一个资产候选,创建正式资产或关联已有个人资产。", project_create_or_update_shots: "创建或更新项目镜头业务数据,不把镜头状态写进画布 metadata。", project_link_shot_asset: "将具体资产版本按首帧、尾帧或参考等用途关联到镜头。", diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 25948a275..6f92e552e 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,9 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 2。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引,用于验证在线更新中的顺序迁移、事务提交和版本健康检查。 +当前结构版本为 3。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引。 + +版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 ## 账号认证与密码找回 @@ -44,6 +46,8 @@ description: 影策后端核心持久化表与状态边界 | --- | --- | --- | | `projects` | 保存短剧项目基础设置、画幅、画风快照、项目主图和默认图片/视频模型 | `default_image_model` / `default_video_model` 保存前端模型选择器的渠道限定引用;模型下架或渠道停用时前端回退当前全局默认。`cover_resource_id` 只能引用当前用户已就绪的图片资源;资源删除检查会阻止仍被项目主图使用的图片被回收 | | `project_units` | 保存项目章节或集数的正文、排序和状态 | `word_count` 在正文创建、导入和更新时同步维护,项目详情只返回摘要和字数,不批量返回长篇正文;升级时会为旧正文回填字数 | +| `assets` / `asset_versions` / `project_asset_links` | 保存用户资产、不可变版本及项目引用 | 业务分类统一为 `character`、`environment`、`prop`、`material`、`other`;服饰、武器与配饰属于道具,图片/视频/音频等通用媒体默认属于素材 | +| `project_asset_candidates` | 保存章节或 Agent 提取后等待用户确认的资产候选 | `name_key` 保存规范化名称,`source` 记录提取入口;角色候选只允许来自 `chapter_character_extract` 且必须关联章节,同一项目、分类和规范化名称的待确认候选保持唯一 | | `shots` | 保存章节内稳定的镜头身份、排序和当前版本指针 | 不保存完整生成历史;`current_revision_id` 指向当前采用的分镜脚本版本 | | `shot_revisions` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 | | `shot_asset_references` | 将镜头绑定到具体资产版本 | 修改引用时只使该镜头的下游产物过期,不影响其他镜头 | @@ -66,7 +70,7 @@ description: 影策后端核心持久化表与状态边界 | `GET /api/projects/:id/units/:unitId/workspace` | 当前章节的镜头、版本、产物、引用、候选、相关任务和已绑定资产 | | `GET /api/projects/:id/canvases` | 项目画布与当前页章节关联,使用 `page`、`page_size` | | `GET /api/projects/:id/assets` | 项目资产分页、分类/目录计数,支持分类、媒体类型、目录和关键词筛选 | -| `GET /api/projects/:id/asset-candidates` | 资产候选分页,支持章节、状态和分类筛选 | +| `GET /api/projects/:id/asset-candidates` | 资产候选分页,支持章节、状态、分类和名称关键词筛选 | 旧 `GET /api/projects/:id` 暂时保留给尚未迁移的调用方;短剧工作台页面不得重新依赖该整包接口。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 8506d727a..dd594bc6f 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -127,7 +127,7 @@ description: 影策当前已实现的核心工作台能力与使用入口 ### 使用规则 - 选中了两个或以上节点时,只整理选中的可移动节点;没有选中节点时,整理所有顶层可移动节点。 -- 候选节点存在连线时,按连线依赖关系进行流程布局;没有连线时,自动使用网格布局。 +- 候选节点存在连线时,横向保持从左到右的依赖层级,纵向按文本、图片、视频、音频分泳道;没有连线时,各媒体类型分别使用网格布局,不再把图片与视频混排。 - 锁定节点、容器节点和折叠批次中的隐藏子节点不会被移动。 - 节点位置变化会进入现有画布历史记录,可使用“撤销”恢复整理前的布局,并继续由画布生命周期自动保存。 @@ -135,4 +135,4 @@ description: 影策当前已实现的核心工作台能力与使用入口 - `web/src/components/canvas/canvas-zoom-controls.tsx`:底部视图控制 Dock 与“自动整理节点”入口。 - `web/src/pages/canvas/use-canvas-node-operations.ts`:候选节点筛选、流程/网格布局选择和位置提交。 -- `web/src/lib/canvas/canvas-layout.ts`:流程、网格、行列排列和对齐算法。 +- `web/src/lib/canvas/canvas-layout.ts`:媒体泳道、拓扑流程、网格、行列排列和对齐算法。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 28b33f5df..fe87ca44f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -88,6 +88,9 @@ description: 影策已实现功能的手工验证清单与未确认边界 ## 短剧分镜工作流 v2 +- [x] 章节角色提取增加候选名称幂等:重复待确认角色、已经确认角色和别名不会再次创建;Agent 来源不能创建角色候选。数据库 schema v3 为候选增加 `name_key` / `source` 并建立待确认唯一索引,后端专项测试通过。 +- [x] 资产分类统一为角色、场景、道具、素材、其他;旧服饰/武器/配饰映射为道具,旧画风和通用媒体映射为素材。项目资产页、画布资产菜单、素材选择器和 Agent 合同已同步,前端生产构建与专项测试通过。 +- [x] 分镜工作台左栏调整为“章节 → 镜头 → 资产”并默认打开章节;项目资产页和分镜资产栏新增防抖名称搜索;镜头默认编号统一为“镜头01”,仅精确匹配旧 `SC.数字` 的默认标题会转换,自定义标题保持不变。 - [x] 后端新增镜头版本、镜头产物和生产任务上下文;分镜修改与资产版本变更会局部将旧产物标记为 `stale`,专项测试通过。 - [x] 短剧工作流调整为“剧情与章节 → 资产拆分 → 分镜脚本 → 黑白动作预演 → 视频生成 → 交付与打包”,旧 v1 实例保留,新章节使用 v2。 - [x] 项目工作台新增独立分镜工作流路由,支持初始化章节流程、新增镜头、保存分镜版本、绑定资产版本、查看预演/视频版本和交付缺失状态;前端生产构建通过。 @@ -101,6 +104,8 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 连续保存同一镜头多个版本并绑定不同资产版本,确认只有该镜头的动作预演和视频转为过期,其他镜头不受影响。 - [ ] 使用画布任务和项目任务分别登记镜头产物,确认 `production_task_links` 正确记录 Project、Canvas、Unit、Shot 和 WorkflowStep,且账号归属校验有效。 - [ ] 在浅色、深色、窄屏和常规桌面验证阶段导航、镜头列表、表单、检查器、媒体预览和长章节内容滚动。 +- [ ] 在角色与资产页分别搜索已确认资产和待确认角色,再切换分类、目录和分页;确认关键词重置到第一页、空态明确且没有重复请求抖动。进入分镜工作台确认左栏顺序、默认章节页和资产搜索一致。 +- [ ] 打开包含旧 `SC.01` 默认标题和用户自定义标题的项目,确认前者显示为“镜头01”且编号旁不重复,后者原样保留;新增镜头和章节生成镜头都直接使用中文编号。 - [ ] 在项目设置分别选择系统、用户自定义的默认生图/视频模型,刷新后进入分镜图、动作预演和镜头视频,确认项目默认优先、已下架模型回退全局默认;切换 Seedance、MiniMax-H3 等能力不同的模型时,画幅、时长、分辨率和本次预计积分立即更新,并与实际账单一致。 - [ ] 连续选择两个镜头并分别提交视频生成,确认两个镜头独立显示“正在提交/生成中”,第二个任务不会被第一个全局锁阻止;刷新页面后两个任务状态都能恢复,完成产物分别绑定各自提交时的镜头版本。 - [ ] 给镜头绑定带主图和声音样本的角色卡及场景资产,使用 MiniMax-H3 生成并检查请求日志:文本包含 `【资产参考】`、角色声音说明和台词,角色/场景图片为 `reference_image`、声音为 `reference_audio`,请求不再出现误判的 `first_frame`,画幅和 `768P` / `2K` 与界面选择一致。 @@ -345,7 +350,8 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] `web` 前端 TypeScript 检查与生产构建通过。 - [x] 画布底部视图控制中显示“自动整理节点”按钮。 -- [ ] 无选中节点时,确认多个顶层节点会按连线流程布局;无连线时会按网格布局。 +- [x] 布局纯函数专项测试覆盖有连线、无连线、选中子集、不同尺寸、锁定节点,以及文本/图片/视频/音频四类泳道。 +- [ ] 无选中节点时,确认多个顶层节点横向保持连线层级,纵向按文本、图片、视频、音频分区;无连线时同类节点各自在独立网格中排列。 - [ ] 选中节点时,确认只移动选中的可移动节点,未选中节点保持原位。 - [ ] 确认锁定节点、容器节点和折叠批次隐藏子节点不会被移动。 - [ ] 点击“撤销”确认可以恢复整理前的位置,并确认刷新画布后布局仍能保存。 diff --git a/web/src/components/canvas/asset-picker-modal.tsx b/web/src/components/canvas/asset-picker-modal.tsx index 9a7f90ec2..315497a01 100644 --- a/web/src/components/canvas/asset-picker-modal.tsx +++ b/web/src/components/canvas/asset-picker-modal.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; +import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category"; import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; @@ -21,7 +22,7 @@ type Props = { onClose: () => void; }; -const categoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; +const categoryLabels: Record = { all: "全部素材", ...ASSET_CATEGORY_LABELS }; export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) { const assets = useAssetStore((state) => state.assets); @@ -31,7 +32,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P ...insertableAssets.map((asset) => ({ id: asset.id, title: asset.title, - category: asset.category || "other", + category: normalizeAssetCategory(asset.category), kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", asset, searchText: (asset.tags || []).join(" "), diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 82ff60693..c7cc2d4e5 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; +import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { canvasThemes } from "@/lib/canvas-theme"; @@ -15,15 +16,7 @@ import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Canvas type CanvasAssetCategory = NonNullable["assetCategory"]>; -const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [ - { value: "character", label: "角色" }, - { value: "environment", label: "场景" }, - { value: "wardrobe", label: "服饰" }, - { value: "prop", label: "道具" }, - { value: "weapon", label: "武器" }, - { value: "style", label: "画风" }, - { value: "other", label: "其他" }, -]; +const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS; type CanvasNodeContextMenuProps = { menu: ContextMenuState; diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index 87ab4e1d4..84c1688f5 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -4,6 +4,7 @@ import type { MenuProps } from "antd"; import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; +import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category"; import { canvasThemes } from "@/lib/canvas-theme"; import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; @@ -58,15 +59,7 @@ type CanvasNodeToolbarProps = { type CanvasAssetCategory = NonNullable["assetCategory"]>; -const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [ - { value: "character", label: "角色" }, - { value: "environment", label: "场景" }, - { value: "wardrobe", label: "服饰" }, - { value: "prop", label: "道具" }, - { value: "weapon", label: "武器" }, - { value: "style", label: "画风" }, - { value: "other", label: "其他" }, -]; +const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS; type ToolbarTool = { id: string; diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx index fcc197a7a..d471bcb60 100644 --- a/web/src/components/canvas/canvas-project-asset-modal.tsx +++ b/web/src/components/canvas/canvas-project-asset-modal.tsx @@ -4,12 +4,13 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference"; +import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import type { ProjectAsset, ProjectDetail } from "@/services/api/projects"; import { getRemoteAsset } from "@/services/api/user-data"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; -const categoryLabels: Record = { all: "全部资产", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; +const categoryLabels: Record = { all: "全部资产", ...ASSET_CATEGORY_LABELS }; type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset }; export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise | void; onInsertFolder?: (folderId: string) => Promise | void }) { @@ -20,13 +21,13 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => { if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }]; const media = mediaById.get(asset.id); - return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: asset.category || media?.category || "other", folderId: asset.folderId, project: asset, media }]; + return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }]; }); if (detail) return projectItems; // 自由画布未关联项目时回退到个人素材库。 return mediaAssets .filter((asset) => asset.kind !== "model" && asset.kind !== "entity") - .map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media })); + .map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media })); }, [detail?.assets, mediaAssets]); const localPickerItems = useMemo(() => items.map((item) => { const character = item.character; diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index 19af80961..45656d074 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; +import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import { useThemeStore } from "@/stores/use-theme-store"; import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store"; @@ -504,16 +505,6 @@ function createInlinePreview(reference: CanvasResourceReference) { return fallback; } -const ASSET_CATEGORY_LABELS: Record = { - character: "角色", - environment: "场景", - wardrobe: "服饰", - prop: "道具", - weapon: "武器", - style: "画风", - other: "其他", -}; - function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: { anchor: HTMLElement; connectedReferences: CanvasResourceReference[]; diff --git a/web/src/lib/asset-category.ts b/web/src/lib/asset-category.ts new file mode 100644 index 000000000..f33a97297 --- /dev/null +++ b/web/src/lib/asset-category.ts @@ -0,0 +1,40 @@ +export const ASSET_CATEGORIES = ["character", "environment", "prop", "material", "other"] as const; + +export type AssetCategory = (typeof ASSET_CATEGORIES)[number]; + +export const ASSET_CATEGORY_LABELS: Record = { + character: "角色", + environment: "场景", + prop: "道具", + material: "素材", + other: "其他", +}; + +export const ASSET_CATEGORY_OPTIONS = ASSET_CATEGORIES.map((value) => ({ + value, + label: ASSET_CATEGORY_LABELS[value], +})); + +const LEGACY_ASSET_CATEGORY_MAP: Record = { + wardrobe: "prop", + weapon: "prop", + accessory: "prop", + style: "material", +}; + +export function normalizeAssetCategory(value: unknown, fallback: AssetCategory = "other"): AssetCategory { + if (typeof value !== "string") return fallback; + const normalized = value.trim().toLowerCase(); + if ((ASSET_CATEGORIES as readonly string[]).includes(normalized)) return normalized as AssetCategory; + return LEGACY_ASSET_CATEGORY_MAP[normalized] || fallback; +} + +export function defaultAssetCategoryForKind(kind: string): AssetCategory { + if (kind === "entity") return "character"; + if (kind === "image" || kind === "video" || kind === "audio" || kind === "model") return "material"; + return "other"; +} + +export function assetCategoryLabel(value: unknown) { + return ASSET_CATEGORY_LABELS[normalizeAssetCategory(value)]; +} diff --git a/web/src/lib/asset-storage-revision.ts b/web/src/lib/asset-storage-revision.ts index 5ec505a60..a52c8e0ce 100644 --- a/web/src/lib/asset-storage-revision.ts +++ b/web/src/lib/asset-storage-revision.ts @@ -1,4 +1,5 @@ import type { Asset } from "@/stores/use-asset-store"; +import { defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category"; export type AssetStorageDocument = { state: { assets: Asset[] }; @@ -8,10 +9,12 @@ export type AssetStorageDocument = { }; export function normalizeAssetRecord(asset: Asset): Asset { - if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string")) return asset; + const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)); + if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category) return asset; return { ...asset, tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [], + category, }; } diff --git a/web/src/lib/canvas/canvas-layout.ts b/web/src/lib/canvas/canvas-layout.ts index d38286c28..274fc32a5 100644 --- a/web/src/lib/canvas/canvas-layout.ts +++ b/web/src/lib/canvas/canvas-layout.ts @@ -1,7 +1,14 @@ -import type { CanvasConnection, CanvasNodeData, Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas"; export type CanvasLayoutMode = "row" | "column" | "grid"; export type CanvasAlignmentMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom" | "distributeX" | "distributeY"; +export type CanvasLayoutLane = "text" | "image" | "video" | "audio"; + +const FLOW_COLUMN_GAP = 120; +const LANE_GAP = 96; +const LANE_NODE_GAP = 36; +const GRID_NODE_GAP = 32; +const LANE_ORDER: CanvasLayoutLane[] = ["text", "image", "video", "audio"]; export function layoutCanvasNodes(nodes: CanvasNodeData[], mode: CanvasLayoutMode) { const sorted = [...nodes].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x); @@ -36,6 +43,7 @@ export function layoutCanvasNodes(nodes: CanvasNodeData[], mode: CanvasLayoutMod } export function layoutCanvasFlow(nodes: CanvasNodeData[], connections: CanvasConnection[]) { + if (!nodes.length) return new Map(); const selectedIds = new Set(nodes.map((node) => node.id)); const inbound = new Map(nodes.map((node) => [node.id, 0])); const outbound = new Map(nodes.map((node) => [node.id, [] as string[]])); @@ -63,29 +71,83 @@ export function layoutCanvasFlow(nodes: CanvasNodeData[], connections: CanvasCon if (!layerById.has(node.id)) layerById.set(node.id, 0); }); - const groups = new Map(); - nodes.forEach((node) => { - const layer = layerById.get(node.id) || 0; - groups.set(layer, [...(groups.get(layer) || []), node]); - }); - const left = Math.min(...nodes.map((node) => node.position.x)); const top = Math.min(...nodes.map((node) => node.position.y)); const result = new Map(); + const layers = [...new Set(layerById.values())].sort((a, b) => a - b); + const layerX = new Map(); let x = left; - [...groups.keys()].sort((a, b) => a - b).forEach((layer) => { - const column = groups.get(layer)!.sort((a, b) => a.position.y - b.position.y); - let y = top; - const width = Math.max(...column.map((node) => node.width)); - column.forEach((node) => { - result.set(node.id, { x, y }); - y += node.height + 48; + layers.forEach((layer) => { + const layerNodes = nodes.filter((node) => layerById.get(node.id) === layer); + layerX.set(layer, x); + x += Math.max(...layerNodes.map((node) => node.width)) + FLOW_COLUMN_GAP; + }); + + let laneTop = top; + LANE_ORDER.forEach((lane) => { + const laneNodes = nodes.filter((node) => canvasLayoutLane(node) === lane); + if (!laneNodes.length) return; + const stacks = new Map(); + laneNodes.forEach((node) => { + const layer = layerById.get(node.id) || 0; + stacks.set(layer, [...(stacks.get(layer) || []), node]); }); - x += width + 120; + let laneHeight = 0; + stacks.forEach((stack) => { + const height = stack.reduce((sum, node) => sum + node.height, 0) + Math.max(0, stack.length - 1) * LANE_NODE_GAP; + laneHeight = Math.max(laneHeight, height); + }); + stacks.forEach((stack, layer) => { + let y = laneTop; + stack.sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x).forEach((node) => { + result.set(node.id, { x: layerX.get(layer) || left, y }); + y += node.height + LANE_NODE_GAP; + }); + }); + laneTop += laneHeight + LANE_GAP; }); return result; } +export function layoutCanvasNodesByMediaType(nodes: CanvasNodeData[]) { + if (!nodes.length) return new Map(); + const left = Math.min(...nodes.map((node) => node.position.x)); + const top = Math.min(...nodes.map((node) => node.position.y)); + const result = new Map(); + let laneTop = top; + + LANE_ORDER.forEach((lane) => { + const laneNodes = nodes.filter((node) => canvasLayoutLane(node) === lane).sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x); + if (!laneNodes.length) return; + const columns = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(laneNodes.length)))); + const cellWidth = Math.max(...laneNodes.map((node) => node.width)) + GRID_NODE_GAP; + const cellHeight = Math.max(...laneNodes.map((node) => node.height)) + GRID_NODE_GAP; + laneNodes.forEach((node, index) => { + result.set(node.id, { + x: left + (index % columns) * cellWidth, + y: laneTop + Math.floor(index / columns) * cellHeight, + }); + }); + laneTop += Math.ceil(laneNodes.length / columns) * cellHeight - GRID_NODE_GAP + LANE_GAP; + }); + return result; +} + +export function layoutCanvasAuto(nodes: CanvasNodeData[], connections: CanvasConnection[]) { + const candidates = nodes.filter((node) => !node.metadata?.locked && node.type !== CanvasNodeType.Frame); + if (candidates.length < 2) return new Map(); + const candidateIds = new Set(candidates.map((node) => node.id)); + const hasConnections = connections.some((connection) => candidateIds.has(connection.fromNodeId) && candidateIds.has(connection.toNodeId)); + return hasConnections ? layoutCanvasFlow(candidates, connections) : layoutCanvasNodesByMediaType(candidates); +} + +export function canvasLayoutLane(node: CanvasNodeData): CanvasLayoutLane { + if (node.type === CanvasNodeType.Video) return "video"; + if (node.type === CanvasNodeType.Audio) return "audio"; + if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.Compare || node.type === CanvasNodeType.ColorGrade) return "image"; + return "text"; +} + export function alignCanvasNodes(nodes: CanvasNodeData[], mode: CanvasAlignmentMode) { const result = new Map(); if (nodes.length < 2) return result; diff --git a/web/src/lib/canvas/canvas-node-asset.ts b/web/src/lib/canvas/canvas-node-asset.ts index e91648af4..c21b210f7 100644 --- a/web/src/lib/canvas/canvas-node-asset.ts +++ b/web/src/lib/canvas/canvas-node-asset.ts @@ -116,13 +116,14 @@ export function declaredCanvasNodeAssetCategory(node: CanvasNodeData): AssetCate if (node.metadata?.assetCategory) return node.metadata.assetCategory; if (node.metadata?.workflowKind === "character") return "character"; if (node.metadata?.workflowKind === "scene") return "environment"; - if (node.metadata?.workflowKind === "styleboard") return "style"; + if (node.metadata?.workflowKind === "styleboard") return "material"; return undefined; } export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory { const declared = declaredCanvasNodeAssetCategory(node); if (declared) return declared; + if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) return "material"; return "other"; } diff --git a/web/src/lib/canvas/canvas-storage-revision.ts b/web/src/lib/canvas/canvas-storage-revision.ts index a6510a554..71746eea3 100644 --- a/web/src/lib/canvas/canvas-storage-revision.ts +++ b/web/src/lib/canvas/canvas-storage-revision.ts @@ -1,5 +1,6 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas"; +import { normalizeAssetCategory } from "@/lib/asset-category"; export type CanvasStorageTombstones = { projects: Record; @@ -61,7 +62,7 @@ function normalizeTombstones(value: unknown): CanvasStorageTombstones { export function parseCanvasStorageDocument(value: string | null, fallback: CanvasProject[] = []): CanvasStorageDocument { if (!value) { return { - state: { projects: fallback }, + state: { projects: normalizeProjectAssetCategories(fallback) }, version: 0, storageRevision: 0, tombstones: emptyTombstones(), @@ -75,13 +76,28 @@ export function parseCanvasStorageDocument(value: string | null, fallback: Canva }; if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效"); return { - state: { projects: parsed.state.projects as CanvasProject[] }, + state: { projects: normalizeProjectAssetCategories(parsed.state.projects as CanvasProject[]) }, version: typeof parsed.version === "number" ? parsed.version : 0, storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0, tombstones: normalizeTombstones(parsed.tombstones), }; } +function normalizeProjectAssetCategories(projects: CanvasProject[]) { + return projects.map((project) => { + let changed = false; + const nodes = project.nodes.map((node) => { + const category = node.metadata?.assetCategory; + if (!category) return node; + const normalized = normalizeAssetCategory(category); + if (normalized === category) return node; + changed = true; + return { ...node, metadata: { ...node.metadata, assetCategory: normalized } }; + }); + return changed ? { ...project, nodes } : project; + }); +} + export function serializeCanvasStorageDocument(document: CanvasStorageDocument) { return JSON.stringify(document); } diff --git a/web/src/lib/canvas/canvas-storyboard-assets.ts b/web/src/lib/canvas/canvas-storyboard-assets.ts index 8133f62e2..9b982a776 100644 --- a/web/src/lib/canvas/canvas-storyboard-assets.ts +++ b/web/src/lib/canvas/canvas-storyboard-assets.ts @@ -38,7 +38,7 @@ export function storyboardAssetRoleForNode(node: CanvasNodeData): StoryboardAsse if (node.type === CanvasNodeType.Audio) return "audio"; if (node.type === CanvasNodeType.Video) return "motion"; const category = node.metadata?.assetCategory; - if (category === "environment" || category === "wardrobe" || category === "prop" || category === "weapon" || category === "style") return category; + if (category === "environment" || category === "prop") return category; if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing) return "style"; return null; } diff --git a/web/src/lib/shot-label.ts b/web/src/lib/shot-label.ts new file mode 100644 index 000000000..863ea2d0b --- /dev/null +++ b/web/src/lib/shot-label.ts @@ -0,0 +1,15 @@ +const LEGACY_DEFAULT_SHOT_TITLE = /^SC\.\d+$/i; + +export function formatShotOrdinal(index: number) { + return `镜头${String(Math.max(0, index) + 1).padStart(2, "0")}`; +} + +export function normalizeDefaultShotTitle(title: string | undefined, index: number) { + const value = (title || "").trim(); + return LEGACY_DEFAULT_SHOT_TITLE.test(value) ? formatShotOrdinal(index) : value; +} + +export function customShotTitle(title: string | undefined, index: number) { + const value = normalizeDefaultShotTitle(title, index); + return value === formatShotOrdinal(index) ? "" : value; +} diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index aff8e179c..b5e69d40b 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -12,6 +12,7 @@ import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/ass import { saveAs } from "file-saver"; import { useCopyText } from "@/hooks/use-copy-text"; +import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils"; import { uploadImage } from "@/services/image-storage"; @@ -48,13 +49,7 @@ const kindOptions = [ const categoryOptions = [ { label: "全部分类", value: "all" }, - { label: "角色", value: "character" }, - { label: "场景", value: "environment" }, - { label: "服饰", value: "wardrobe" }, - { label: "道具", value: "prop" }, - { label: "武器", value: "weapon" }, - { label: "画风", value: "style" }, - { label: "其他", value: "other" }, + ...ASSET_CATEGORY_OPTIONS, ]; const assetKindIcons: Record = { @@ -781,10 +776,6 @@ function assetSearchText(asset: LibraryAsset) { return [asset.title, asset.source || "", asset.note || "", assetCategoryLabel(asset.category), (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase(); } -function assetCategoryLabel(category?: AssetCategory) { - return categoryOptions.find((item) => item.value === (category || "other"))?.label || "其他"; -} - function assetProjectLabel(asset: LibraryAsset) { const projectName = asset.metadata?.projectName; if (typeof projectName === "string" && projectName.trim()) return projectName; diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index f6d0aa686..be496f853 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -5,7 +5,7 @@ import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame"; -import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; +import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain"; import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy"; import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas"; @@ -200,12 +200,9 @@ export function useCanvasNodeOperations({ return; } - const candidateIds = new Set(candidates.map((node) => node.id)); - const hasConnections = connectionsRef.current.some((connection) => candidateIds.has(connection.fromNodeId) && candidateIds.has(connection.toNodeId)); - const mode = hasConnections ? "flow" : "grid"; - const positions = mode === "flow" ? layoutCanvasFlow(candidates, connectionsRef.current) : layoutCanvasNodes(candidates, "grid"); + const positions = layoutCanvasAuto(candidates, connectionsRef.current); commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node)); - message.success(mode === "flow" ? (hasSelection ? "已按连线整理选中节点" : "已按连线整理画布") : (hasSelection ? "已网格整理选中节点" : "已网格整理画布")); + message.success(hasSelection ? "已按媒体分类整理选中节点" : "已按媒体分类整理画布"); }, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]); const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => { diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index a1831236e..a3c708c7f 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -14,8 +14,10 @@ import { VoiceRecordingButton } from "@/components/conversation/voice-recording- import { ModelPicker } from "@/components/model-picker"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; +import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; +import { formatShotOrdinal } from "@/lib/shot-label"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; @@ -934,7 +936,7 @@ export default function CreatePage() { setCount, promptOptimizerProvider, composerFocusRef, - placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, + placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), }; @@ -1018,7 +1020,7 @@ export default function CreatePage() { ; } -const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; +const creationAssetCategoryLabels: Record = { all: "全部素材", ...ASSET_CATEGORY_LABELS }; function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) { const [keyword, setKeyword] = useState(""); @@ -1572,9 +1574,9 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability } const creationEmptyBannerFrames = [ - { src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" }, - { src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" }, - { src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" }, + { src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "镜头01 · 雨夜霓虹" }, + { src: "/short-drama-styles/suspense-noir.jpg", caption: "镜头02 · 暗巷追逐" }, + { src: "/short-drama-styles/retro-hong-kong.jpg", caption: "镜头03 · 天台重逢" }, ]; function CreationEmptyBanner() { @@ -1630,7 +1632,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
{railOpen ?
-
镜头时间线{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}
+
镜头时间线{composing ? `下一镜 ${formatShotOrdinal(nextShotNumber - 1)}` : `${shots.length} 个镜头`}
    {shots.map((shot, index) => { const status = statusOf(shot); @@ -1639,15 +1641,15 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom const thumbIsVideo = shot.result?.mode === "video"; return
  • ; })} - {composing ?
  • : null} + {composing ?
  • : null}
: null} @@ -1677,7 +1679,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, return
- SC.{String(shotNumber).padStart(2, "0")}镜头 {shotNumber} + {formatShotOrdinal(shotNumber - 1)} {mode === "video" ? : mode === "image" ? : }{modeLabels[mode]} {modelName ? {modelName} : null} {status === "pending" ? 生成中 : status === "error" ? 生成失败 : status === "done" ? 已完成 : 待生成} @@ -1720,7 +1722,7 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; return
- SC.{String(shotNumber).padStart(2, "0")}下一镜 {shotNumber} + {formatShotOrdinal(shotNumber - 1)}下一镜 待撰写
@@ -1731,8 +1733,8 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
- SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本 - 在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。 + {formatShotOrdinal(shotNumber - 1)} 等待你的脚本 + 在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。
diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index cf6c4e399..2d391d4a7 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useMutation, useMutationState, useQuery } from "@tanstack/react-query"; import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Tabs, type FormInstance } from "antd"; -import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react"; +import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -10,10 +10,12 @@ import { CachedResourceImage } from "@/components/cached-resource-image"; import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card"; import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview"; import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme"; import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal"; import { CHARACTER_VOICE_FORMAT_LABEL, CHARACTER_VOICE_UPLOAD_ACCEPT, characterVoiceFormatName, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats"; +import { ASSET_CATEGORIES, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { uploadMediaFile } from "@/services/file-storage"; import { @@ -49,7 +51,7 @@ import { linkSelectedProjectAssets } from "./project-asset-linking"; import { generateCharacterTurnaround } from "./project-character-media"; import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared"; -const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"]; +const categories = ["all", ...ASSET_CATEGORIES]; const ALL_FOLDERS = "__all_folders__"; const pickerCategoryLabels = { all: "全部素材", ...categoryLabels }; const characterFields = [ @@ -73,6 +75,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(40); const [candidatePage, setCandidatePage] = useState(1); + const [keyword, setKeyword] = useState(""); + const debouncedKeyword = useDebouncedValue(keyword.trim(), 250); const candidatePageSize = 24; const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null); const [folderName, setFolderName] = useState(""); @@ -88,19 +92,20 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const [form] = Form.useForm(); const assetsQuery = useQuery({ - queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId], + queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId, debouncedKeyword], queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category, folderId: folderId === ALL_FOLDERS ? undefined : folderId, + query: debouncedKeyword || undefined, }), }); const foldersQuery = useQuery({ queryKey: ["project", detail.project.id, "asset-folders"], queryFn: () => listProjectAssetFolders(detail.project.id) }); const showPendingCandidates = folderId === ALL_FOLDERS && (category === "all" || category === "character"); const candidatesQuery = useQuery({ - queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation"], - queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation" }), + queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation", debouncedKeyword], + queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation", query: debouncedKeyword || undefined }), }); const assets = assetsQuery.data?.assets || []; const assetFolders = foldersQuery.data?.folders || []; @@ -121,6 +126,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const lastPage = Math.max(1, Math.ceil(candidatesQuery.data.total / candidatePageSize)); if (candidatePage > lastPage) setCandidatePage(lastPage); }, [candidatePage, candidatesQuery.data]); + useEffect(() => { + setPage(1); + setCandidatePage(1); + }, [debouncedKeyword]); const selectFolder = (nextFolderId: string) => { setFolderId(nextFolderId); @@ -140,7 +149,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet ...availableAssets.map((asset) => ({ id: asset.id, title: asset.title, - category: asset.kind === "entity" ? "character" : asset.category || "other", + category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)), kindLabel: mediaLabel(asset.kind), asset, description: asset.note, @@ -152,7 +161,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet ...imageAssets.map((asset) => ({ id: asset.id, title: asset.title, - category: asset.category || "other", + category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)), kindLabel: "图片", asset, searchText: (asset.tags || []).join(" "), @@ -210,7 +219,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const assetId = addAsset(imported); return linkProjectAsset(detail.project.id, { assetId, - category: imported.kind === "entity" ? "character" : imported.category || "other", + category: normalizeAssetCategory(imported.category, defaultAssetCategoryForKind(imported.kind)), folderId: nextFolderId, }); } @@ -218,7 +227,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet if (!selected) throw new Error("所选素材已不存在,请重新选择"); return linkProjectAsset(detail.project.id, { assetId: selected.id, - category: selected.kind === "entity" ? "character" : selected.category || "other", + category: normalizeAssetCategory(selected.category, defaultAssetCategoryForKind(selected.kind)), folderId: nextFolderId, }); }); @@ -238,7 +247,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet }); const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") }); const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") }); - const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") }); + const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: AssetCategory }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") }); const moveMutation = useMutation({ mutationFn: ({ id, nextFolderId }: { id: string; nextFolderId: string }) => moveProjectAsset(detail.project.id, id, nextFolderId), onSuccess: () => done("资产已移动"), onError: failed("资产移动失败") }); const createFolderMutation = useMutation({ mutationFn: ({ name, parentId }: { name: string; parentId: string }) => createProjectAssetFolder(detail.project.id, { name, parentId: parentId || undefined }), @@ -392,7 +401,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet {folderId === ALL_FOLDERS ? <>/全部资产 : folderPath.map((folder) => /)}
- {folderId !== ALL_FOLDERS ? : null} +
+ setKeyword(event.target.value)} prefix={} placeholder="搜索资产名称" aria-label="搜索项目资产" className="w-52" /> + {folderId !== ALL_FOLDERS ? : null} +
{childFolders.length ?
{childFolders.map((folder) => selectFolder(folder.id)} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}
: null} {folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? ( @@ -423,7 +435,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet ) : null}
{currentFolder?.name || (folderId === ALL_FOLDERS ? (category === "all" ? "全部资产" : categoryLabel(category)) : "根目录内容")}{assetsQuery.data?.total || 0} 项已确认
- {assetsQuery.isLoading ? : visibleAssets.length ? <>
{visibleAssets.map((asset) => asset.category === "character" ? setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
{ setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : } + {assetsQuery.isLoading ? : visibleAssets.length ? <>
{visibleAssets.map((asset) => asset.category === "character" ? setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
{ setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : } @@ -648,8 +660,8 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }: type CharacterFormKey = (typeof characterFields)[number][0]; -function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) { - return
value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}>·v{Math.max(1, asset.versionCount)}
{mediaLabel(asset.mediaType)}
onMove(key) }}>
; +function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: AssetCategory) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) { + return
value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(normalizeAssetCategory(key)) }}>·v{Math.max(1, asset.versionCount)}
{mediaLabel(asset.mediaType)}
onMove(key) }}>
; } function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) { diff --git a/web/src/pages/projects/detail/chapter-storyboard-production.ts b/web/src/pages/projects/detail/chapter-storyboard-production.ts index 5a7f47d06..a55474be1 100644 --- a/web/src/pages/projects/detail/chapter-storyboard-production.ts +++ b/web/src/pages/projects/detail/chapter-storyboard-production.ts @@ -1,4 +1,5 @@ import type { StoryboardRow } from "@/types/canvas"; +import { formatShotOrdinal } from "@/lib/shot-label"; import type { ProjectDetail, ShotRevisionInput } from "@/services/api/projects"; import { ensureShotAssetMentionPrompt } from "./workflow-shot-references"; @@ -90,7 +91,7 @@ export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: Pro return reference ? [reference] : []; }); return { - title: `SC.${String(shotNumber).padStart(2, "0")}`, + title: formatShotOrdinal(index), description, durationMs, assetVersionIds: storyboardRowAssetVersionIds(row, versionByAssetId), diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx index f03ad8c9d..83b653aa0 100644 --- a/web/src/pages/projects/detail/chapters.tsx +++ b/web/src/pages/projects/detail/chapters.tsx @@ -347,17 +347,17 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true })); }; const storeExtractedCharacters = async (unitId: string, characters: Awaited>) => { - // 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。 - const knownNames = new Set(detail.assetCandidates - .filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation") - .map((candidate) => normalizeCharacterName(candidate.name))); + const knownNames = new Set([ + ...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)), + ...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)), + ]); const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name))); - if (fresh.length) { - await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } }))); - } + const created = fresh.length + ? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract") + : { candidates: [] }; markChapterOperationCompleted(unitId, "characters"); refreshProject(); - return fresh.length; + return created.candidates.length; }; const storeGeneratedStoryboard = async (unitId: string, rows: ReturnType["rows"]) => { const shots = storyboardRowsToProjectShots(rows, detail); diff --git a/web/src/pages/projects/detail/shared.tsx b/web/src/pages/projects/detail/shared.tsx index fc0f579ef..73694fe56 100644 --- a/web/src/pages/projects/detail/shared.tsx +++ b/web/src/pages/projects/detail/shared.tsx @@ -2,6 +2,7 @@ import { Tag } from "antd"; import type { ReactNode } from "react"; import type { ProjectDetail, ProjectUnit } from "@/services/api/projects"; +import { ASSET_CATEGORY_LABELS, assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category"; export type ProjectDetailViewProps = { detail: ProjectDetail; @@ -9,15 +10,7 @@ export type ProjectDetailViewProps = { onCreateCanvas: () => void; }; -export const categoryLabels: Record = { - character: "角色", - environment: "场景", - wardrobe: "服饰", - prop: "道具", - weapon: "武器", - style: "画风", - other: "其他", -}; +export const categoryLabels: Record = ASSET_CATEGORY_LABELS; export const mediaLabels: Record = { image: "图片", @@ -64,7 +57,7 @@ const sourceTypeLabels: Record = { }; export function categoryLabel(value: string) { - return categoryLabels[value] || "其他"; + return sharedAssetCategoryLabel(value); } export function statusLabel(value: string) { diff --git a/web/src/pages/projects/detail/workflow-production-workbench.tsx b/web/src/pages/projects/detail/workflow-production-workbench.tsx index e3c138d97..44fcefdd7 100644 --- a/web/src/pages/projects/detail/workflow-production-workbench.tsx +++ b/web/src/pages/projects/detail/workflow-production-workbench.tsx @@ -1,14 +1,16 @@ 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 { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, Search, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; 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 { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label"; import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { formatVideoResolutionLabel } from "@/lib/video-generation-options"; import { submitBackendGenerationTask } from "@/services/api/generation-task"; @@ -80,7 +82,7 @@ export default function WorkflowProductionWorkbench(props: Props) { const [form] = Form.useForm(); const watchedDuration = Form.useWatch("durationSeconds", form); const watchedTitle = Form.useWatch("title", form); - const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("assets"); + const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("episodes"); const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest"); const [previewArtifactId, setPreviewArtifactId] = useState(""); const [editorDirty, setEditorDirty] = useState(false); @@ -219,7 +221,7 @@ export default function WorkflowProductionWorkbench(props: Props) { : shotDurationSeconds; const videoPrompt = ensureShotAssetMentionPrompt(revision?.videoPrompt || "", shotAssetReferenceContext.mentionReferences); form.setFieldsValue({ - title: selectedShot?.title || "", + title: normalizeDefaultShotTitle(selectedShot?.title, Math.max(0, shotIndex)), plotDescription: revision?.plotDescription || selectedShot?.description || "", action: revision?.action || "", dialogue: revision?.dialogue || "", @@ -438,7 +440,7 @@ export default function WorkflowProductionWorkbench(props: Props) { size="small" value={leftTab} onChange={(value) => setLeftTab(value as typeof leftTab)} - options={[{ value: "assets", label: "资产" }, { value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }]} + options={[{ value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }, { value: "assets", label: "资产" }]} />
{leftTab === "assets" ? changeAssetBinding.mutate({ asset, reference })} /> : null} @@ -450,8 +452,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
- SC.{String(shotIndex + 1).padStart(2, "0")} -

{watchedTitle || selectedShot.title || "未命名镜头"}

+ {formatShotOrdinal(shotIndex)} + {customShotTitle(watchedTitle || selectedShot.title, shotIndex) ?

{customShotTitle(watchedTitle || selectedShot.title, shotIndex)}

: null} {saveShot.isPending ? "保存中" : editorDirty ? "有未保存修改" : revision ? "已保存" : "草稿"}
{shotIndex + 1} / {shots.length}
@@ -590,12 +592,15 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "", function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map; changing: boolean; onToggle: (asset: ProjectAsset, reference?: ShotAssetReference) => void }) { const [category, setCategory] = useState("all"); + const [keyword, setKeyword] = useState(""); + const debouncedKeyword = useDebouncedValue(keyword.trim(), 250); const [page, setPage] = useState(1); const pageSize = 30; const assetsQuery = useQuery({ - queryKey: ["project", detail.project.id, "assets", "workflow-library", category, page, pageSize], - queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category }), + queryKey: ["project", detail.project.id, "assets", "workflow-library", category, debouncedKeyword, page, pageSize], + queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category, query: debouncedKeyword || undefined }), }); + useEffect(() => setPage(1), [debouncedKeyword]); const assetsPage = assetsQuery.data?.assets || []; const groups = useMemo(() => { const map = new Map(); @@ -604,7 +609,7 @@ function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { de }, [assetsPage]); const total = assetsQuery.data?.total || 0; const pages = Math.max(1, Math.ceil(total / pageSize)); - return
setKeyword(event.target.value)} prefix={} placeholder="搜索资产名称" aria-label="搜索镜头资产" />