feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理
This commit is contained in:
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
28
backend/internal/model/asset_category.go
Normal file
28
backend/internal/model/asset_category.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
17
backend/internal/model/project_asset_identity.go
Normal file
17
backend/internal/model/project_asset_identity.go
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ToolName, z.AnyZodObject>;
|
||||
@@ -196,7 +198,7 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
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: "将具体资产版本按首帧、尾帧或参考等用途关联到镜头。",
|
||||
|
||||
@@ -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` 暂时保留给尚未迁移的调用方;短剧工作台页面不得重新依赖该整包接口。
|
||||
|
||||
|
||||
@@ -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`:媒体泳道、拓扑流程、网格、行列排列和对齐算法。
|
||||
|
||||
@@ -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] 布局纯函数专项测试覆盖有连线、无连线、选中子集、不同尺寸、锁定节点,以及文本/图片/视频/音频四类泳道。
|
||||
- [ ] 无选中节点时,确认多个顶层节点横向保持连线层级,纵向按文本、图片、视频、音频分区;无连线时同类节点各自在独立网格中排列。
|
||||
- [ ] 选中节点时,确认只移动选中的可移动节点,未选中节点保持原位。
|
||||
- [ ] 确认锁定节点、容器节点和折叠批次隐藏子节点不会被移动。
|
||||
- [ ] 点击“撤销”确认可以恢复整理前的位置,并确认刷新画布后布局仍能保存。
|
||||
|
||||
@@ -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<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||||
const categoryLabels: Record<string, string> = { 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(" "),
|
||||
|
||||
@@ -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<NonNullable<CanvasNodeData["metadata"]>["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;
|
||||
|
||||
@@ -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<NonNullable<CanvasNodeData["metadata"]>["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;
|
||||
|
||||
@@ -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<string, string> = { all: "全部资产", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||||
const categoryLabels: Record<string, string> = { 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> | void; onInsertFolder?: (folderId: string) => Promise<void> | 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<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
|
||||
@@ -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<AssetCategory, string> = {
|
||||
character: "角色",
|
||||
environment: "场景",
|
||||
wardrobe: "服饰",
|
||||
prop: "道具",
|
||||
weapon: "武器",
|
||||
style: "画风",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
|
||||
anchor: HTMLElement;
|
||||
connectedReferences: CanvasResourceReference[];
|
||||
|
||||
40
web/src/lib/asset-category.ts
Normal file
40
web/src/lib/asset-category.ts
Normal file
@@ -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<AssetCategory, string> = {
|
||||
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<string, AssetCategory> = {
|
||||
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)];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Position>();
|
||||
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<number, CanvasNodeData[]>();
|
||||
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<string, Position>();
|
||||
const layers = [...new Set(layerById.values())].sort((a, b) => a - b);
|
||||
const layerX = new Map<number, number>();
|
||||
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<number, CanvasNodeData[]>();
|
||||
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<string, Position>();
|
||||
const left = Math.min(...nodes.map((node) => node.position.x));
|
||||
const top = Math.min(...nodes.map((node) => node.position.y));
|
||||
const result = new Map<string, Position>();
|
||||
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<string, Position>();
|
||||
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<string, Position>();
|
||||
if (nodes.length < 2) return result;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
15
web/src/lib/shot-label.ts
Normal file
15
web/src/lib/shot-label.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<LibraryAsset["kind"], LucideIcon> = {
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||||
const creationAssetCategoryLabels: Record<string, string> = { 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
|
||||
<div className="storyboard-workbench-rail">
|
||||
<Tooltip title="镜头时间线"><button type="button" className={`storyboard-workbench-rail-button${railOpen ? " is-open" : ""}${composing ? " is-draft" : ""}`} aria-expanded={railOpen} aria-label="镜头时间线" onClick={() => setRailOpen((value) => !value)}><Film /><span className="storyboard-workbench-rail-badge">{composing ? nextShotNumber : shots.length}</span></button></Tooltip>
|
||||
{railOpen ? <div className="storyboard-workbench-rail-pop" role="listbox" aria-label="镜头列表">
|
||||
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
|
||||
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 ${formatShotOrdinal(nextShotNumber - 1)}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
|
||||
<ul className="creation-scrollbar">
|
||||
{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 <li key={shot.user?.id || shot.result?.id || index}>
|
||||
<button type="button" className={`storyboard-workbench-rail-row${index === activeIndex && !composing ? " is-active" : ""}`} onClick={() => { onSelect(index); closeRail(); }}>
|
||||
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(index + 1).padStart(2, "0")}</em></span>}</span>
|
||||
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>{formatShotOrdinal(index)}</em></span>}</span>
|
||||
<span className="storyboard-workbench-rail-info">
|
||||
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(index + 1).padStart(2, "0")}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
|
||||
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">{formatShotOrdinal(index)}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
|
||||
<span className="storyboard-workbench-rail-row-title">{title}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>;
|
||||
})}
|
||||
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(nextShotNumber).padStart(2, "0")}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(nextShotNumber).padStart(2, "0")}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
|
||||
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>{formatShotOrdinal(nextShotNumber - 1)}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">{formatShotOrdinal(nextShotNumber - 1)}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
|
||||
</ul>
|
||||
<button type="button" className="storyboard-workbench-rail-pop-add" onClick={() => { closeRail(); onBeginCompose(); }}><Plus />新增镜头</button>
|
||||
</div> : null}
|
||||
@@ -1677,7 +1679,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
|
||||
return <article className={`storyboard-workbench-card is-${status}`}>
|
||||
<header className="storyboard-workbench-card-head">
|
||||
<div className="storyboard-workbench-card-heading">
|
||||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>镜头 {shotNumber}</span>
|
||||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">{formatShotOrdinal(shotNumber - 1)}</span></span>
|
||||
<span className="storyboard-workbench-card-mode">{mode === "video" ? <Film /> : mode === "image" ? <ImageIcon /> : <MessageSquareText />}{modeLabels[mode]}</span>
|
||||
{modelName ? <span className="storyboard-workbench-card-model">{modelName}</span> : null}
|
||||
{status === "pending" ? <span className="storyboard-workbench-card-state is-pending"><LoaderCircle className="animate-spin" />生成中</span> : status === "error" ? <span className="storyboard-workbench-card-state is-error">生成失败</span> : status === "done" ? <span className="storyboard-workbench-card-state is-done"><Check />已完成</span> : <span className="storyboard-workbench-card-state">待生成</span>}
|
||||
@@ -1720,7 +1722,7 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
|
||||
return <article className="storyboard-workbench-card is-next">
|
||||
<header className="storyboard-workbench-card-head">
|
||||
<div className="storyboard-workbench-card-heading">
|
||||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>下一镜 {shotNumber}</span>
|
||||
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">{formatShotOrdinal(shotNumber - 1)}</span>下一镜</span>
|
||||
<span className="storyboard-workbench-card-state is-draft"><Clapperboard />待撰写</span>
|
||||
</div>
|
||||
<div className="storyboard-workbench-card-actions">
|
||||
@@ -1731,8 +1733,8 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
|
||||
<div className="storyboard-workbench-next-panel">
|
||||
<span className="storyboard-workbench-next-panel-icon"><Clapperboard /></span>
|
||||
<div className="storyboard-workbench-next-panel-copy">
|
||||
<strong>SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本</strong>
|
||||
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。</span>
|
||||
<strong>{formatShotOrdinal(shotNumber - 1)} 等待你的脚本</strong>
|
||||
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<CharacterForm>();
|
||||
|
||||
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
|
||||
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => selectFolder("")}>素材库</button>
|
||||
{folderId === ALL_FOLDERS ? <><span>/</span><span className="font-medium text-foreground">全部资产</span></> : folderPath.map((folder) => <span key={folder.id} className="contents"><span>/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => selectFolder(folder.id)}>{folder.name}</button></span>)}
|
||||
</div>
|
||||
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索项目资产" className="w-52" />
|
||||
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={assets} folderCounts={folderCountMap} personalAssets={personalAssets} onOpen={() => 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)} />)}</div> : null}
|
||||
{folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
@@ -423,7 +435,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</section>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{currentFolder?.name || (folderId === ALL_FOLDERS ? (category === "all" ? "全部资产" : categoryLabel(category)) : "根目录内容")}</span><span>{assetsQuery.data?.total || 0} 项已确认</span></div>
|
||||
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录和分类加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => 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)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => 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)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title="这个文件夹还没有内容" description="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
|
||||
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录、分类和关键词加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => 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)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => 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)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title={debouncedKeyword ? "没有找到匹配资产" : "这个文件夹还没有内容"} description={debouncedKeyword ? "换一个关键词,或调整左侧分类与目录筛选。" : "可以新建子文件夹、引用个人素材,或把画布产物归档到这里。"} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
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 <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(normalizeAssetCategory(key)) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ReturnType<typeof extractChapterCharacters>>) => {
|
||||
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
|
||||
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<typeof chapterStoryboardFromGenerationTask>["rows"]) => {
|
||||
const shots = storyboardRowsToProjectShots(rows, detail);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
character: "角色",
|
||||
environment: "场景",
|
||||
wardrobe: "服饰",
|
||||
prop: "道具",
|
||||
weapon: "武器",
|
||||
style: "画风",
|
||||
other: "其他",
|
||||
};
|
||||
export const categoryLabels: Record<string, string> = ASSET_CATEGORY_LABELS;
|
||||
|
||||
export const mediaLabels: Record<string, string> = {
|
||||
image: "图片",
|
||||
@@ -64,7 +57,7 @@ const sourceTypeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return categoryLabels[value] || "其他";
|
||||
return sharedAssetCategoryLabel(value);
|
||||
}
|
||||
|
||||
export function statusLabel(value: string) {
|
||||
|
||||
@@ -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<ShotEditorValues>();
|
||||
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: "资产" }]}
|
||||
/>
|
||||
<div className="workflow-library-scroll thin-scrollbar">
|
||||
{leftTab === "assets" ? <AssetLibrary detail={detail} referenceByVersionId={referenceByVersionId} changing={changeAssetBinding.isPending} onToggle={(asset, reference) => changeAssetBinding.mutate({ asset, reference })} /> : null}
|
||||
@@ -450,8 +452,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
<section className="workflow-shot-editor">
|
||||
<header className="workflow-panel-header">
|
||||
<div className="workflow-shot-heading">
|
||||
<span className="workflow-shot-number">SC.{String(shotIndex + 1).padStart(2, "0")}</span>
|
||||
<h2>{watchedTitle || selectedShot.title || "未命名镜头"}</h2>
|
||||
<span className="workflow-shot-number">{formatShotOrdinal(shotIndex)}</span>
|
||||
{customShotTitle(watchedTitle || selectedShot.title, shotIndex) ? <h2>{customShotTitle(watchedTitle || selectedShot.title, shotIndex)}</h2> : null}
|
||||
<Tag className="!m-0" color={saveShot.isPending ? "blue" : editorDirty ? "orange" : revision ? "green" : undefined}>{saveShot.isPending ? "保存中" : editorDirty ? "有未保存修改" : revision ? "已保存" : "草稿"}</Tag>
|
||||
</div>
|
||||
<div className="flex items-center gap-1"><span className="mr-1 text-[var(--fs-micro)] text-foreground/45">{shotIndex + 1} / {shots.length}</span><Button type="text" size="small" icon={<ChevronLeft className="size-4" />} disabled={shotIndex <= 0} onClick={() => selectRelativeShot(-1)} aria-label="上一个镜头" /><Button type="text" size="small" icon={<ChevronRight className="size-4" />} disabled={shotIndex >= shots.length - 1} onClick={() => selectRelativeShot(1)} aria-label="下一个镜头" /></div>
|
||||
@@ -590,12 +592,15 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "",
|
||||
|
||||
function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map<string, ShotAssetReference>; 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<string, ProjectAsset[]>();
|
||||
@@ -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 <div className="workflow-asset-groups"><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
|
||||
return <div className="workflow-asset-groups"><Input allowClear size="small" className="mb-2 w-full" value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索镜头资产" /><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={debouncedKeyword ? "没有找到匹配资产" : "项目还没有资产"} />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
|
||||
}
|
||||
|
||||
function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType<typeof buildShotAssetReferenceContext>["mentionReferences"]; variant?: "scene" | "motion" }) {
|
||||
@@ -630,7 +635,7 @@ function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { deta
|
||||
}
|
||||
|
||||
function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void }) {
|
||||
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{String(index + 1).padStart(2, "0")}</span><span className="min-w-0 flex-1"><strong>{shot.title}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
|
||||
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{formatShotOrdinal(index)}</span><span className="min-w-0 flex-1"><strong>{customShotTitle(shot.title, index) || "未命名"}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
|
||||
}
|
||||
|
||||
function BoundAssets({ detail, shotId, changing, onUnlink }: { detail: ProjectDetail; shotId: string; changing: boolean; onUnlink: (reference: ShotAssetReference) => void }) {
|
||||
@@ -693,7 +698,7 @@ function TimelineShot({ artifactType, detail, shot, task, submitting, index, sel
|
||||
const revision = currentRevision(detail, shot);
|
||||
const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面";
|
||||
const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数";
|
||||
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
|
||||
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>{formatShotOrdinal(index)}</strong><b>{formatDuration(shot.durationMs)}</b></span>{customShotTitle(shot.title, index) ? <em className="workflow-timeline-title">{customShotTitle(shot.title, index)}</em> : null}<small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
|
||||
}
|
||||
|
||||
function revisionInput(values: ShotEditorValues): ShotRevisionInput {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tag } from "antd";
|
||||
import { Check, CircleAlert } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category";
|
||||
import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects";
|
||||
import type { TaskStatus } from "@/services/api/task-center";
|
||||
|
||||
@@ -71,7 +72,7 @@ export function artifactTypeForStage(stage: ShortDramaWorkflowStage) {
|
||||
}
|
||||
|
||||
export function assetCategoryLabel(category: string) {
|
||||
return ({ character: "角色", environment: "场景", wardrobe: "服饰", accessory: "配饰", prop: "道具", weapon: "武器", style: "画风" } as Record<string, string>)[category] || category || "其他";
|
||||
return sharedAssetCategoryLabel(category);
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { assetCategoryLabel, normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
|
||||
import type { CharacterRepresentation, ProjectAsset, ProjectDetail, ShotAssetReference } from "@/services/api/projects";
|
||||
import type { AssetCategory } from "@/stores/use-asset-store";
|
||||
@@ -23,8 +24,6 @@ export type ShotPromptAssetReference = {
|
||||
dialogue?: string;
|
||||
};
|
||||
|
||||
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
|
||||
|
||||
export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext {
|
||||
const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset]));
|
||||
const seenAssetIds = new Set<string>();
|
||||
@@ -194,11 +193,11 @@ function dialogueForCharacter(dialogue: string, characterName: string) {
|
||||
}
|
||||
|
||||
function projectAssetCategory(value: string): AssetCategory {
|
||||
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
|
||||
return normalizeAssetCategory(value);
|
||||
}
|
||||
|
||||
function projectAssetCategoryLabel(value: string) {
|
||||
return ({ environment: "场景", wardrobe: "服装", prop: "道具", weapon: "武器", style: "风格", other: "资产" } as Record<string, string>)[value] || "资产";
|
||||
return assetCategoryLabel(value);
|
||||
}
|
||||
|
||||
function characterVoiceDescription(asset: ProjectAsset) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Film, Layers3, PackageCheck } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ASSET_CATEGORIES } from "@/lib/asset-category";
|
||||
import { listProjectAssetsPage, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { assetCategoryLabel, formatDuration, MetricCard, StageHeading } from "./workflow-shared";
|
||||
@@ -16,8 +17,7 @@ export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDeta
|
||||
const candidates = detail.assetCandidates.filter((item) => !item.unitId || item.unitId === unitId);
|
||||
const assetCountsQuery = useQuery({ queryKey: ["project", projectId, "assets", "workflow-counts"], queryFn: () => listProjectAssetsPage(projectId, { page: 1, pageSize: 1 }) });
|
||||
const confirmedCounts = assetCountsQuery.data?.categoryCounts || {};
|
||||
const categories = ["character", "environment", "wardrobe", "prop", "weapon"];
|
||||
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、服饰、配饰与武器先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{categories.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
|
||||
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、道具、素材与其他资产先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{ASSET_CATEGORIES.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
|
||||
}
|
||||
|
||||
export function DeliveryStage({ detail, unitId }: { detail: ProjectDetail; unitId: string }) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Empty } from "antd";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatShotOrdinal } from "@/lib/shot-label";
|
||||
import { saveProjectShot, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { AssetsStage, DeliveryStage, StoryStage } from "./workflow-stage-views";
|
||||
@@ -49,7 +50,7 @@ export default function ProjectWorkflowView({ detail, projectId, unitId, stage }
|
||||
if (!unit) throw new Error("请先添加章节");
|
||||
return saveProjectShot(projectId, {
|
||||
unitId: unit.id,
|
||||
title: `SC.${String(shots.length + 1).padStart(2, "0")}`,
|
||||
title: formatShotOrdinal(shots.length),
|
||||
description: "待补充分镜画面",
|
||||
position: shots.length,
|
||||
durationMs: 3000,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ProjectDetail,
|
||||
type ShotAssetReference,
|
||||
} from "./projects";
|
||||
import { normalizeAssetCategory, type AssetCategory } from "@/lib/asset-category";
|
||||
|
||||
export const projectAgentToolNames = [
|
||||
"project_get_context",
|
||||
@@ -47,7 +48,7 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
|
||||
}
|
||||
if (name === "project_extract_asset_candidates") {
|
||||
const candidates = Array.isArray(rawInput.candidates) ? rawInput.candidates : [];
|
||||
return createProjectAssetCandidates(projectId, candidates.filter(isCandidateInput));
|
||||
return createProjectAssetCandidates(projectId, candidates.map(normalizeAgentCandidateInput).filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate)), "agent");
|
||||
}
|
||||
if (name === "project_confirm_asset_candidate") {
|
||||
return confirmProjectAssetCandidate(projectId, String(rawInput.candidateId || ""), String(rawInput.assetId || "") || undefined);
|
||||
@@ -68,7 +69,7 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
|
||||
return updateWorkflowStep(projectId, String(rawInput.stepId || ""), { status: "running" });
|
||||
}
|
||||
if (name === "project_link_asset") {
|
||||
return linkProjectAsset(projectId, { assetId: String(rawInput.assetId || ""), category: String(rawInput.category || "other") });
|
||||
return linkProjectAsset(projectId, { assetId: String(rawInput.assetId || ""), category: normalizeAssetCategory(rawInput.category) });
|
||||
}
|
||||
if (name === "project_upsert_asset_version") {
|
||||
return createProjectAssetVersion(projectId, String(rawInput.assetId || ""), { prompt: String(rawInput.prompt || ""), definitionJson: typeof rawInput.definitionJson === "string" ? rawInput.definitionJson : undefined, note: String(rawInput.note || "") });
|
||||
@@ -79,10 +80,21 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
|
||||
throw new Error(`未知项目工具:${name}`);
|
||||
}
|
||||
|
||||
function isCandidateInput(value: unknown): value is { unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> } {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const agentCandidateCategories = new Set<AssetCategory>(["environment", "prop", "material", "other"]);
|
||||
|
||||
function normalizeAgentCandidateInput(value: unknown): { unitId?: string; shotId?: string; name: string; category: AssetCategory; details?: Record<string, unknown> } | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
return typeof item.name === "string" && typeof item.category === "string";
|
||||
if (typeof item.name !== "string" || typeof item.category !== "string") return null;
|
||||
const category = normalizeAssetCategory(item.category);
|
||||
if (!agentCandidateCategories.has(category)) return null;
|
||||
return {
|
||||
name: item.name,
|
||||
category,
|
||||
...(typeof item.unitId === "string" ? { unitId: item.unitId } : {}),
|
||||
...(typeof item.shotId === "string" ? { shotId: item.shotId } : {}),
|
||||
...(item.details && typeof item.details === "object" && !Array.isArray(item.details) ? { details: item.details as Record<string, unknown> } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isShotInput(value: unknown): value is { id?: string; unitId?: string; title: string; description?: string; position?: number; durationMs?: number; status?: string } {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
import { normalizeAssetCategory, type AssetCategory } from "@/lib/asset-category";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
|
||||
const api = apiClient;
|
||||
@@ -56,7 +57,7 @@ export type ProjectAsset = {
|
||||
id: string;
|
||||
title: string;
|
||||
mediaType: string;
|
||||
category: string;
|
||||
category: AssetCategory;
|
||||
status: string;
|
||||
primaryVersionId?: string;
|
||||
versionCount: number;
|
||||
@@ -121,7 +122,7 @@ export type ProjectAssetCandidate = {
|
||||
unitId?: string;
|
||||
shotId?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
category: AssetCategory;
|
||||
status: "pending_confirmation" | "confirmed" | "ignored" | string;
|
||||
detailsJson: string;
|
||||
resolvedAssetId?: string;
|
||||
@@ -365,21 +366,30 @@ export function listProjectAssetsPage(projectId: string, options: { page?: numbe
|
||||
status: options.status || undefined,
|
||||
folder_id: options.folderId,
|
||||
q: options.query || undefined,
|
||||
} }));
|
||||
} })).then((page) => ({ ...page, assets: page.assets.map(normalizeProjectAsset) }));
|
||||
}
|
||||
|
||||
export function listProjectAssets(projectId: string) {
|
||||
return request<{ assets: ProjectAsset[] }>(api.get(`/projects/${encodeURIComponent(projectId)}/assets`));
|
||||
return request<{ assets: ProjectAsset[] }>(api.get(`/projects/${encodeURIComponent(projectId)}/assets`)).then((result) => ({ assets: result.assets.map(normalizeProjectAsset) }));
|
||||
}
|
||||
|
||||
export function listProjectAssetCandidates(projectId: string, options: { page?: number; pageSize?: number; unitId?: string; status?: string; category?: string } = {}) {
|
||||
export function listProjectAssetCandidates(projectId: string, options: { page?: number; pageSize?: number; unitId?: string; status?: string; category?: string; query?: string } = {}) {
|
||||
return request<ProjectAssetCandidatePage>(api.get(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { params: {
|
||||
page: options.page || 1,
|
||||
page_size: options.pageSize || 100,
|
||||
unit_id: options.unitId || undefined,
|
||||
status: options.status || undefined,
|
||||
category: options.category || undefined,
|
||||
} }));
|
||||
q: options.query || undefined,
|
||||
} })).then((page) => ({ ...page, candidates: page.candidates.map(normalizeProjectAssetCandidate) }));
|
||||
}
|
||||
|
||||
function normalizeProjectAsset(asset: ProjectAsset): ProjectAsset {
|
||||
return { ...asset, category: normalizeAssetCategory(asset.category), usages: Array.isArray(asset.usages) ? asset.usages : [] };
|
||||
}
|
||||
|
||||
function normalizeProjectAssetCandidate(candidate: ProjectAssetCandidate): ProjectAssetCandidate {
|
||||
return { ...candidate, category: normalizeAssetCategory(candidate.category) };
|
||||
}
|
||||
|
||||
function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
|
||||
@@ -391,8 +401,7 @@ function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
|
||||
: [];
|
||||
const assets = Array.isArray(detail.assets)
|
||||
? detail.assets.map((asset) => ({
|
||||
...asset,
|
||||
usages: Array.isArray(asset.usages) ? asset.usages : [],
|
||||
...normalizeProjectAsset(asset),
|
||||
...(asset.character ? {
|
||||
character: {
|
||||
...asset.character,
|
||||
@@ -415,7 +424,7 @@ function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
|
||||
shotRevisions: Array.isArray(detail.shotRevisions) ? detail.shotRevisions : [],
|
||||
shotArtifacts: Array.isArray(detail.shotArtifacts) ? detail.shotArtifacts : [],
|
||||
shotReferences: Array.isArray(detail.shotReferences) ? detail.shotReferences : [],
|
||||
assetCandidates: Array.isArray(detail.assetCandidates) ? detail.assetCandidates : [],
|
||||
assetCandidates: Array.isArray(detail.assetCandidates) ? detail.assetCandidates.map(normalizeProjectAssetCandidate) : [],
|
||||
tasks: Array.isArray(detail.tasks) ? detail.tasks : [],
|
||||
};
|
||||
}
|
||||
@@ -468,7 +477,7 @@ export function unlinkCanvasProject(projectId: string, canvasId: string) {
|
||||
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
|
||||
}
|
||||
|
||||
export function linkProjectAsset(projectId: string, input: { assetId: string; category: string; folderId?: string }, signal?: AbortSignal) {
|
||||
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string }, signal?: AbortSignal) {
|
||||
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input, { signal }));
|
||||
}
|
||||
|
||||
@@ -476,7 +485,7 @@ export function unlinkProjectAsset(projectId: string, assetId: string) {
|
||||
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`));
|
||||
}
|
||||
|
||||
export function updateProjectAssetCategory(projectId: string, assetId: string, category: string, signal?: AbortSignal) {
|
||||
export function updateProjectAssetCategory(projectId: string, assetId: string, category: AssetCategory, signal?: AbortSignal) {
|
||||
return request<{ asset: ProjectAsset }>(api.patch(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`, { category }, { signal }));
|
||||
}
|
||||
|
||||
@@ -560,8 +569,8 @@ export function createShotRevision(projectId: string, shotId: string, input: Sho
|
||||
return request<{ shot: ProjectShot; revision: ShotRevision }>(api.post(`/projects/${encodeURIComponent(projectId)}/shots/${encodeURIComponent(shotId)}/revisions`, input));
|
||||
}
|
||||
|
||||
export function createProjectAssetCandidates(projectId: string, candidates: Array<{ unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> }>) {
|
||||
return request<{ candidates: ProjectAssetCandidate[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { candidates }));
|
||||
export function createProjectAssetCandidates(projectId: string, candidates: Array<{ unitId?: string; shotId?: string; name: string; category: AssetCategory; details?: Record<string, unknown> }>, source?: "chapter_character_extract" | "agent") {
|
||||
return request<{ candidates: ProjectAssetCandidate[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { candidates, source }));
|
||||
}
|
||||
|
||||
export function confirmProjectAssetCandidate(projectId: string, candidateId: string, assetId?: string) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createLocalDreaminaTaskEffectStore } from "@/services/local-dreamina-ge
|
||||
import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
|
||||
import { useAssetStore, type AssetCategory, type AssetStatus, type NewAsset } from "@/stores/use-asset-store";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
@@ -92,7 +93,7 @@ async function syncAssetToProject(assetId: string, domainProjectId: string, cate
|
||||
domainProjectId,
|
||||
{
|
||||
assetId: asset.id,
|
||||
category: category || asset.category || "other",
|
||||
category: normalizeAssetCategory(category || asset.category),
|
||||
folderId,
|
||||
},
|
||||
signal,
|
||||
@@ -102,7 +103,7 @@ async function syncAssetToProject(assetId: string, domainProjectId: string, cate
|
||||
if (folderId !== undefined && (linked.folderId || "") !== folderId) linked = (await moveProjectAsset(domainProjectId, asset.id, folderId, signal)).asset;
|
||||
throwIfAborted(signal);
|
||||
useAssetStore.getState().updateAsset(asset.id, {
|
||||
category: linked.category as AssetCategory,
|
||||
category: normalizeAssetCategory(linked.category),
|
||||
status: linked.status as AssetStatus,
|
||||
primaryVersionId: linked.primaryVersionId,
|
||||
metadata: { ...asset.metadata, projectIds: [...new Set([...linkedProjectIds, domainProjectId])] },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AssetCategory } from "@/lib/asset-category";
|
||||
import { normalizeAssetRecord, parseAssetStorageDocument, rebaseAssetSnapshot, serializeAssetStorageDocument, type AssetStorageDocument } from "@/lib/asset-storage-revision";
|
||||
import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
@@ -13,7 +14,7 @@ import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGe
|
||||
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
|
||||
export type AssetCategory = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
|
||||
export type { AssetCategory } from "@/lib/asset-category";
|
||||
export type AssetStatus = "draft" | "review" | "confirmed" | "archived";
|
||||
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
|
||||
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
|
||||
import type { AssetCategory } from "@/lib/asset-category";
|
||||
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
|
||||
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
|
||||
@@ -256,7 +257,7 @@ export type CanvasNodeMetadata = {
|
||||
hasAudio?: boolean;
|
||||
assetId?: string;
|
||||
assetTags?: string[];
|
||||
assetCategory?: "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
|
||||
assetCategory?: AssetCategory;
|
||||
workflowKind?: CanvasWorkflowKind;
|
||||
workflowTitle?: string;
|
||||
workflowDescription?: string;
|
||||
|
||||
24
web/test/asset-category.test.ts
Normal file
24
web/test/asset-category.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { assetCategoryLabel, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
|
||||
|
||||
describe("asset category contract", () => {
|
||||
test("旧服饰、武器和配饰统一迁移为道具", () => {
|
||||
expect(normalizeAssetCategory("wardrobe")).toBe("prop");
|
||||
expect(normalizeAssetCategory("weapon")).toBe("prop");
|
||||
expect(normalizeAssetCategory("accessory")).toBe("prop");
|
||||
});
|
||||
|
||||
test("旧画风迁移为素材,未知值归入其他", () => {
|
||||
expect(normalizeAssetCategory("style")).toBe("material");
|
||||
expect(normalizeAssetCategory("unknown")).toBe("other");
|
||||
expect(assetCategoryLabel("material")).toBe("素材");
|
||||
});
|
||||
|
||||
test("未分类媒体默认归入素材", () => {
|
||||
expect(defaultAssetCategoryForKind("image")).toBe("material");
|
||||
expect(defaultAssetCategoryForKind("video")).toBe("material");
|
||||
expect(defaultAssetCategoryForKind("audio")).toBe("material");
|
||||
expect(defaultAssetCategoryForKind("text")).toBe("other");
|
||||
});
|
||||
});
|
||||
65
web/test/canvas-layout-categories.test.ts
Normal file
65
web/test/canvas-layout-categories.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType } from "@/lib/canvas/canvas-layout";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function node(id: string, type: CanvasNodeType, width = 320, height = 180, locked = false): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 100, y: 100 }, width, height, metadata: locked ? { locked: true } : undefined };
|
||||
}
|
||||
|
||||
describe("canvas media-aware layout", () => {
|
||||
test("无连线时按文本、图片、视频、音频分成纵向泳道", () => {
|
||||
const text = node("text", CanvasNodeType.Text, 300, 120);
|
||||
const image = node("image", CanvasNodeType.Image, 420, 260);
|
||||
const video = node("video", CanvasNodeType.Video, 500, 280);
|
||||
const audio = node("audio", CanvasNodeType.Audio, 360, 100);
|
||||
const positions = layoutCanvasNodesByMediaType([video, text, audio, image]);
|
||||
|
||||
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
|
||||
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
||||
expect(positions.get("video")!.y).toBeLessThan(positions.get("audio")!.y);
|
||||
expect(positions.get("image")!.y).toBeGreaterThanOrEqual(positions.get("text")!.y + text.height);
|
||||
});
|
||||
|
||||
test("有连线时保持从左到右的拓扑层,同时分离媒体泳道", () => {
|
||||
const text = node("text", CanvasNodeType.Text);
|
||||
const image = node("image", CanvasNodeType.Image);
|
||||
const video = node("video", CanvasNodeType.Video);
|
||||
const connections: CanvasConnection[] = [
|
||||
{ id: "a", fromNodeId: text.id, toNodeId: image.id },
|
||||
{ id: "b", fromNodeId: image.id, toNodeId: video.id },
|
||||
];
|
||||
const positions = layoutCanvasFlow([video, image, text], connections);
|
||||
|
||||
expect(positions.get("text")!.x).toBeLessThan(positions.get("image")!.x);
|
||||
expect(positions.get("image")!.x).toBeLessThan(positions.get("video")!.x);
|
||||
expect(positions.get("text")!.y).toBeLessThan(positions.get("image")!.y);
|
||||
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
||||
});
|
||||
|
||||
test("同一拓扑层和媒体泳道中的不同尺寸节点不会重叠", () => {
|
||||
const first = node("first", CanvasNodeType.Image, 420, 300);
|
||||
const second = node("second", CanvasNodeType.Image, 240, 120);
|
||||
const positions = layoutCanvasFlow([first, second], []);
|
||||
|
||||
expect(positions.get("second")!.y).toBeGreaterThanOrEqual(positions.get("first")!.y + first.height);
|
||||
});
|
||||
|
||||
test("自动整理只返回可移动节点,并忽略选区外连接", () => {
|
||||
const image = node("image", CanvasNodeType.Image);
|
||||
const video = node("video", CanvasNodeType.Video);
|
||||
const locked = node("locked", CanvasNodeType.Image, 320, 180, true);
|
||||
const frame = node("frame", CanvasNodeType.Frame);
|
||||
const positions = layoutCanvasAuto([image, video, locked, frame], [{ id: "external", fromNodeId: image.id, toNodeId: "outside" }]);
|
||||
|
||||
expect([...positions.keys()].sort()).toEqual(["image", "video"]);
|
||||
expect(positions.get("image")!.y).toBeLessThan(positions.get("video")!.y);
|
||||
});
|
||||
|
||||
test("节点类型映射为稳定的四类泳道", () => {
|
||||
expect(canvasLayoutLane(node("script", CanvasNodeType.Script))).toBe("text");
|
||||
expect(canvasLayoutLane(node("drawing", CanvasNodeType.Drawing))).toBe("image");
|
||||
expect(canvasLayoutLane(node("video", CanvasNodeType.Video))).toBe("video");
|
||||
expect(canvasLayoutLane(node("audio", CanvasNodeType.Audio))).toBe("audio");
|
||||
});
|
||||
});
|
||||
22
web/test/shot-label.test.ts
Normal file
22
web/test/shot-label.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
|
||||
|
||||
describe("shot labels", () => {
|
||||
test("使用中文两位镜头编号", () => {
|
||||
expect(formatShotOrdinal(0)).toBe("镜头01");
|
||||
expect(formatShotOrdinal(11)).toBe("镜头12");
|
||||
});
|
||||
|
||||
test("只转换精确匹配的历史默认标题", () => {
|
||||
expect(normalizeDefaultShotTitle("SC.01", 0)).toBe("镜头01");
|
||||
expect(normalizeDefaultShotTitle("SC.01 雨夜", 0)).toBe("SC.01 雨夜");
|
||||
expect(normalizeDefaultShotTitle("雨夜追逐", 0)).toBe("雨夜追逐");
|
||||
});
|
||||
|
||||
test("默认标题不会在编号旁重复展示", () => {
|
||||
expect(customShotTitle("SC.01", 0)).toBe("");
|
||||
expect(customShotTitle("镜头01", 0)).toBe("");
|
||||
expect(customShotTitle("雨夜追逐", 0)).toBe("雨夜追逐");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user