diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 6477dec5c..6e20ab51b 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -218,6 +218,8 @@ func verifyMigrationCoverage(db *gorm.DB) error { func migrations() []tableMigration { return []tableMigration{ + migrateTable[model.Tool]("tools"), + migrateTable[model.ToolFavorite]("tool_favorites"), migrateTable[model.User]("users"), migrateTable[model.AuthSession]("auth_sessions"), migrateTable[model.UserIdentity]("user_identities"), diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d61023ff3..d6f6e2c6e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -91,6 +91,9 @@ func run(ctx context.Context) error { if err := svc.EnsureSkillPackages(); err != nil { return err } + if err := svc.EnsureBuiltinTools(); err != nil { + return err + } if summary, err := svc.MigrateLegacyStorage(); err != nil { log.Printf("storage migration skipped after error: %v", err) } else if summary.Backup != "" { diff --git a/backend/internal/app/project_settings_test.go b/backend/internal/app/project_settings_test.go index 96f150975..33125ebdd 100644 --- a/backend/internal/app/project_settings_test.go +++ b/backend/internal/app/project_settings_test.go @@ -24,7 +24,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) { &model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.CreationRun{}, &model.CreationSubmission{}, - &model.CloudAgentResourceLease{}, + &model.CloudAgentResourceLease{}, &model.Tool{}, &model.Announcement{}, &model.AnnouncementImageDraft{}, ); err != nil { t.Fatal(err) diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index c1502b199..ca24c06fc 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -347,6 +347,12 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string if strings.TrimSpace(input.Prompt) == "" { return nil, errors.New("prompt is required") } + // 将 @[tool:type:ID:label:icon] 令牌替换为对应工具的提示词文本 + resolved, err := s.ResolveToolMentionTokens(userID, input.Mode, input.Prompt) + if err != nil { + return nil, err + } + input.Prompt = resolved config, err := s.resolveProviderConfig(input.Config) if err != nil { return nil, err diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go index bc84675ad..008f92828 100644 --- a/backend/internal/app/service.go +++ b/backend/internal/app/service.go @@ -19,6 +19,7 @@ import ( "infinite-canvas/backend/internal/prompts" "infinite-canvas/backend/internal/repository" "infinite-canvas/backend/internal/skills" + "infinite-canvas/backend/internal/tools" ) type Service struct { @@ -67,6 +68,7 @@ type Service struct { routeCatalogRetryAt time.Time routeCatalogRefreshError error skills *skills.Service + tools *tools.Service prompts *prompts.Service auth *auth.Service canvas *canvas.Service @@ -116,6 +118,7 @@ func newService(repo *repository.Repository, dataDir string) *Service { service.taskWorkerCoordinator = newTaskWorkerCoordinator(service) service.taskLifecycleCoordinator = newTaskLifecycleCoordinator(service) service.skills = skills.New(service.repo, service.dataDir, service.runWorkerLoop) + service.tools = tools.New(service.repo) service.prompts = prompts.New(service.repo, promptAdminGate{svc: service}) service.auth = auth.New(service.repo, authHost{svc: service}, nil) service.canvas = canvas.New(service.repo, canvasHost{svc: service}) diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 5369dd252..132a1d00d 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -44,6 +44,24 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if err != nil { return nil, err } + // Fail admission before queueing or charging; the worker validates again in + // case a tool is deleted or its visibility changes while queued. + toolMode, _ := normalizedInput["mode"].(string) + if toolMode == "" { + if strings.HasPrefix(taskType, "video_") || taskType == "canvas_video" { + toolMode = "video" + } else if taskType == "canvas_image" { + toolMode = "image" + } + } + if _, err := s.ResolveToolMentionTokens(userID, toolMode, prompt); err != nil { + return nil, err + } + if inputPrompt, ok := normalizedInput["prompt"].(string); ok && inputPrompt != prompt { + if _, err := s.ResolveToolMentionTokens(userID, toolMode, inputPrompt); err != nil { + return nil, err + } + } var routed *RoutedModel logicalModelID := strings.TrimSpace(req.LogicalModelID) diff --git a/backend/internal/app/tools_bridge.go b/backend/internal/app/tools_bridge.go new file mode 100644 index 000000000..7206c6c71 --- /dev/null +++ b/backend/internal/app/tools_bridge.go @@ -0,0 +1,77 @@ +package app + +import ( + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/tools" + "strings" +) + +type ( + ToolListRequest = tools.ToolListRequest + ToolList = tools.ToolList + ToolItem = tools.ToolItem + ToolSummary = tools.ToolSummary + ToolMutationRequest = tools.ToolMutationRequest +) + +func (s *Service) toolDomain() *tools.Service { + if s == nil { + return tools.New(nil) + } + if s.tools != nil { + return s.tools + } + return tools.New(s.repo) +} + +func (s *Service) Tools(userID string, req ToolListRequest) (*ToolList, error) { + return s.toolDomain().List(userID, req) +} + +func (s *Service) ToolDetail(userID string, toolID int64) (*ToolItem, error) { + return s.toolDomain().Detail(userID, toolID) +} + +func (s *Service) SetToolFavorite(userID string, toolID int64, favorite bool) (*ToolItem, error) { + return s.toolDomain().SetFavorite(userID, toolID, favorite) +} + +func (s *Service) CreateTool(userID string, req ToolMutationRequest) (*ToolItem, error) { + if strings.TrimSpace(userID) == "" { + return nil, Unauthorized("请先登录") + } + values := append([]string{req.Cover, req.MediaURL}, req.ExtraInfo...) + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + id := resourceIDFromFileURL(value) + if id == "" { + return nil, BadAuthRequest("工具预览仅支持本人上传的资源") + } + resource, err := s.repo.ResourceForUser(userID, id) + if err != nil { + return nil, err + } + if resource.Status != model.ResourceStatusReady { + return nil, BadAuthRequest("预览资源尚未上传完成") + } + if req.Visibility == tools.ToolVisibilityPublic { + return nil, BadAuthRequest("公开工具暂不支持私人预览资源,请移除预览或改为私有") + } + } + return s.toolDomain().Create(userID, req) +} + +func (s *Service) DeleteTool(userID string, toolID int64) error { + return s.toolDomain().Delete(userID, toolID) +} + +// ResolveToolMentionTokens 将 prompt 中的 @[tool:type:ID:label:icon] 令牌替换为对应工具的提示词文本。 +func (s *Service) ResolveToolMentionTokens(userID, mode, prompt string) (string, error) { + return s.toolDomain().ResolveToolMentionTokens(userID, mode, prompt) +} + +func (s *Service) EnsureBuiltinTools() error { + return tools.EnsureBuiltinTools(s.repo) +} diff --git a/backend/internal/app/tools_bridge_test.go b/backend/internal/app/tools_bridge_test.go new file mode 100644 index 000000000..df96159fa --- /dev/null +++ b/backend/internal/app/tools_bridge_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "infinite-canvas/backend/internal/model" + "strings" + "testing" +) + +func TestToolPreviewOwnershipAndResourceDeletionGuard(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ID: "tool-preview", UserID: "alice", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "preview.png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + req := ToolMutationRequest{Type: "style", Label: "style", Prompt: "film", Cover: "/api/resources/tool-preview/file", Visibility: "private"} + if _, err := svc.CreateTool("bob", req); err == nil { + t.Fatal("foreign resource accepted") + } + req.Visibility = "public" + if _, err := svc.CreateTool("alice", req); err == nil { + t.Fatal("private resource exposed publicly") + } + req.Visibility = "private" + tool, err := svc.CreateTool("alice", req) + if err != nil { + t.Fatal(err) + } + snapshot, err := svc.repo.ResourceReferenceSnapshot("alice", "", []string{resource.ID}) + if err != nil { + t.Fatal(err) + } + found := false + for _, doc := range snapshot.Documents { + if doc.Kind == "工具" && documentReferencesResources(doc.PrimaryJSON, map[string]struct{}{resource.ID: {}}) { + found = true + } + } + if !found { + t.Fatal("tool preview not protected from resource deletion") + } + if err := svc.DeleteTool("alice", tool.ID); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("deleting tool deleted preview resource") + } +} + +func TestToolAdmissionRejectsBeforeQueueing(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + _, err := svc.CreateTask("alice", CreateTaskRequest{Type: "canvas_image", Prompt: "@[tool:style:999:x:X]", Input: map[string]any{"mode": "image", "prompt": "@[tool:style:999:x:X]"}}) + if err == nil || !strings.Contains(err.Error(), "工具") { + t.Fatalf("unexpected admission result: %v", err) + } + var count int64 + if err := db.Model(&model.Task{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("invalid tool was queued") + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 8d62059ff..122eb1e3c 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 29 +const CurrentSchemaVersion int64 = 31 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -99,6 +99,12 @@ var schemaMigrations = []migration{ {version: 29, name: "agent_resource_leases", checksum: "sha256:agent-resource-leases-v29-20260919", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CloudAgentResourceLease{}) }}, + {version: 30, name: "builtin_tools", checksum: "sha256:builtin-tools-v30", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.Tool{}) + }}, + {version: 31, name: "tool_favorites", checksum: "sha256:tool-favorites-v31", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.ToolFavorite{}) + }}, } func migrateChannelCreditCost(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index b06d2c6a8..26f68d097 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -599,3 +599,76 @@ func TestMigrateSchemaV13AddsCloudAgentCanvasMutation(t *testing.T) { } } } + +func TestMigrateSchemaV30AddsBuiltinTools(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-builtin-tools-v30?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&model.Tool{}) { + t.Fatal("migration v30 did not create tools table") + } +} + +func TestMigrateSchemaV31AddsToolUserActions(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-tool-user-actions-v31?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&model.ToolFavorite{}) { + t.Fatal("migration v31 did not create tool_favorites table") + } +} + +func TestToolsUpgradeFromMain29PreservesMigrationChecksums(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:tools-main29-upgrade?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&schemaMigration{}); err != nil { + t.Fatal(err) + } + for _, item := range schemaMigrations { + if item.version > 29 { + break + } + if err := item.apply(db); err != nil { + t.Fatal(err) + } + if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + } + // The initial migration uses today's model registry; restore the actual v29 + // boundary so this test proves that v30/v31 create the new tables. + if err := db.Migrator().DropTable(&model.ToolFavorite{}, &model.Tool{}); err != nil { + t.Fatal(err) + } + if db.Migrator().HasTable(&model.Tool{}) || db.Migrator().HasTable(&model.ToolFavorite{}) { + t.Fatal("tool tables must not exist before upgrading v29") + } + for range 2 { + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + } + for _, version := range []int64{28, 29} { + var record schemaMigration + if err := db.First(&record, version).Error; err != nil { + t.Fatal(err) + } + expected := map[int64]string{28: "sha256:agent-execution-journal-v28", 29: "sha256:agent-resource-leases-v29-20260919"} + if record.Checksum != expected[version] { + t.Fatal("main checksum changed") + } + } + if !db.Migrator().HasTable(&model.Tool{}) || !db.Migrator().HasTable(&model.ToolFavorite{}) { + t.Fatal("tools tables missing") + } +} diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index be9d83b7d..c90ac5e59 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -63,6 +63,8 @@ func Models() []any { &model.SkillVersion{}, &model.SkillFile{}, &model.UserSkillState{}, + &model.Tool{}, + &model.ToolFavorite{}, &model.Resource{}, &model.ResourceDeletionJob{}, &model.AnnouncementImageDraft{}, diff --git a/backend/internal/handler/api.go b/backend/internal/handler/api.go index 49cf1b484..ddb114427 100644 --- a/backend/internal/handler/api.go +++ b/backend/internal/handler/api.go @@ -37,6 +37,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) { RegisterTaskRoutes(api, svc) RegisterRunningHubRoutes(api, svc) RegisterSkillRoutes(api, svc) + RegisterToolRoutes(api, svc) RegisterUserDataRoutes(api, svc) RegisterChunkedUploadRoutes(api, svc) RegisterDiagnosticsRoutes(api, svc) diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 61f5dc80e..f5ea6b942 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -26,6 +26,62 @@ tags: - name: agent - name: admin paths: + /tools: + get: + tags: [canvas] + summary: 列出可见且启用的生成工具 + parameters: + - {in: query, name: scope, schema: {type: string, enum: [public, favorites, custom], default: public}} + - {in: query, name: type, schema: {type: string, enum: [style, motion, nine_grid, effect]}} + - {in: query, name: tag, schema: {type: string}} + - {in: query, name: search, schema: {type: string}} + - {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}} + - {in: query, name: pageSize, schema: {type: integer, minimum: 1, maximum: 80, default: 20}} + responses: + "200": {description: "标准信封 data 为 tools(摘要不含 prompt/extraInfo)、totalCount、page、pageSize、hasMore"} + "401": {description: 未登录} + post: + tags: [canvas] + summary: 创建当前用户的工具 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ToolMutation'} + responses: + "200": {description: 标准信封 data 为完整工具} + "400": {description: 参数无效或预览资源不符合要求} + "401": {description: 未登录} + /tools/{id}: + parameters: + - {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}} + get: + tags: [canvas] + summary: 查询可见且启用工具的完整详情 + responses: + "200": {description: 标准信封 data 为完整工具,包含 prompt 和 extraInfo} + "404": {description: 工具不存在或不可见} + delete: + tags: [canvas] + summary: 删除自己的用户工具及关联收藏,不删除预览文件 + responses: + "200": {description: "标准信封 data 为 {deleted: true}"} + "404": {description: 工具不存在或不能删除} + /tools/{id}/favorite: + parameters: + - {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}} + post: + tags: [canvas] + summary: 幂等收藏可见工具 + responses: + "200": {description: 标准信封 data 为完整工具和收藏状态} + "404": {description: 工具不存在或不可见} + delete: + tags: [canvas] + summary: 幂等取消收藏可见工具 + responses: + "200": {description: 标准信封 data 为完整工具和收藏状态} + "404": {description: 工具不存在或不可见} /model-catalog: get: tags: [models] @@ -971,6 +1027,20 @@ components: in: cookie name: open_ai_canvas_session schemas: + ToolMutation: + type: object + required: [type, label, prompt] + properties: + type: {type: string, enum: [style, motion, nine_grid, effect]} + label: {type: string, description: 名称最多 120 字节} + prompt: {type: string, description: 最多 8000 字节,不允许嵌套工具标签} + visibility: {type: string, enum: [private, public], default: private} + desc: {type: string} + tag: {type: string} + cover: {type: string, description: 本人已上传完成的资源 URL,仅限私有工具} + mediaUrl: {type: string, description: 本人已上传完成的资源 URL,仅限私有工具} + extraInfo: {type: array, items: {type: string}} + ratio: {type: string} ModelRequestIntent: type: object required: [capability] diff --git a/backend/internal/handler/tools.go b/backend/internal/handler/tools.go new file mode 100644 index 000000000..13143265c --- /dev/null +++ b/backend/internal/handler/tools.go @@ -0,0 +1,137 @@ +package handler + +import ( + "net/http" + "strconv" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +// RegisterToolRoutes 注册画布工具 API:查询、收藏、新增、删除。 +func RegisterToolRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/tools", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, pageSize, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.Tools(user.ID, service.ToolListRequest{ + Page: page, + PageSize: pageSize, + Scope: c.DefaultQuery("scope", "public"), + Type: c.Query("type"), + Tag: c.Query("tag"), + Search: c.Query("search"), + }) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + + r.GET("/tools/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + toolID, err := parseToolID(c) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + item, err := svc.ToolDetail(user.ID, toolID) + if err != nil { + failService(c, err) + return + } + ok(c, item) + }) + + r.POST("/tools", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.ToolMutationRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("请求体格式无效")) + return + } + item, err := svc.CreateTool(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, item) + }) + + r.POST("/tools/:id/favorite", func(c *gin.Context) { + setToolFavorite(c, svc, true) + }) + + r.DELETE("/tools/:id/favorite", func(c *gin.Context) { + setToolFavorite(c, svc, false) + }) + + r.DELETE("/tools/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + toolID, err := parseToolID(c) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.DeleteTool(user.ID, toolID); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"deleted": true}) + }) +} + +func setToolFavorite(c *gin.Context, svc *service.Service, favorite bool) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + toolID, err := parseToolID(c) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + item, err := svc.SetToolFavorite(user.ID, toolID, favorite) + if err != nil { + failService(c, err) + return + } + ok(c, item) +} + +func parseToolID(c *gin.Context) (int64, error) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return 0, errInvalidToolID + } + return id, nil +} + +type toolIDError string + +func (e toolIDError) Error() string { return string(e) } + +const errInvalidToolID = toolIDError("工具 ID 无效") diff --git a/backend/internal/model/models_platform.go b/backend/internal/model/models_platform.go index dc81297ad..b2e3fa083 100644 --- a/backend/internal/model/models_platform.go +++ b/backend/internal/model/models_platform.go @@ -147,3 +147,32 @@ type UserSkillState struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +type Tool struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" gorm:"size:24;index"` + LabelEn string `json:"labelEn" gorm:"size:120;index"` + Label string `json:"label" gorm:"size:120"` + Desc string `json:"desc" gorm:"size:500"` + Tag string `json:"tag" gorm:"size:64;index"` + Cover string `json:"cover" gorm:"size:500"` + ExtraInfoJSON string `json:"-" gorm:"type:text"` + Prompt string `json:"prompt" gorm:"type:text"` + Ratio string `json:"ratio" gorm:"size:32"` + MediaURL string `json:"mediaUrl" gorm:"size:500"` + OwnerID string `json:"ownerId" gorm:"size:36;index"` + Source string `json:"source" gorm:"size:16;index"` + Enabled bool `json:"enabled" gorm:"index"` + Visibility string `json:"visibility" gorm:"size:16;index"` + SortWeight int `json:"sortWeight" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +// ToolFavorite 保存用户对工具的收藏关系;一个用户对同一工具只能收藏一次。 +type ToolFavorite struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_tool_favorite_user_tool,priority:1"` + ToolID int64 `json:"toolId" gorm:"index;uniqueIndex:idx_tool_favorite_user_tool,priority:2"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index d545b5102..9700f5b5b 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -1,7 +1,9 @@ package repository import ( + "encoding/json" "slices" + "strconv" "strings" "time" @@ -100,6 +102,23 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "Agent 待执行引用", ID: lease.OwnerID, Title: "已准备的生成输入", ResourceID: lease.ResourceID}) } + var toolRecords []model.Tool + if err := r.db.Where("owner_id = ?", userID).Find(&toolRecords).Error; err != nil { + return snapshot, err + } + for _, tool := range toolRecords { + var extra []string + if tool.ExtraInfoJSON != "" { + if err := json.Unmarshal([]byte(tool.ExtraInfoJSON), &extra); err != nil { + return snapshot, err + } + } + payload, err := json.Marshal(map[string]any{"coverUrl": tool.Cover, "url": tool.MediaURL, "referenceUrls": extra}) + if err != nil { + return snapshot, err + } + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工具", ID: strconv.FormatInt(tool.ID, 10), Title: tool.Label, PrimaryJSON: string(payload)}) + } var assets []model.Asset assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID) if err := assetQuery.Find(&assets).Error; err != nil { diff --git a/backend/internal/repository/tools.go b/backend/internal/repository/tools.go new file mode 100644 index 000000000..2ca061bf7 --- /dev/null +++ b/backend/internal/repository/tools.go @@ -0,0 +1,200 @@ +package repository + +import ( + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/tools" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UpsertBuiltinTools 按 id 幂等更新内置工具;created_at 保留首次写入值。 +func (r *Repository) UpsertBuiltinTools(tools []model.Tool) error { + if len(tools) == 0 { + return nil + } + return r.db.Transaction(func(tx *gorm.DB) error { + ids := make([]int64, 0, len(tools)) + for _, tool := range tools { + ids = append(ids, tool.ID) + } + var collisions int64 + if err := tx.Model(&model.Tool{}).Where("id IN ? AND source <> ?", ids, "builtin").Count(&collisions).Error; err != nil { + return err + } + if collisions > 0 { + return errors.New("内置工具 ID 与用户工具冲突,拒绝覆盖") + } + result := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: clause.Column{Table: "tools", Name: "source"}, Value: "builtin"}}}, + DoUpdates: clause.AssignmentColumns([]string{ + "type", "label_en", "label", "desc", "tag", "cover", "extra_info_json", "prompt", "ratio", + "media_url", "owner_id", "source", "enabled", "visibility", "sort_weight", "updated_at", + }), + }).Create(&tools) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != int64(len(tools)) { + return errors.New("内置工具写入遇到 ID 冲突,已回滚") + } + // 内置工具显式写入自增主键,PostgreSQL 的序列不会自动前移;这里同步到当前最大值, + // 避免用户自建工具从 1 重新分配而与内置工具主键冲突。 + if r.Dialect() != "postgres" { + return nil + } + return tx.Exec("SELECT setval(pg_get_serial_sequence('tools', 'id'), (SELECT COALESCE(MAX(id), 1) FROM tools), true)").Error + }) +} + +// ListTools 按范围查询工具列表并携带当前用户收藏状态。 +func (r *Repository) ListTools(userID string, req tools.ToolListRequest) ([]tools.ToolWithFavorite, int64, error) { + base := r.db.Table("tools").Where("enabled = ? AND (tools.visibility = ? OR tools.owner_id = ?)", true, tools.ToolVisibilityPublic, userID) + switch req.Scope { + case tools.ToolScopeFavorites: + base = base.Joins("JOIN tool_favorites tf ON tf.tool_id = tools.id AND tf.user_id = ?", userID) + default: // public / custom + base = base.Joins("LEFT JOIN tool_favorites tf ON tf.tool_id = tools.id AND tf.user_id = ?", userID) + } + + // 可见性:公共 scope 只看 public(含他人公开),其余 scope 只看自己可见的 + switch req.Scope { + case tools.ToolScopePublic: + base = base.Where("tools.visibility = ?", tools.ToolVisibilityPublic) + case tools.ToolScopeFavorites: + base = base.Where("tf.user_id = ?", userID) + case tools.ToolScopeCustom: + base = base.Where("tools.owner_id = ? AND tools.source = ?", userID, tools.ToolSourceUser) + } + + if req.Type != "" { + base = base.Where("tools.type = ?", req.Type) + } + if req.Tag != "" { + base = base.Where("tools.tag = ?", req.Tag) + } + if req.Search != "" { + keyword := "%" + strings.ToLower(req.Search) + "%" + base = base.Where("(LOWER(tools.label) LIKE ? OR LOWER(tools.label_en) LIKE ? OR LOWER(tools.desc) LIKE ?)", keyword, keyword, keyword) + } + + var total int64 + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + orderBy := "tools.sort_weight ASC, tools.id ASC" + switch req.Scope { + case tools.ToolScopeFavorites: + orderBy = "tf.created_at DESC, tools.id DESC" + } + + offset := (req.Page - 1) * req.PageSize + var rows []struct { + model.Tool + TFID *int64 `gorm:"column:tf_id"` + TFCreatedAt *time.Time `gorm:"column:tf_created_at"` + } + // 列表只取摘要字段,不取 extra_info_json、prompt 两个大字段;列与 tools.ToolSummary 对齐。 + selectColumns := "tools.id, tools.type, tools.label_en, tools.label, tools.desc, tools.tag, tools.cover, " + + "tools.ratio, tools.media_url, tools.owner_id, tools.source, tools.enabled, tools.visibility, " + + "tools.sort_weight, tools.created_at, tools.updated_at, tf.id AS tf_id, tf.created_at AS tf_created_at" + err := base.Session(&gorm.Session{}). + Select(selectColumns). + Order(orderBy). + Offset(offset). + Limit(req.PageSize). + Scan(&rows).Error + if err != nil { + return nil, 0, err + } + + items := make([]tools.ToolWithFavorite, 0, len(rows)) + for _, row := range rows { + items = append(items, tools.ToolWithFavorite{ + Tool: row.Tool, + Favorited: row.TFID != nil, + FavoritedAt: row.TFCreatedAt, + }) + } + return items, total, nil +} + +// ToolForUser 校验可见性后返回工具。 +func (r *Repository) ToolForUser(userID string, toolID int64) (model.Tool, error) { + var tool model.Tool + err := r.db.Where("id = ? AND enabled = ? AND (visibility = ? OR owner_id = ?)", toolID, true, tools.ToolVisibilityPublic, userID). + First(&tool).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tool, kernel.NotFound("工具不存在或不可见") + } + if err != nil { + return tool, err + } + return tool, nil +} + +// ToolFavorited 返回工具及收藏时间(未收藏时为 nil)。 +func (r *Repository) ToolFavorited(userID string, toolID int64) (model.Tool, *time.Time, error) { + tool, err := r.ToolForUser(userID, toolID) + if err != nil { + return tool, nil, err + } + var favorite model.ToolFavorite + err = r.db.Where("user_id = ? AND tool_id = ?", userID, toolID).First(&favorite).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tool, nil, nil + } + if err != nil { + return tool, nil, err + } + return tool, &favorite.CreatedAt, nil +} + +// AddToolFavorite 幂等添加收藏;已收藏时直接成功。 +func (r *Repository) AddToolFavorite(userID string, toolID int64) error { + favorite := model.ToolFavorite{UserID: userID, ToolID: toolID, CreatedAt: time.Now()} + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "tool_id"}}, + DoNothing: true, + }).Create(&favorite).Error +} + +// RemoveToolFavorite 取消收藏;未收藏时也无错误。 +func (r *Repository) RemoveToolFavorite(userID string, toolID int64) error { + return r.db.Where("user_id = ? AND tool_id = ?", userID, toolID). + Delete(&model.ToolFavorite{}).Error +} + +// CreateTool 创建用户自定义工具。 +func (r *Repository) CreateTool(tool *model.Tool) (*model.Tool, error) { + if err := r.db.Create(tool).Error; err != nil { + return nil, err + } + return tool, nil +} + +// DeleteUserTool 仅允许删除自己的自定义工具;事务内同步清理收藏记录。 +func (r *Repository) DeleteUserTool(userID string, toolID int64) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var tool model.Tool + err := tx.Where("id = ? AND owner_id = ? AND source = ?", toolID, userID, tools.ToolSourceUser). + First(&tool).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return kernel.NotFound("工具不存在或不支持删除") + } + if err != nil { + return err + } + if err := tx.Where("tool_id = ?", toolID).Delete(&model.ToolFavorite{}).Error; err != nil { + return err + } + return tx.Delete(&model.Tool{}, toolID).Error + }) +} diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index df34dc858..db687daf6 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -285,6 +285,11 @@ type ( StorageMigrationSummary = app.StorageMigrationSummary StyleProfileFavoriteRequest = app.StyleProfileFavoriteRequest StyleProfileRequest = app.StyleProfileRequest + ToolItem = app.ToolItem + ToolList = app.ToolList + ToolListRequest = app.ToolListRequest + ToolMutationRequest = app.ToolMutationRequest + ToolSummary = app.ToolSummary SystemPerformanceCacheGroup = app.SystemPerformanceCacheGroup SystemPerformanceDisk = app.SystemPerformanceDisk SystemPerformanceHost = app.SystemPerformanceHost diff --git a/backend/internal/tools/repository.go b/backend/internal/tools/repository.go new file mode 100644 index 000000000..6ab65e175 --- /dev/null +++ b/backend/internal/tools/repository.go @@ -0,0 +1,56 @@ +package tools + +import ( + "encoding/json" + "regexp" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// ToolWithFavorite 列表查询结果:工具 + 当前用户收藏状态。 +type ToolWithFavorite struct { + Tool model.Tool + Favorited bool + FavoritedAt *time.Time +} + +// Repository 定义 tools 域需要的持久化接口,由 repository.Repository 实现。 +type Repository interface { + UpsertBuiltinTools(tools []model.Tool) error + ListTools(userID string, req ToolListRequest) ([]ToolWithFavorite, int64, error) + ToolForUser(userID string, toolID int64) (model.Tool, error) + ToolFavorited(userID string, toolID int64) (model.Tool, *time.Time, error) + AddToolFavorite(userID string, toolID int64) error + RemoveToolFavorite(userID string, toolID int64) error + CreateTool(tool *model.Tool) (*model.Tool, error) + DeleteUserTool(userID string, toolID int64) error +} + +var labelEnPattern = regexp.MustCompile(`[^a-zA-Z0-9_]+`) + +// sanitizeLabelEn 从中文名称生成英文标识:取拼音不可行时用时间戳保证唯一性由数据库自增 ID 兜底。 +func sanitizeLabelEn(label string) string { + normalized := labelEnPattern.ReplaceAllString(strings.ToLower(label), "_") + normalized = strings.Trim(normalized, "_") + if normalized == "" { + normalized = "custom_tool" + } + return normalized +} + +func marshalExtraInfo(items []string) ([]byte, error) { + return json.Marshal(items) +} + +func decodeExtraInfo(raw string) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + var items []string + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []string{} + } + return items +} diff --git a/backend/internal/tools/seed/tools.json b/backend/internal/tools/seed/tools.json new file mode 100644 index 000000000..39800bd34 --- /dev/null +++ b/backend/internal/tools/seed/tools.json @@ -0,0 +1,1557 @@ +{ + "style": { + "title": "风格", + "tags": { + "period": "古装", + "city": "都市", + "decade": "年代", + "life": "生活", + "science_fiction": "科幻", + "type": "类型", + "poetic": "写意", + "animation": "动画", + "drawing": "绘画", + "myth": "神话" + }, + "list": [ + { + "id": 1, + "label_en": "period_idol", + "label": "古装偶像", + "tag": "period", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/period_idol/cover.webp", + "extra_info": [ + "period_idol/female.webp", + "period_idol/youth.webp", + "period_idol/male.webp", + "period_idol/elder.webp" + ], + "prompt": "古偶唯美柔光风格,东方新古典写意浪漫,古装言情影像,ARRI Alexa Mini LF大画幅摄影机拍摄,[50/85/135mm]大光圈定焦镜头,1/8黑柔焦镜+薄雾滤镜效果。低饱和东方传统色盘,包裹式柔光布光,侧逆柔光勾勒轮廓,光比1:2低反差,高调柔影调,明暗过渡丝滑,宋式雅致美学,高通透度,强空气感,薄烟氤氲氛围,奶油焦外虚化,极细腻微胶片颗粒,宽动态范围,中低锐度边缘柔和,电影级古偶唯美质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 2, + "label_en": "palace_intrigue", + "label": "宫廷权谋", + "tag": "period", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/palace_intrigue/cover.webp", + "extra_info": [ + "palace_intrigue/female.webp", + "palace_intrigue/youth.webp", + "palace_intrigue/male.webp", + "palace_intrigue/elder.webp" + ], + "prompt": "古装宫廷权谋正剧,冷峻肃杀风格,东方写实主义极简美学,ARRI Alexa 35 摄影机拍摄,Super35 画幅,电影级质感。低明度低饱和冷调色盘;大光比硬光布光,侧逆光伦勃朗光,人物半明半暗,硬朗阴影边界,低调硬调高反差,整体压暗曝光,暗部沉实保留层次。庄重肃穆宫廷美术,哑光做旧质感,细腻中灰胶片颗粒,低通透度,淡淡焚香薄烟空气感,宽动态范围,中等偏高锐度,材质纹理清晰。整体氛围沉郁压抑,静水流深暗流涌动,权力博弈的宿命感,高级电影质感。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 3, + "label_en": "wuxia", + "label": "武侠江湖", + "tag": "period", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/wuxia/cover.webp", + "extra_info": [ + "wuxia/female.webp", + "wuxia/youth.webp", + "wuxia/male.webp", + "wuxia/elder.webp" + ], + "prompt": "写实武侠江湖题材,新现实主义武侠风格,沉郁凛冽的江湖烟火气质,院线电影级画面,采用ARRI Alexa 35数字电影摄影机拍摄,Super 35画幅,库克S4/i定焦镜头,35mm柯达Vision3胶片扫描质感,自然主义光源,侧逆低调布光,伦勃朗光型,中高反差硬调影调,宽动态范围,暗部细节丰富。,粗粝写实美术,史实级中式古代场景与服饰,中等胶片颗粒,中低通透度,自然空气感与丁达尔效应,中等偏上锐度,焦外柔润,整体厚重沉稳,电影级叙事感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 4, + "label_en": "cn_urban", + "label": "国产都市", + "tag": "city", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/cn_urban/cover.webp", + "extra_info": [ + "cn_urban/female.webp", + "cn_urban/youth.webp", + "cn_urban/male.webp", + "cn_urban/elder.webp" + ], + "prompt": "中国新写实主义都市剧情片,纪实美学风格,克制粗粝的本土都市质感,蔡司 CP.3 定焦镜头,35mm 焦段,自然景深;低饱和冷灰基底,小面积高饱和霓虹点缀,中低反差影调,实景自然光源布光,7-9 档明暗层级,暗部留细节高光不溢出,阴天散射光质感;低程度数字底噪,中低通透度带轻微灰雾,14 档高动态范围,中低锐度保留皮肤纹理,沉静疏离的日常氛围", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 5, + "label_en": "urban_romance", + "label": "都市情感", + "tag": "city", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/urban_romance/cover.webp", + "extra_info": [ + "urban_romance/female.webp", + "urban_romance/youth.webp", + "urban_romance/male.webp", + "urban_romance/elder.webp" + ], + "prompt": "韩国当代都市情感剧集影像,韩剧都市柔光风格,日常浪漫主义美学,暖柔治愈基调;ARRI Alexa Mini LF 全画幅数字摄影机,35mm 大光圈定焦镜头,1/8 黑柔焦镜,浅景深;低饱和暖调基底,奶杏米棕主色调,莫兰迪冷色点缀,软质散射光,平柔布光,低反差中高调,明暗过渡顺滑,高光柔化自然溢出,暗部保留灰阶细节;韩式简约现代美术,哑光柔润质感,圆润线条造型,细腻数字微颗粒,中高通透度,轻微空气感,宽动态范围,中低锐度,温柔治愈都市氛围感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 6, + "label_en": "crime_suspense", + "label": "现实悬疑", + "tag": "city", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/crime_suspense/cover.webp", + "extra_info": [ + "crime_suspense/female.webp", + "crime_suspense/youth.webp", + "crime_suspense/male.webp", + "crime_suspense/elder.webp" + ], + "prompt": "国产社会派现实主义悬疑,冷峻克制的写实影像风格,沉郁压抑的冷调悬疑气质,ARRI Alexa Mini LF全画幅数字电影机,35mm定焦镜头,电影级浅景深,整体色彩偏沉偏冷,低调布光,大光比,侧逆光塑造人物,大面积阴影制造悬念,明暗高反差,硬调影调,暗部扎实厚重,现实主义粗粝质感,轻微均匀胶片颗粒,低通透度带轻微雾感,弱空气感,高动态范围,中等偏低锐度,厚重写实画质", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 7, + "label_en": "korean_muted", + "label": "韩国冷淡", + "tag": "city", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/korean_muted/cover.webp", + "extra_info": [ + "korean_muted/female.webp", + "korean_muted/youth.webp", + "korean_muted/male.webp", + "korean_muted/elder.webp" + ], + "prompt": "新现实主义写实美学,清冷克制的疏离感,ARRI Alexa Mini 拍摄,Super 35mm 传感器,35mm 定焦人文视角,克制虚化。低饱和冷灰调基底,阴天漫射侧光,低光比平柔布光,低反差中灰影调,整体欠曝 0.5 档,明暗过渡平缓。全画面哑光质感,元素精简,细腻轻微胶片颗粒,中低通透度带轻微雾感,弱空气透视,高动态范围,中等偏低锐度,边缘柔和温润。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 8, + "label_en": "nineties", + "label": "90年代", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/nineties/cover.webp", + "extra_info": [ + "nineties/female.webp", + "nineties/youth.webp", + "nineties/male.webp", + "nineties/elder.webp" + ], + "prompt": "90年代新写实主义电影,现实题材纪实感,Super 16mm胶片拍摄,柯达Vision 500T 5279胶片,Arriflex SR3摄影机,手持拍摄轻微呼吸感,蔡司25mm/35mm/50mm定焦镜头,低饱和度色彩,自然光+实用光源布光,现有光效,中低反差,中间调丰富,暗部带灰雾,高光柔和暖溢,中等16mm胶片颗粒,暗部颗粒更明显,低通透度,空气带浮尘薄雾,窄动态范围,中低锐度,边缘柔和,纯胶片质感\n构图平实自然,无刻意形式感,无数码感,年代感准确", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 9, + "label_en": "golden_age", + "label": "黄金时代", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/golden_age/cover.webp", + "extra_info": [ + "golden_age/female.webp", + "golden_age/youth.webp", + "golden_age/male.webp", + "golden_age/elder.webp" + ], + "prompt": "美式复古好莱坞黄金时代风格,古典制片厂美学,1930s-1960s年代感,强电影叙事感,浪漫与戏剧张力并存,35mm醋酸纤维胶片,Mitchell BNC摄影机,Cooke Speed Panchro定焦镜头,模拟Technicolor特艺彩冲印工艺,中高反差影调,伦勃朗三点布光,菲涅尔聚光灯主光,高光暖调柔化,暗部沉实带灰雾,肤色暖橙细腻,Art Deco装饰艺术置景,微做旧质感,环境烟雾空气透视,中等细度35mm胶片颗粒,中低通透度薄雾感,胶片级动态范围,中等偏柔柔化锐度,优雅怀旧电影质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 10, + "label_en": "documentary_realism", + "label": "纪实写实", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/documentary_realism/cover.webp", + "extra_info": [ + "documentary_realism/female.webp", + "documentary_realism/youth.webp", + "documentary_realism/male.webp", + "documentary_realism/elder.webp" + ], + "prompt": "复古叙事电影风格,35mm胶片拍摄,ARRICAM摄影机,Cooke经典定焦镜头,诗意现实主义风格,年代剧情片质感,整体暖棕基调,低饱和中低明度色彩,侧逆柔光,伦勃朗布光,中等柔反差,中低调影调,高光柔化带暖溢,暗部保留丰富细节,灰阶过渡平滑,年代写实美术,质朴生活质感,中等细腻胶片颗粒,轻微雾感,空气透视,自然暗角,高光柔化,中等偏柔锐度,高动态范围,复古电影质感,叙事氛围感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 11, + "label_en": "retro_narrative", + "label": "复古叙事", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/retro_narrative/cover.webp", + "extra_info": [ + "retro_narrative/female.webp", + "retro_narrative/youth.webp", + "retro_narrative/male.webp", + "retro_narrative/elder.webp" + ], + "prompt": "现实主义纪实风格,35mm柯达Vision彩色胶片拍摄,Arri BL4胶片摄影机,35mm标准定焦镜头,自然空间透视,纪实自然光效,中等偏粗胶片颗粒,低通透度,空气浮尘朦胧感,强空气透视纵深感,中等动态范围,偏低锐度,胶片温润质感,原生年代真实感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 12, + "label_en": "american_nineties", + "label": "美式90", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/american_nineties/cover.webp", + "extra_info": [ + "american_nineties/female.webp", + "american_nineties/youth.webp", + "american_nineties/male.webp", + "american_nineties/elder.webp" + ], + "prompt": "经典复古剧情片视觉,古典好莱坞摄影美学,35mm胶片质感,沉静叙事感,怀旧厚重氛围,35mm柯达Vision3彩色电影胶片,Arriflex 535B摄影机,180°快门角度,Panavision C系列变形镜头,自然运动模糊,低饱和复古色盘,古典三点布光,低光比柔化光影,中低反差影调,暗部保留细节,高光柔和滚降,暖调高光偏移,哑光写实质感,年代经典造型,35mm胶片自然细颗粒,中等通透度,轻微空气透视,宽动态范围,中等锐度边缘柔化,电影级最终放映质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 13, + "label_en": "showa_monochrome", + "label": "昭和黑白", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/showa_monochrome/cover.webp", + "extra_info": [ + "showa_monochrome/female.webp", + "showa_monochrome/youth.webp", + "showa_monochrome/male.webp", + "showa_monochrome/elder.webp" + ], + "prompt": "日本昭和黑白电影风格,35mm黑白负片胶片拍摄,机械式胶片电影摄影机,,50mm标准定焦镜头,f/4.0中等光圈,日式写实主义,物哀美学,沉静克制的视觉基调,留白式构图,纯黑白灰阶影调,中高反差,全灰阶过渡自然,侧光与侧逆光为主,单主光+弱环境补光,柔和自然光效,暗部保留细节、亮部不过曝,写实质朴美术,35mm自然银盐颗粒,中等锐度,柔润结像,宽动态范围,自然空气透视,复古胶片质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 14, + "label_en": "vintage_industrial", + "label": "老式工业", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/vintage_industrial/cover.webp", + "extra_info": [ + "vintage_industrial/female.webp", + "vintage_industrial/youth.webp", + "vintage_industrial/male.webp", + "vintage_industrial/elder.webp" + ], + "prompt": "20 世纪中期重工业纪实影像,胶片工业美学,冷峻厚重的工业力量感;35mm 柯达 Ektachrome 彩色反转胶片,Arriflex 35 II 摄影机,Cooke Speed Panchro 镜头;低饱和度低明度;高位顶光与侧逆光结合,现场固有光源布光,硬调中高反差,暗部扎实高光柔和溢出;中等偏粗胶片颗粒,中低通透度,空间弥漫粉尘雾气带轻微丁达尔效应,中等动态范围,柔和中等锐度,真实工业纪实质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 15, + "label_en": "sixties_poster", + "label": "60海报", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/sixties_poster/cover.webp", + "extra_info": [ + "sixties_poster/female.webp", + "sixties_poster/youth.webp", + "sixties_poster/male.webp", + "sixties_poster/elder.webp" + ], + "prompt": "50-60年代美式复古海报风格,Mid-Century Modern插画美学,高饱和度撞色搭配,强烈的摄影棚硬光与深邃阴影,高反差长调影调,清晰可见的CMYK四色印刷网点与罗塞塔纹,大画幅胶片细腻颗粒,套印不准的微妙色彩溢出,廉价纸张纹理与岁月泛黄效果,油墨不均的印刷瑕疵,硬朗的轮廓光,理想化的人物造型与比例,充满年代感的乐观主义氛围,怀旧广告画,Saturday Evening Post封面质感,平版胶印工艺模拟。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 16, + "label_en": "hk_retro", + "label": "复古港片", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/hk_retro/cover.webp", + "extra_info": [ + "hk_retro/female.webp", + "hk_retro/youth.webp", + "hk_retro/male.webp", + "hk_retro/elder.webp" + ], + "prompt": "90年代香港电影风格,35mm柯达500T胶片拍摄,Arriflex 435摄影机,35mm标准定焦镜头,冷暖对冲色彩体系,混合光源照明,硬光侧逆光布光,高反差硬调影调,暗部扎实厚重,霓虹高光带自然溢色,中等偏粗胶片颗粒,暗部颗粒明显,中低通透度,空气带轻微烟尘雾感,中等动态范围,中等温润锐度,写实基底带戏剧张力", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 17, + "label_en": "retro_film", + "label": "复古胶片", + "tag": "decade", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/retro_film/cover.webp", + "extra_info": [ + "retro_film/female.webp", + "retro_film/youth.webp", + "retro_film/male.webp", + "retro_film/elder.webp" + ], + "prompt": "复古柯达胶片摄影风格,Kodak Portra 400与Gold 200色彩科学,细腻的ISO 400胶片颗粒质感,暖黄与冷蓝双调的怀旧色偏,定焦老镜头(Helios/Canon FD)特有的肥皂泡圆形散景与轻微旋转焦外,自然的镜头漏光与暖调光晕效果,曝光过度1档的褪色感,胶片特有的Halation高光滚降,浅景深,手动对焦的轻微柔化感,底片扫描的灰尘与划痕纹理,压缩的反差与低动态范围,电影级怀旧色彩分级,不经意的生活碎片美学。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 18, + "label_en": "healing_life", + "label": "生活治愈", + "tag": "life", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/healing_life/cover.webp", + "extra_info": [ + "healing_life/female.webp", + "healing_life/youth.webp", + "healing_life/male.webp", + "healing_life/elder.webp" + ], + "prompt": "日式生活流治愈影像,整体基调静谧松弛温润,自然主义日常美学;16mm彩色负片拍摄,35mm/50mm/85mm定焦镜头组,漫射自然光为主、暖柔光为辅,软调低反差影调,明暗过渡平缓;侘寂质朴美术,细腻胶片颗粒,空气感强,宽动态范围,氛围平缓治愈,影视专业标准", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 19, + "label_en": "youth_film", + "label": "青春胶片", + "tag": "life", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/youth_film/cover.webp", + "extra_info": [ + "youth_film/female.webp", + "youth_film/youth.webp", + "youth_film/male.webp", + "youth_film/elder.webp" + ], + "prompt": "日式青春胶片风格,135 彩色负片,富士 C200 质感,大光圈定焦拍摄,平成青春映画美学,清新写实风格,低饱和高明度色彩,阴影青蓝色偏高光暖黄调,中低反差高调影调,宽动态范围,明暗过渡顺滑,黄金时段侧逆光,柔和自然光,漫射软光,发丝轮廓光,空气丁达尔效应,中等细腻胶片颗粒,中低锐度边缘柔和,强空气透视感,奶油焦外虚化,画面通透有呼吸感,治愈懵懂青春情绪,怀旧怅惘氛围,日常诗意感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 20, + "label_en": "atompunk", + "label": "原子朋克", + "tag": "science_fiction", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/atompunk/cover.webp", + "extra_info": [ + "atompunk/female.webp", + "atompunk/youth.webp", + "atompunk/male.webp", + "atompunk/elder.webp" + ], + "prompt": "原子朋克复古科幻,黄金时代科幻美学,1950 年代复古未来主义,前数字时代的未来幻想,冷峻工业感与浪漫人文感交织;35mm 电影胶片,Panavision C 系列变形宽银幕镜头,柯达反转片质感,轻微镜头眩光与边缘色散;高反差硬调影调,中等细度胶片颗粒,中低通透度,淡雾空气感,12 档动态范围,中等偏柔锐度,空间纵深感强,复古胶片光学质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 21, + "label_en": "neon_punk", + "label": "霓虹朋克", + "tag": "science_fiction", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/neon_punk/cover.webp", + "extra_info": [ + "neon_punk/female.webp", + "neon_punk/youth.webp", + "neon_punk/male.webp", + "neon_punk/elder.webp" + ], + "prompt": "科幻反乌托邦赛博朋克风格,新黑色电影叙事逻辑,高科技低生活二元对立气质,冷峻迷幻的反乌托邦都市基调,ARRI Alexa Mini LF 大画幅摄影机拍摄,变形宽银幕镜头,带水平眩光与拉丝散景,14 档宽动态范围,冷暖强对冲,高饱和霓虹与低饱和环境形成对比,全人工霓虹环境光为主,蓝调时刻弱天光为辅,逆光霓虹勾勒轮廓,人物面部弱漫反射光,高反差低调影调,霓虹高光带柔和光晕,16mm 细腻胶片颗粒,中低通透度,强空气感,中等锐度,近实远虚空气透视,电影级质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 22, + "label_en": "hard_scifi", + "label": "硬核科幻", + "tag": "science_fiction", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/hard_scifi/cover.webp", + "extra_info": [ + "hard_scifi/female.webp", + "hard_scifi/youth.webp", + "hard_scifi/male.webp", + "hard_scifi/elder.webp" + ], + "prompt": "硬核科幻风格,严谨的工程技术美学,极低饱和的功能指示色点缀,航天级铝合金与钛合金精密加工质感,暴露可循的管道与电缆走线逻辑,高反差无情阴影,真空级通透度与零浮尘空气感,零颗粒数字成像,功能决定形式的包豪斯设计哲学,理性冷峻的深空压抑氛围,真实物理光影与反射,超精密机械结构的秩序感,非装饰的功能性主义。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 23, + "label_en": "steampunk", + "label": "蒸汽朋克", + "tag": "science_fiction", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/steampunk/cover.webp", + "extra_info": [ + "steampunk/female.webp", + "steampunk/youth.webp", + "steampunk/male.webp", + "steampunk/elder.webp" + ], + "prompt": "维多利亚时代蒸汽朋克风格,复古机械美学与古典贵族服饰融合,中高反差长调影调,金属镜面高光与天鹅绒暗部层次,精密渐开线齿轮与铆接蒸汽管道结构,新艺术运动与哥特复兴装饰纹样,新维多利亚式工业浪漫主义,维多利亚时代湿版底片颗粒质感,微距级材质还原,电影级 ARRI ALEXA 65 质感,匠人之手与工业之力协奏的机械诗篇。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 24, + "label_en": "biopunk", + "label": "血肉朋克", + "tag": "science_fiction", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/biopunk/cover.webp", + "extra_info": [ + "biopunk/female.webp", + "biopunk/youth.webp", + "biopunk/male.webp", + "biopunk/elder.webp" + ], + "prompt": "科幻反乌托邦血肉朋克美学,生物工业共生题材,肉体与工业深度侵蚀融合,有机生长替代工业组装,压抑诡谲的生理冲击感,暗黑生物科技基,35mm柯达Vision3 500T胶片拍摄,ARRICAM摄影机,潘纳维申C系列变形镜头,轻微眩光与焦外拉丝,原生胶片颗粒,高反差硬调影调,低照度大光比,侧逆光勾勒肌理,湿雾散射环境光,中等偏粗胶片颗粒,暗部颗粒加重;低通透度,重度湿雾空气感,丁达尔光效;高动态范围,暗部保留肌理;中等偏低锐度,边缘柔和", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 25, + "label_en": "psychological_horror", + "label": "心理恐怖", + "tag": "type", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/psychological_horror/cover.webp", + "extra_info": [ + "psychological_horror/female.webp", + "psychological_horror/youth.webp", + "psychological_horror/male.webp", + "psychological_horror/elder.webp" + ], + "prompt": "院线级恐怖电影,写实主义恐怖风格,哥特暗黑美学,日常空间异化,心理惊悚氛围,35mm胶片拍摄,ARRICAM ST摄影机,老式电影镜头,轻微暗角与呼吸效应,高反差硬调,大面积纯黑暗部,局部高光溢出,侧逆光+顶光布光,整体欠曝,衰败日常美学,恐怖元素半遮半露,中等胶片颗粒,暗部颗粒加重,低通透度,轻微灰雾,窄动态范围,中等偏低锐度,边缘柔化", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 26, + "label_en": "cult_film", + "label": "邪典cult", + "tag": "type", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/cult_film/cover.webp", + "extra_info": [ + "cult_film/female.webp", + "cult_film/youth.webp", + "cult_film/male.webp", + "cult_film/elder.webp" + ], + "prompt": "美式复古怪诞邪典风格,50-70年代美式复古背景,林奇式超现实悬疑,乡村哥特怪核美学,克制型心理惊悚,平静下的诡异感,35mm彩色胶片拍摄,Arriflex 35mm胶片摄影机,老式定焦镜头,轻微镜头漏光与眩光,胶片齿孔边缘,低调布光,中高反差,大面积死黑暗部,50-70年代美式复古美术,搪植入微小违和怪异细节,中等偏粗胶片颗粒,暗部颗粒加重,低通透薄雾感,窄动态范围,中心锐度中等边缘柔化,轻微胶片划痕与印片污渍,老胶片胶转磁质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 27, + "label_en": "modern_warfare", + "label": "现代战争", + "tag": "type", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/modern_warfare/cover.webp", + "extra_info": [ + "modern_warfare/female.webp", + "modern_warfare/youth.webp", + "modern_warfare/male.webp", + "modern_warfare/elder.webp" + ], + "prompt": "写实复古战争史诗画面,35mm 柯达电影胶片拍摄,潘纳维申复古变形镜头,自然胶片颗粒,轻微镜头眩光与焦外拉丝;低饱和低明度影调,高反差低调硬调,侧逆光硬光布光,冷暖光影对比;中等偏粗胶片颗粒,低通透度,强空气透视,硝烟扬尘空气介质,中等动态范围,中等锐度边缘柔化,沉郁冷峻的战场氛围,沉浸式临场感,影视级画面质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 28, + "label_en": "wasteland_road", + "label": "荒野公路", + "tag": "type", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/wasteland_road/cover.webp", + "extra_info": [ + "wasteland_road/female.webp", + "wasteland_road/youth.webp", + "wasteland_road/male.webp", + "wasteland_road/elder.webp" + ], + "prompt": "荒野叙事电影质感,新写实主义融合史诗气质,粗粝冷峻的旷野美学,ARRI Alexa Mini LF大画幅数字摄影机,变形宽银幕定焦镜头成像,14档宽动态范围,自然光效主导,黄金时刻侧逆光,中高反差硬调,暗部扎实有层次,亮部保留高光肌理,整体曝光偏沉,风化写实美术风格,中等偏粗有机胶片颗粒,中低通透度,空气尘雾感,强空气透视,自然中等锐度,电影级画面质感,氛围孤寂蛮荒", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 29, + "label_en": "western_cowboy", + "label": "西部牛仔", + "tag": "type", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/western_cowboy/cover.webp", + "extra_info": [ + "western_cowboy/female.webp", + "western_cowboy/youth.webp", + "western_cowboy/male.webp", + "western_cowboy/elder.webp" + ], + "prompt": "经典美式西部风格,强烈的正午直射阳光,边缘锋利的硬阴影,极高反差的影调结构,风沙扬尘的大气介质,35mm 胶片颗粒感,变形宽银幕的横向拉伸与光学眩光,复古西部电影的严峻史诗氛围,帽檐阴影下锐利的眼神光,汗渍与泥土的细节,赛尔乔·莱昂内式构图,尘土飞扬的荒野传说。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 30, + "label_en": "neo_chinese", + "label": "新兴中式", + "tag": "poetic", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/neo_chinese/cover.webp", + "extra_info": [ + "neo_chinese/female.webp", + "neo_chinese/youth.webp", + "neo_chinese/male.webp", + "neo_chinese/elder.webp" + ], + "prompt": "电影级画面,新中式诗意写实风格,东方人文意境,阿莱Alexa Mini LF拍摄,蔡司电影定焦镜头,35mm柯达夜景胶片质感,霁蓝灰蓝冷蓝辉光,低饱和冷暖对冲,柔光布光,半明半暗留白布光,中低调影调,宽动态范围,明暗过渡柔和,细腻中等胶片颗粒,中高通透度,微弱薄雾空气感,蓝辉轻度丁达尔散射,中等柔化锐度,焦外柔和,整体静谧悠远东方禅意氛围", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 31, + "label_en": "high_key_absurd", + "label": "高调荒诞", + "tag": "poetic", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/high_key_absurd/cover.webp", + "extra_info": [ + "high_key_absurd/female.webp", + "high_key_absurd/youth.webp", + "high_key_absurd/male.webp", + "high_key_absurd/elder.webp" + ], + "prompt": "35mm胶片质感,高调荒诞美学,超现实怪诞风格,ARRICAM胶片摄影机,潘纳维申C系列变形镜头,柯达Vision3 50D彩色负片,高短调影调,大面积柔光照明,光比1:2,高明度马卡龙主色系,高饱和点缀色,明暗过渡顺滑,高光保留细节,暗部极浅无死黑,洁净规整哑光质感,几何化夸张造型,秩序化陈设,比例失调的荒诞细节,中等细腻胶片颗粒,高通透度,轻微空气感,高动态范围,中等偏高锐度,变形宽银幕散景光晕", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 32, + "label_en": "shanghai_animation", + "label": "上美动漫", + "tag": "animation", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/shanghai_animation/cover.webp", + "extra_info": [ + "shanghai_animation/female.webp", + "shanghai_animation/youth.webp", + "shanghai_animation/male.webp", + "shanghai_animation/elder.webp" + ], + "prompt": "中国动画学派,上海美术电影制片厂经典风格,手绘赛璐璐动画正片,中国传统工笔重彩,东方古典浪漫主义,影视级叙事质感,ISO100低感光度,平光为主弱塑形光,装饰性布光,中国传统矿物颜料配色,中对比度中间调,三级明暗层级,铁线描勾边,矿物颜料平涂,局部毛笔晕染,意象化造型,均衡构图留白得当,线条清晰色块均匀,背景水墨空气感,宽动态范围", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 33, + "label_en": "otomo_akira", + "label": "大友克洋", + "tag": "animation", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/otomo_akira/cover.webp", + "extra_info": [ + "otomo_akira/female.webp", + "otomo_akira/youth.webp", + "otomo_akira/male.webp", + "otomo_akira/elder.webp" + ], + "prompt": "大友克洋风格,硬核科幻写实动画,赛璐璐手绘质感,工业精密美学,冷峻末世基调,低调硬光布光,大光比高反差硬调,人工霓虹混合光源,中低通透度,强空间纵深感,12档动态范围,中高锐度,暗部沉实高光克制", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 34, + "label_en": "stop_motion", + "label": "定格动画", + "tag": "animation", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/stop_motion/cover.webp", + "extra_info": [ + "stop_motion/female.webp", + "stop_motion/youth.webp", + "stop_motion/male.webp", + "stop_motion/elder.webp" + ], + "prompt": "逐帧定格动画,物理实体微缩模型,手工制作质感,移轴效果,自然景深,精准透视,柔光主光,低位漫反射补光,中对比度全阶影调,明暗过渡自然,黏土/树脂/布艺/木质实体材质,统一微缩比例,手工打磨痕迹,道具带自然使用磨损,材质肌理清晰,真实物理投影,细腻数字底噪,中高通透度,轻度空气透视,宽动态范围,中等偏高锐度,院线级定格动画质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 35, + "label_en": "claymation", + "label": "黏土动画", + "tag": "animation", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/claymation/cover.webp", + "extra_info": [ + "claymation/female.webp", + "claymation/youth.webp", + "claymation/male.webp", + "claymation/elder.webp" + ], + "prompt": "传统手工黏土定格动画,100mm f/2.8微距镜头,大画幅传感器,宽动态范围,实体塑性黏土材质,表面带手工塑形细微肌理,微缩实体模型场景,手工制作轻微不完美痕迹,45°侧位主光,漫射辅光补亮暗部,顶置轮廓光分离主体,中反差柔化影调,明暗过渡自然,细腻浅度胶片颗粒,中等通透度,轻微空气透视,自然中等锐度,微缩世界沉浸感,温润手工艺术质感,阿德曼工作室。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 36, + "label_en": "chinese_ink", + "label": "中式水墨", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/chinese_ink/cover.webp", + "extra_info": [ + "chinese_ink/female.webp", + "chinese_ink/youth.webp", + "chinese_ink/male.webp", + "chinese_ink/elder.webp" + ], + "prompt": "新中式数字水墨影像,东方诗意美学,虚实相生,留白见意,气韵生动,墨分五色,清雅空灵的电影画面,墨色五阶为主色,软反差低对比度,细腻灰阶过渡,自然漫射柔光,阴影边缘晕染虚化,中低调影调,生宣纤维质感基底,墨色边缘自然洇染,干湿笔锋结合,造型概括简练,中式传统形制,近浓远淡,无生硬轮廓,强空气感水雾山岚,中低锐度边缘柔化,温润沉静的东方禅意氛围", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 37, + "label_en": "ukiyo_e", + "label": "浮世绘风", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/ukiyo_e/cover.webp", + "extra_info": [ + "ukiyo_e/female.webp", + "ukiyo_e/youth.webp", + "ukiyo_e/male.webp", + "ukiyo_e/elder.webp" + ], + "prompt": "日本传统浮世绘木版画风格,葛饰北斋与歌川广重艺术风格,流畅有力的书法性黑色轮廓线,大面积矿物颜料平涂上色,层次分明的硬边阴影切割,无渐变渲染的2-3级色阶,和纸植物纤维的天然纹理质感,木版木理纹理的隐约显现,版木角压痕与轻微套印不准的工艺感,幽玄与物哀的东方古典美学意境,古朴典雅的木版墨色与矿物色粉质感,和纸的温润呼吸感。 无文字。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 38, + "label_en": "shadow_puppet", + "label": "传统皮影", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/shadow_puppet/cover.webp", + "extra_info": [ + "shadow_puppet/female.webp", + "shadow_puppet/youth.webp", + "shadow_puppet/male.webp", + "shadow_puppet/elder.webp" + ], + "prompt": "中国传统非遗皮影戏风格,蔡司Supreme Prime定焦镜头,Super 35画幅,16档宽动态范围,中高反差戏剧影调,传统矿物颜料色系,明暗层级丰富,逆光下皮料半通透,手工兽皮皮质质感,传统皮影造型规范,镂空雕刻工艺,中式传统纹样,分层平面布局,手工刀刻痕迹,矿物染色颗粒感,线条刚劲流畅,细腻低银盐颗粒,温润通透画质,微弱丁达尔空气感,中等偏高锐度,细节扎实,16mm胶片复古质感,东方写意戏剧氛围,古朴厚重的非遗艺术调性,专业影视布光", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 39, + "label_en": "egyptian_mural", + "label": "埃及壁画", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/egyptian_mural/cover.webp", + "extra_info": [ + "egyptian_mural/female.webp", + "egyptian_mural/youth.webp", + "egyptian_mural/male.webp", + "egyptian_mural/elder.webp" + ], + "prompt": "史诗级古埃及壁画风格,平涂矿物色彩,硬朗粗粝的深色轮廓线,几何化边框纹样,象形文字铭文装饰,局部鎏金斑驳质感,极低反差影调,神秘古老仪式氛围,浮尘空气感,ARRI Alexa 65 质感,庄严永恒的画面。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 40, + "label_en": "minimal_illustration", + "label": "简约插画", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/minimal_illustration/cover.webp", + "extra_info": [ + "minimal_illustration/female.webp", + "minimal_illustration/youth.webp", + "minimal_illustration/male.webp", + "minimal_illustration/elder.webp" + ], + "prompt": "现代扁平化设计风格,纯矢量图形质感,极简几何形状构成,高饱和纯色块填充,高对比度色相搭配,清晰锐利的轮廓线条(单像素级),绝对无阴影、无渐变、无纹理、无材质表现的平面设计法则,严格网格系统构图,大面积留白提供视觉呼吸感,UI插画与信息图形设计美学,简洁明快的现代简约视觉效果,包豪斯与瑞士国际主义设计语言,整齐统一的元素间距与圆角半径,信息传达清晰精确,完美的像素级锐度。", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 41, + "label_en": "dali_surreal", + "label": "荒诞达利", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/dali_surreal/cover.webp", + "extra_info": [ + "dali_surreal/female.webp", + "dali_surreal/youth.webp", + "dali_surreal/male.webp", + "dali_surreal/elder.webp" + ], + "prompt": "达利式超现实主义影视画面,偏执批判派梦境美学,潜意识荒诞隐喻,古典艺术厚重调性,非逻辑错视空间,写实细节搭配违背物理规则的形态,65mm柯达Vision3 50D胶片拍摄,Panavision大画幅胶片摄影机,Primo定焦镜头,1/8黑柔焦镜,自然细腻胶片颗粒,侧逆硬光主光,冷色漫反射补光,丁达尔体积光;古典明暗对照法,中高反差影调,14档宽动态范围,高光有细节暗部有层次,达利经典意象写实材质+非逻辑形态,多重透视错视空间,中低通透度,强空气透视雾感,近实远虚,主体锐度清晰边缘柔化,影调过渡平滑,电影级超现实梦境质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 42, + "label_en": "game_concept", + "label": "游戏概念", + "tag": "drawing", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/game_concept/cover.webp", + "extra_info": [ + "game_concept/female.webp", + "game_concept/youth.webp", + "game_concept/male.webp", + "game_concept/elder.webp" + ], + "prompt": "美国游戏概念艺术,好莱坞写实主义数字概念设计,Master Prime定焦镜头,大光圈浅景深,轻微自然暗角与镜头眩光,低饱和基底色,高对比度点缀色,侧逆轮廓光,伦勃朗布光逻辑,硬调高反差影调,暗部有细节,高光不裁切,PBR物理材质,美式工业写实造型,细腻原生数字颗粒,中高通透度,真实大气透视,空气感强,14档宽动态范围,中心高锐度边缘柔化,3A游戏概念美术质感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 43, + "label_en": "chinese_myth", + "label": "传统神话", + "tag": "myth", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/chinese_myth/cover.webp", + "extra_info": [ + "chinese_myth/female.webp", + "chinese_myth/youth.webp", + "chinese_myth/male.webp", + "chinese_myth/elder.webp" + ], + "prompt": "东方新古典神话史诗,新东方主义美学,上古神话题材,兼具洪荒厚重感与空灵仙气,写实与东方写意结合,庄肃瑰丽,65mm大画幅传感器,16mm柯达胶片颗粒,电影级浅景深,变形宽银幕散景,暗部偏青蓝冷调,亮部偏暖金调,冷暖对冲,中高反差影调,9档动态范围,冷色环境补光,丁达尔体积光束,伦勃朗人物布光,神性角色轮廓光晕,淡墨晕染质感,古拙雄浑,细腻胶片颗粒,高通透度,强空气感,宽动态范围,中心锐度扎实,焦外柔和,电影级画质,空间纵深感强,自然暗角", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 44, + "label_en": "greek_myth", + "label": "希腊神话", + "tag": "myth", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/greek_myth/cover.webp", + "extra_info": [ + "greek_myth/female.webp", + "greek_myth/youth.webp", + "greek_myth/male.webp", + "greek_myth/elder.webp" + ], + "prompt": "希腊神话史诗风格,35mm柯达胶片拍摄,Vision3胶片质感,中等胶片颗粒,高动态范围,新古典主义史诗风格,沉郁雄浑的神性史诗气质,考古级写实质感,地中海硬调自然光,侧逆光塑造雕塑感轮廓,明暗层次丰富,中高反差影调,冷暖对比布光,画面中心锐度高,边缘轻微柔化,变形宽银幕拉丝光斑,空气透视远景雾感,电影级光影,影视工业质感,细节丰富,构图庄重有仪式感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 45, + "label_en": "western_fantasy", + "label": "西式魔幻", + "tag": "myth", + "cover": "https://nfg-web-assets.cdnfg.com/dramaclaw/western_fantasy/cover.webp", + "extra_info": [ + "western_fantasy/female.webp", + "western_fantasy/youth.webp", + "western_fantasy/male.webp", + "western_fantasy/elder.webp" + ], + "prompt": "黑暗奇幻影视,新哥特现实主义风格,史诗悲剧质感,神性陨落与蛮荒生长的核心情绪,辉煌残响中的绝望诗意,写实化奇幻叙事,35mm柯达Vision3 5219胶片拍摄,Panavision C系列变形镜头,中等胶片颗粒,4K数字中间片校色,低调硬调影调,大光比1:12,侧逆轮廓光塑形,环境光压暗,丁达尔光束,高动态范围,衰败的崇高感,低通透度冷灰薄雾,强空气透视,阴冷潮湿的幽冥气息,中等柔化锐度,史诗级肃穆压迫感", + "media_url": "", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + } + ] + }, + "motion": { + "title": "运镜", + "tags": { + "basic": "基础控制", + "follow": "人物跟拍", + "reveal": "揭示转场", + "emotion": "情绪强化", + "aerial": "空间航拍" + }, + "list": [ + { + "id": 46, + "label_en": "static_shot", + "label": "固定镜头", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/static_shot.webp", + "desc": "建立冷静秩序", + "prompt": "运镜:固定机位,镜头位置和拍摄方向都保持不变,画面构图稳定,由主体动作或画面内部变化形成运动感。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/static_shot.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 47, + "label_en": "tilt_up", + "label": "镜头上摇", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/tilt_up.webp", + "desc": "展示高度威压", + "prompt": "运镜:固定机位上摇,镜头位置不移动,只沿垂直方向缓慢向上转动,画面重心从下方逐步移向上方。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/tilt_up.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 48, + "label_en": "tilt_down", + "label": "镜头下摇", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/tilt_down.webp", + "desc": "从环境落到人", + "prompt": "运镜:固定机位下摇,镜头位置不移动,只沿垂直方向缓慢向下转动,画面重心从上方逐步移向下方。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/tilt_down.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 49, + "label_en": "pan_left", + "label": "镜头左摇", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pan_left.webp", + "desc": "横向展示空间", + "prompt": "运镜:固定机位左摇,镜头位置不移动,只沿水平方向缓慢向左转动,让画面内容从右向左连续变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pan_left.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 50, + "label_en": "pan_right", + "label": "镜头右摇", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pan_right.webp", + "desc": "引出画外信息", + "prompt": "运镜:固定机位右摇,镜头位置不移动,只沿水平方向缓慢向右转动,让画面内容从左向右连续变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pan_right.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 51, + "label_en": "crane_up", + "label": "镜头上升", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crane_up.webp", + "desc": "展开场景规模", + "prompt": "运镜:镜头垂直上升,拍摄方向基本保持稳定,机位高度从低到高变化,画面视角随高度产生变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crane_up.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 52, + "label_en": "truck_left", + "label": "镜头左移", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/truck_left.webp", + "desc": "制造空间视差", + "prompt": "运镜:镜头左移,机位整体向左横向平移,拍摄方向基本不变,前景与背景形成自然视差。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/truck_left.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 53, + "label_en": "crane_down", + "label": "镜头下降", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crane_down.webp", + "desc": "从全局聚焦", + "prompt": "运镜:镜头垂直下降,拍摄方向基本保持稳定,机位高度从高到低变化,画面视角随高度产生变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crane_down.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 54, + "label_en": "truck_right", + "label": "镜头右移", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/truck_right.webp", + "desc": "制造空间视差", + "prompt": "运镜:镜头右移,机位整体向右横向平移,拍摄方向基本不变,前景与背景形成自然视差。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/truck_right.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 55, + "label_en": "dolly_in", + "label": "镜头前推", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_in.webp", + "desc": "强调情绪靠近", + "prompt": "运镜:镜头缓慢前推,机位沿镜头朝向向前移动,主体在画面中逐渐变大,空间层次产生自然视差。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_in.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 56, + "label_en": "dolly_out", + "label": "镜头后移", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_out.webp", + "desc": "展示人物孤独", + "prompt": "运镜:镜头缓慢后拉,机位沿镜头朝向向后移动,主体在画面中逐渐变小,画面范围逐步变宽。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_out.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 57, + "label_en": "zoom_in", + "label": "变焦推进", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/zoom_in.webp", + "desc": "突出表情反应", + "prompt": "运镜:缓慢变焦推进,机位保持不动,只通过焦距变化让画面范围逐渐收紧。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/zoom_in.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 58, + "label_en": "zoom_out", + "label": "变焦拉远", + "tag": "basic", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/zoom_out.webp", + "desc": "从局部到全貌", + "prompt": "运镜:缓慢变焦拉远,机位保持不动,只通过焦距变化让画面范围逐渐放宽。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/zoom_out.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 59, + "label_en": "follow_tracking", + "label": "跟随拍摄", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/follow_tracking.webp", + "desc": "跟住人物行动", + "prompt": "运镜:跟随拍摄,镜头跟随主体运动方向移动,与主体保持相对稳定的距离和构图。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/follow_tracking.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 60, + "label_en": "leading_tracking", + "label": "迎面跟拍", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/leading_tracking.webp", + "desc": "保留人物表情", + "prompt": "运镜:迎面跟拍,镜头位于主体前方并随主体运动同步后退,保持主体正面信息稳定可见。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/leading_tracking.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 61, + "label_en": "profile_tracking", + "label": "侧面跟拍", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/profile_tracking.webp", + "desc": "强化行进节奏", + "prompt": "运镜:侧面跟拍,镜头在主体侧方与其平行移动,保持侧向构图稳定,并形成横向空间视差。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/profile_tracking.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 62, + "label_en": "handheld_shot", + "label": "手持拍摄", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/handheld_shot.webp", + "desc": "增加真实紧张", + "prompt": "运镜:手持拍摄,镜头带轻微自然晃动和近距离临场感,可随主体动作产生小幅调整。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/handheld_shot.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 63, + "label_en": "pov_shot", + "label": "第一视角", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pov_shot.webp", + "desc": "进入角色视角", + "prompt": "运镜:第一视角,镜头模拟角色视线和身体移动,带轻微头部晃动和主观视角的运动反馈。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pov_shot.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 64, + "label_en": "steadicam_walkthrough", + "label": "稳定器行进", + "tag": "follow", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/steadicam_walkthrough.webp", + "desc": "平稳进入现场", + "prompt": "运镜:稳定器行进,镜头以稳定、连续的方式向前或跟随移动,运动平滑,晃动幅度低。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/steadicam_walkthrough.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 65, + "label_en": "slider_reveal", + "label": "横滑揭示", + "tag": "reveal", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/slider_reveal.webp", + "desc": "从遮挡露主体", + "prompt": "运镜:横滑揭示,镜头从遮挡关系中横向滑出,逐步露出被遮挡的主体或关键信息。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/slider_reveal.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 66, + "label_en": "foreground_wipe", + "label": "前景擦过", + "tag": "reveal", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/foreground_wipe.webp", + "desc": "用遮挡完成切换", + "prompt": "运镜:前景擦过,近处遮挡物从镜头前掠过并短暂覆盖画面,随后露出新的画面内容。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/foreground_wipe.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 67, + "label_en": "push_through", + "label": "穿越镜头", + "tag": "reveal", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/push_through.webp", + "desc": "穿过边界入场", + "prompt": "运镜:穿越镜头,镜头持续快速向前推进,并产生短暂运动模糊,穿过明确的前景边界或空间层次,进入后方画面,最后落到新的构图或目标上。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/push_through.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 68, + "label_en": "whip_pan", + "label": "甩摇", + "tag": "reveal", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/whip_pan.webp", + "desc": "快速切换信息", + "prompt": "运镜:甩摇,镜头快速水平转动并产生短暂运动模糊,最后落到新的构图或目标上。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/whip_pan.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 69, + "label_en": "rack_focus", + "label": "焦点转移", + "tag": "reveal", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/rack_focus.webp", + "desc": "注意力换目标", + "prompt": "运镜:焦点转移,镜头位置可以不变,焦点从一个画面层次平滑切换到另一个画面层次。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/rack_focus.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 70, + "label_en": "crash_zoom", + "label": "急速变焦", + "tag": "emotion", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crash_zoom.webp", + "desc": "放大戏剧反应", + "prompt": "运镜:急速变焦推进,机位保持不动,画面范围快速收紧到目标区域,形成突然强调。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/crash_zoom.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 71, + "label_en": "dolly_zoom", + "label": "希区柯克", + "tag": "emotion", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_zoom.webp", + "desc": "现实崩塌瞬间", + "prompt": "运镜:希区柯克式变焦,机位移动与反向变焦同时发生,主体大小基本保持稳定,背景透视发生明显变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/dolly_zoom.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 72, + "label_en": "orbit_180", + "label": "环绕拍摄", + "tag": "emotion", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/orbit_180.webp", + "desc": "强化人物气场", + "prompt": "运镜:环绕拍摄,镜头围绕主体平稳旋转约180度,主体保持在画面核心位置,背景产生连续旋转视差。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/orbit_180.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 73, + "label_en": "ascending_orbit", + "label": "盘旋抬升", + "tag": "emotion", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/ascending_orbit.webp", + "desc": "高光登场时刻", + "prompt": "运镜:盘旋抬升,镜头围绕主体旋转的同时逐渐升高,主体保持锁定,机位高度持续变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/ascending_orbit.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 74, + "label_en": "descending_orbit", + "label": "盘旋下降", + "tag": "emotion", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/descending_orbit.webp", + "desc": "巨物压迫登场", + "prompt": "运镜:盘旋下降,镜头围绕主体旋转的同时逐渐降低,主体保持锁定,机位高度持续变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/descending_orbit.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 75, + "label_en": "fpv_flythrough", + "label": "穿越机运镜", + "tag": "aerial", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/fpv_flythrough.webp", + "desc": "高速掠过空间", + "prompt": "运镜:穿越机运镜,fpv 镜头以第一视角高速穿行空间,贴近场景边界和主体灵活飞行,带明显速度感与滚转俯仰变化。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/fpv_flythrough.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 76, + "label_en": "aerial_establishing", + "label": "高空航拍", + "tag": "aerial", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/aerial_establishing.webp", + "desc": "建立宏大世界", + "prompt": "运镜:高空航拍,镜头以高角度俯视画面,并保持稳定滑行,用于呈现空间关系和整体布局。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/aerial_establishing.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 77, + "label_en": "drone_dive", + "label": "俯冲下降", + "tag": "aerial", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/drone_dive.webp", + "desc": "从高空压向目标", + "prompt": "运镜:俯冲下降,镜头从高处向目标方向快速下降并靠近,形成明显速度感和压迫感。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/drone_dive.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 78, + "label_en": "pull_away", + "label": "拉升离场", + "tag": "aerial", + "cover": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pull_away.webp", + "desc": "人变小世界变大", + "prompt": "运镜:拉升离场,镜头从主体附近向后并向上移动,主体在画面中逐渐变小,画面范围逐步扩大。", + "media_url": "https://canvai.oss-cn-shanghai.aliyuncs.com/camera-motions/pull_away.mp4", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + } + ] + }, + "nine_grid": { + "title": "九宫格", + "list": [ + { + "id": 79, + "label_en": "multi_camera_nine_grid", + "label": "多机位九宫格", + "tag": "", + "desc": "生成一个 3x3 九宫格的多机位联系表", + "prompt": "Generate a libtv-style 3x3 director multi-camera contact sheet from the source image.\n\nOutput requirements:\n- Final output must be one readable 3x3 grid contact sheet, not nine separate images.\n- Keep the same primary subject, same costume, same scene, same time moment, and same action.\n- Do not add new characters, new dialogue, new story events, or unrelated props.\n- Each cell must preserve the source image aspect ratio and orientation.\n- Do not crop each camera view into a different ratio.\n- Vary only camera coverage: shot size, camera height, lens distance, and angle.\n- Each panel must look like a usable director coverage frame from the same shot setup.\n- Add a small white label in the upper-left corner of every cell.\n- Use exactly these nine labels and shot types in reading order:\n [KF1 | 3s | ELS] extreme long shot / full environment,\n [KF2 | 2s | LS] long shot / full body,\n [KF3 | 2s | MLS] medium long shot,\n [KF4 | 2s | MS] medium shot,\n [KF5 | 2s | MCU] medium close-up,\n [KF6 | 2s | CU] close-up,\n [KF7 | 1s | ECU] extreme close-up of the key hand/object/detail,\n [KF8 | 2s | High-Angle] high-angle view,\n [KF9 | 2s | Low-Angle] low-angle view.\n- Use thin dark grid lines between cells; no large white gutters, no decorative border.\n- Fill the whole output canvas; do not add black bars, letterboxing, UI, or watermark.\n- Preserve identity, costume, lighting mood, color tone, and scene continuity across all cells.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 80, + "label_en": "story_pitch_four_grid", + "label": "剧情推演四宫格", + "tag": "", + "desc": "生成一个 2x2 四宫格的剧情推演联系表", + "prompt": "Generate a 2x2 story pitch board from the source image.\n\nOutput requirements:\n- Create four consecutive pitch frames that expand the current story moment.\n- Keep the same characters, scene, and dramatic context.\n- Emphasize clear story progression and emotional beats.\n- Each cell must preserve the source image aspect ratio and orientation.\n- Do not crop each story frame into a different ratio.\n- Arrange the four same-ratio frames in a clean 2x2 grid with thin dividers.\n- Fill the whole output canvas; do not add black bars, letterboxing, UI, or watermark.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 81, + "label_en": "character_face_three_view", + "label": "角色脸部三视图", + "tag": "", + "desc": "生成一个 3x2 三宫图的角色脸部联系表", + "prompt": "Generate a clean three-view face sheet from the source image.\n\nOutput requirements:\n- Show front view, three-quarter view, and side view of the same face.\n- Preserve facial identity, age, hairstyle, skin tone, and expression logic.\n- Use a clean reference-sheet style.\n- Final output must be a compact three-view face layout.", + "ratio": "3:2", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 82, + "label_en": "product_three_view", + "label": "产品三视图", + "tag": "", + "desc": "生成一个 3x2 三宫图的产品联系表", + "prompt": "Generate a clean three-view product reference sheet from the source image.\n\nOutput requirements:\n- Show front, side, and back/alternate view of the same product.\n- Preserve materials, silhouette, proportions, and key details.\n- Use a clean product reference layout with neutral presentation.\n- Final output must be a three-view sheet.", + "ratio": "3:2", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 83, + "label_en": "storyboard_25_grid", + "label": "25宫格连贯分镜", + "tag": "", + "desc": "生成一个 5x5 25宫格的连贯分镜联系表", + "prompt": "Generate a libtv-style 5x5 cinematic storyboard shot sequence from the source image.\n\nOutput requirements:\n- Final output must be one readable 5x5 storyboard contact sheet, not 25 separate images.\n- Build a coherent shot progression around the same core event in the source image.\n- Do not create random variants, unrelated future scenes, or a new ending.\n- Preserve the visible subjects, identities, costumes/materials, environment, lighting mood, and key objects from the source image.\n- Adapt the sequence to the actual source content. Do not invent dialogue, extra characters, paper, weapons, vehicles, or props that are not visible or strongly implied.\n- Organize the 25 cells like an editable film sequence:\n 1-3 establishing coverage of the location, subject placement, and spatial relationship,\n 4-6 primary subject close-ups, detail views, or reaction shots when characters exist,\n 7-10 alternate angles, over-the-shoulder or eye-line coverage only when applicable,\n 11-15 step-by-step progression of the visible key action or the most plausible next micro-action,\n 16-19 inserts and extreme close-ups of visible key details: hands, face, eyes, object, texture, signage, machinery, landscape feature, or environment clue,\n 20-22 pause, reaction, consequence, or atmospheric detail beats,\n 23-25 restrained resolution frames that stay in the same scene and subject context.\n- Mix shot types deliberately: wide, medium, close-up, extreme close-up, insert, reaction/detail. Use OTS only when the source contains a valid over-shoulder relationship.\n- Avoid repeating the same two-shot or portrait composition across many cells.\n- Number each cell unobtrusively in the upper-left corner from 1 to 25.\n- Each cell must preserve the source image aspect ratio and orientation.\n- Do not crop each storyboard frame into a different ratio.\n- Arrange the twenty-five same-ratio frames in a clean 5x5 grid with thin dividers.\n- Fill the whole output canvas; do not add black bars, letterboxing, UI, or watermark.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 84, + "label_en": "cinematic_light_correction", + "label": "电影级光影校正", + "tag": "", + "desc": "修正电影灯光,使场景更真实", + "prompt": "Cinematically refine the source image lighting.\n\nOutput requirements:\n- Improve light hierarchy, shadow structure, exposure balance, and atmosphere.\n- Preserve the source image aspect ratio, canvas dimensions, and orientation exactly.\n- Keep the same scene, same characters, and same camera framing.\n- Do not turn the image into a different composition.\n- Fill the whole existing canvas; do not add black bars, borders, or letterboxing.\n- Final output must remain a single frame with no collage, UI, watermark, or text.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 85, + "label_en": "character_three_view_generation", + "label": "角色三视图生成", + "tag": "", + "desc": "生成一个 16x9 三宫图的角色角色联系表", + "prompt": "Generate a clean character three-view sheet from the source image.\n\nOutput requirements:\n- Show front, side, and back/full-figure view of the same character.\n- Preserve face identity, body proportions, costume details, and style.\n- Keep the presentation clean and reference-friendly.\n- Final output must be a three-view character sheet.", + "ratio": "16:9", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 86, + "label_en": "image_projection_after_3s", + "label": "画面推演-3秒后", + "tag": "", + "desc": "创建一个 3秒后投影的图像,作为视频的一帧", + "prompt": "Create a future keyframe from the source image, as if this is a libtv-style frame projection 3 seconds later in a video.\n\nOutput requirements:\n- Preserve character identity, costume, environment, art style, and story continuity.\n- Preserve the source image aspect ratio, canvas dimensions, and orientation exactly.\n- Fill the whole existing canvas; do not add black bars, borders, or letterboxing.\n- Do not make a near-duplicate or simple retouch of the source image.\n- Create a clear time jump: the subject must be in a different action phase, body pose, walking position, hand position, gaze, and object placement.\n- Within the same frame size, use plausible camera pan, tilt, push, pull, or subject relocation to make the temporal change obvious.\n- Allow doors, props, cloth, hair, shadows, and nearby environment details to change according to the action, while keeping spatial continuity coherent.\n- The projected moment should feel like a real adjacent video frame, not a retouched still.\n- Final output must be one single frame with no collage, UI, watermark, or text.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + }, + { + "id": 87, + "label_en": "image_projection_before_5s", + "label": "画面推演-5秒前", + "tag": "", + "desc": "创建一个 5秒前投影的图像,作为视频的一帧", + "prompt": "Create a past keyframe from the source image, as if this is a libtv-style frame projection 5 seconds before in a video.\n\nOutput requirements:\n- Preserve character identity, costume, environment, art style, and story continuity.\n- Preserve the source image aspect ratio, canvas dimensions, and orientation exactly.\n- Fill the whole existing canvas; do not add black bars, borders, or letterboxing.\n- Do not make a near-duplicate or simple retouch of the source image.\n- Create a clear earlier setup: the subject must be in a different action phase, body pose, walking position, hand position, gaze, and object placement.\n- Within the same frame size, use plausible camera pan, tilt, push, pull, or subject relocation to make the earlier moment obvious.\n- Allow doors, props, cloth, hair, shadows, and nearby environment details to change according to the preceding action, while keeping spatial continuity coherent.\n- The projected moment should feel like a real adjacent video frame, not a retouched still.\n- Final output must be one single frame with no collage, UI, watermark, or text.", + "ratio": "original", + "enabled": true, + "visibility": "public", + "create_at": "2023-08-01 00:00:00", + "update_at": "2023-08-01 00:00:00", + "owner_id": "99e218dfc1314c413b206aead4d8877b" + } + ] + } +} diff --git a/backend/internal/tools/tool_mention.go b/backend/internal/tools/tool_mention.go new file mode 100644 index 000000000..38ce959c8 --- /dev/null +++ b/backend/internal/tools/tool_mention.go @@ -0,0 +1,51 @@ +package tools + +import ( + "regexp" + "strconv" + "strings" + + "infinite-canvas/backend/internal/kernel" +) + +var toolMentionPattern = regexp.MustCompile(`@\[tool:(style|motion|nine_grid|effect):(\d+):[^:\]]+:[^\]]+\]`) + +// ResolveToolMentionTokens validates every tool before expanding it. Labels and +// icons are presentation only; neither grants access to a private tool. +func (s *Service) ResolveToolMentionTokens(userID, mode, prompt string) (string, error) { + if !strings.Contains(prompt, "@[tool:") { + return prompt, nil + } + if strings.TrimSpace(userID) == "" { + return "", kernel.Unauthorized("请先登录") + } + if strings.Contains(toolMentionPattern.ReplaceAllString(prompt, ""), "@[tool:") { + return "", kernel.BadAuthRequest("工具标签无效,请重新选择工具") + } + prompts := make(map[string]string) + for _, match := range toolMentionPattern.FindAllStringSubmatch(prompt, -1) { + if _, ok := prompts[match[0]]; ok { + continue + } + id, err := strconv.ParseInt(match[2], 10, 64) + if err != nil || id <= 0 { + return "", kernel.BadAuthRequest("工具 ID 无效") + } + tool, err := s.repo.ToolForUser(userID, id) + if err != nil { + return "", err + } + if !tool.Enabled || tool.Type != match[1] { + return "", kernel.BadAuthRequest("工具已停用或类型不匹配") + } + imageTool := tool.Type == ToolTypeStyle || tool.Type == ToolTypeNineGrid + if (imageTool && mode != "image") || (!imageTool && mode != "video") { + return "", kernel.BadAuthRequest("工具不适用于当前生成类型") + } + if strings.TrimSpace(tool.Prompt) == "" || strings.Contains(tool.Prompt, "@[tool:") { + return "", kernel.BadAuthRequest("工具提示词为空或包含嵌套工具标签") + } + prompts[match[0]] = tool.Prompt + } + return toolMentionPattern.ReplaceAllStringFunc(prompt, func(token string) string { return prompts[token] }), nil +} diff --git a/backend/internal/tools/tools.go b/backend/internal/tools/tools.go new file mode 100644 index 000000000..5859ce5da --- /dev/null +++ b/backend/internal/tools/tools.go @@ -0,0 +1,407 @@ +package tools + +import ( + "errors" + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +const ( + ToolSourceBuiltin = "builtin" + ToolSourceUser = "user" + + ToolVisibilityPublic = "public" + ToolVisibilityPrivate = "private" + + // 查询范围 + ToolScopePublic = "public" + ToolScopeFavorites = "favorites" + ToolScopeCustom = "custom" + + toolMaxPageSize = 80 + toolMaxPromptLength = 8000 +) + +var validToolTypes = map[string]struct{}{ + ToolTypeStyle: {}, + ToolTypeMotion: {}, + ToolTypeNineGrid: {}, + ToolTypeEffect: {}, +} + +// ToolListRequest 工具列表查询参数。 +type ToolListRequest struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Scope string `json:"scope"` // public | favorites | custom + Type string `json:"type"` // style | motion | nine_grid | effect + Tag string `json:"tag"` + Search string `json:"search"` +} + +// ToolItem 工具列表项;ExtraInfo 为样本图路径数组。 +type ToolItem struct { + ID int64 `json:"id"` + Type string `json:"type"` + LabelEn string `json:"labelEn"` + Label string `json:"label"` + Desc string `json:"desc"` + Tag string `json:"tag"` + Cover string `json:"cover"` + ExtraInfo []string `json:"extraInfo"` + Prompt string `json:"prompt"` + Ratio string `json:"ratio"` + MediaURL string `json:"mediaUrl"` + OwnerID string `json:"ownerId"` + Source string `json:"source"` + Enabled bool `json:"enabled"` + Visibility string `json:"visibility"` + SortWeight int `json:"sortWeight"` + Favorited bool `json:"favorited"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ToolSummary 工具列表摘要项;不含 prompt、extraInfo 等大字段,详情/收藏结果才返回完整 ToolItem。 +type ToolSummary struct { + ID int64 `json:"id"` + Type string `json:"type"` + LabelEn string `json:"labelEn"` + Label string `json:"label"` + Desc string `json:"desc"` + Tag string `json:"tag"` + Cover string `json:"cover"` + Ratio string `json:"ratio"` + MediaURL string `json:"mediaUrl"` + OwnerID string `json:"ownerId"` + Source string `json:"source"` + Enabled bool `json:"enabled"` + Visibility string `json:"visibility"` + SortWeight int `json:"sortWeight"` + Favorited bool `json:"favorited"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ToolList 分页结果。 +type ToolList struct { + Tools []ToolSummary `json:"tools"` + TotalCount int64 `json:"totalCount"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + HasMore bool `json:"hasMore"` +} + +// ToolMutationRequest 新增/更新自定义工具的请求体。 +type ToolMutationRequest struct { + Type string `json:"type"` + Label string `json:"label"` + Desc string `json:"desc"` + Tag string `json:"tag"` + Cover string `json:"cover"` + ExtraInfo []string `json:"extraInfo"` + Prompt string `json:"prompt"` + Ratio string `json:"ratio"` + MediaURL string `json:"mediaUrl"` + Visibility string `json:"visibility"` +} + +// Service 工具域服务。 +type Service struct { + repo Repository +} + +func New(repo Repository) *Service { + return &Service{repo: repo} +} + +// List 按范围分页查询工具列表。 +func (s *Service) List(userID string, req ToolListRequest) (*ToolList, error) { + if strings.TrimSpace(userID) == "" { + return nil, kernel.Unauthorized("请先登录") + } + if err := normalizeToolListRequest(&req); err != nil { + return nil, err + } + items, total, err := s.repo.ListTools(userID, req) + if err != nil { + return nil, err + } + tools := make([]ToolSummary, 0, len(items)) + for _, item := range items { + tools = append(tools, buildToolSummary(item.Tool, item.Favorited)) + } + return &ToolList{ + Tools: tools, + TotalCount: total, + Page: req.Page, + PageSize: req.PageSize, + HasMore: int64(req.Page*req.PageSize) < total, + }, nil +} + +// Detail 返回单个工具详情(含提示词),供工具栏快捷调用。 +func (s *Service) Detail(userID string, toolID int64) (*ToolItem, error) { + if userID == "" { + return nil, kernel.Unauthorized("请先登录") + } + if toolID <= 0 { + return nil, kernel.BadAuthRequest("工具 ID 无效") + } + tool, err := s.repo.ToolForUser(userID, toolID) + if err != nil { + return nil, err + } + return buildToolItemPtr(tool, false, nil), nil +} + +// SetFavorite 添加/取消收藏;同一用户对同一工具仅一条记录。 +func (s *Service) SetFavorite(userID string, toolID int64, favorite bool) (*ToolItem, error) { + if userID == "" { + return nil, kernel.Unauthorized("请先登录") + } + if toolID <= 0 { + return nil, kernel.BadAuthRequest("工具 ID 无效") + } + if _, err := s.repo.ToolForUser(userID, toolID); err != nil { + return nil, err + } + if favorite { + if err := s.repo.AddToolFavorite(userID, toolID); err != nil { + return nil, err + } + } else { + if err := s.repo.RemoveToolFavorite(userID, toolID); err != nil { + return nil, err + } + } + updated, favoritedAt, err := s.repo.ToolFavorited(userID, toolID) + if err != nil { + return nil, err + } + return buildToolItemPtr(updated, true, favoritedAt), nil +} + +// Create 创建用户自定义工具。 +func (s *Service) Create(userID string, req ToolMutationRequest) (*ToolItem, error) { + if userID == "" { + return nil, kernel.Unauthorized("请先登录") + } + normalized, err := normalizeToolMutationRequest(req) + if err != nil { + return nil, err + } + now := time.Now() + tool := model.Tool{ + Type: normalized.Type, + LabelEn: normalized.LabelEn, + Label: normalized.Label, + Desc: normalized.Desc, + Tag: normalized.Tag, + Cover: normalized.Cover, + ExtraInfoJSON: normalized.ExtraInfoJSON, + Prompt: normalized.Prompt, + Ratio: normalized.Ratio, + MediaURL: normalized.MediaURL, + OwnerID: userID, + Source: ToolSourceUser, + Enabled: true, + Visibility: normalized.Visibility, + SortWeight: 0, + CreatedAt: now, + UpdatedAt: now, + } + created, err := s.repo.CreateTool(&tool) + if err != nil { + return nil, err + } + item := buildToolItem(*created, false, nil) + return &item, nil +} + +// Delete 删除用户自定义工具;内置工具不可删除,同步清理收藏记录。 +func (s *Service) Delete(userID string, toolID int64) error { + if userID == "" { + return kernel.Unauthorized("请先登录") + } + if toolID <= 0 { + return kernel.BadAuthRequest("工具 ID 无效") + } + return s.repo.DeleteUserTool(userID, toolID) +} + +func normalizeToolListRequest(req *ToolListRequest) error { + if req.Scope == "" { + req.Scope = ToolScopePublic + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + if req.PageSize > toolMaxPageSize { + req.PageSize = toolMaxPageSize + } + switch req.Scope { + case ToolScopePublic, ToolScopeFavorites, ToolScopeCustom: + default: + return kernel.BadAuthRequest(fmt.Sprintf("不支持的范围: %s", req.Scope)) + } + if req.Type != "" { + if _, ok := validToolTypes[req.Type]; !ok { + return kernel.BadAuthRequest(fmt.Sprintf("不支持的工具类型: %s", req.Type)) + } + } + req.Search = strings.TrimSpace(req.Search) + req.Tag = strings.TrimSpace(req.Tag) + return nil +} + +func normalizeToolMutationRequest(req ToolMutationRequest) (*model.Tool, error) { + toolType := strings.TrimSpace(req.Type) + if _, ok := validToolTypes[toolType]; !ok { + return nil, kernel.BadAuthRequest("工具类型无效,仅支持 style、motion、nine_grid、effect") + } + label := strings.TrimSpace(req.Label) + if label == "" { + return nil, kernel.BadAuthRequest("工具名称不能为空") + } + if len(label) > 120 { + return nil, kernel.BadAuthRequest("工具名称过长") + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return nil, kernel.BadAuthRequest("工具提示词不能为空") + } + if strings.Contains(prompt, "@[tool:") { + return nil, kernel.BadAuthRequest("不支持嵌套工具标签") + } + if len(prompt) > toolMaxPromptLength { + return nil, kernel.BadAuthRequest("工具提示词过长") + } + desc := strings.TrimSpace(req.Desc) + if len(desc) > 500 { + return nil, kernel.BadAuthRequest("工具描述过长") + } + tag := strings.TrimSpace(req.Tag) + if len(tag) > 64 { + return nil, kernel.BadAuthRequest("标签过长") + } + visibility := strings.TrimSpace(req.Visibility) + if visibility == "" { + visibility = ToolVisibilityPrivate + } + if visibility != ToolVisibilityPublic && visibility != ToolVisibilityPrivate { + return nil, kernel.BadAuthRequest("可见性仅支持 public 或 private") + } + cover := strings.TrimSpace(req.Cover) + if len(cover) > 500 { + return nil, kernel.BadAuthRequest("封面路径过长") + } + mediaURL := strings.TrimSpace(req.MediaURL) + if len(mediaURL) > 500 { + return nil, kernel.BadAuthRequest("媒体路径过长") + } + ratio := strings.TrimSpace(req.Ratio) + if len(ratio) > 32 { + return nil, kernel.BadAuthRequest("比例参数过长") + } + extraInfo := make([]string, 0, len(req.ExtraInfo)) + for _, item := range req.ExtraInfo { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if len(trimmed) > 500 { + return nil, kernel.BadAuthRequest("扩展信息路径过长") + } + extraInfo = append(extraInfo, trimmed) + } + extraInfoJSON := "" + if len(extraInfo) > 0 { + data, err := marshalExtraInfo(extraInfo) + if err != nil { + return nil, kernel.BadAuthRequest("扩展信息格式无效") + } + extraInfoJSON = string(data) + } + labelEn := sanitizeLabelEn(label) + return &model.Tool{ + Type: toolType, + LabelEn: labelEn, + Label: label, + Desc: desc, + Tag: tag, + Cover: cover, + ExtraInfoJSON: extraInfoJSON, + Prompt: prompt, + Ratio: ratio, + MediaURL: mediaURL, + Visibility: visibility, + }, nil +} + +func buildToolSummary(tool model.Tool, favorited bool) ToolSummary { + return ToolSummary{ + ID: tool.ID, + Type: tool.Type, + LabelEn: tool.LabelEn, + Label: tool.Label, + Desc: tool.Desc, + Tag: tool.Tag, + Cover: tool.Cover, + Ratio: tool.Ratio, + MediaURL: tool.MediaURL, + OwnerID: tool.OwnerID, + Source: tool.Source, + Enabled: tool.Enabled, + Visibility: tool.Visibility, + SortWeight: tool.SortWeight, + Favorited: favorited, + CreatedAt: tool.CreatedAt, + UpdatedAt: tool.UpdatedAt, + } +} + +func buildToolItem(tool model.Tool, favorited bool, favoritedAt *time.Time) ToolItem { + return ToolItem{ + ID: tool.ID, + Type: tool.Type, + LabelEn: tool.LabelEn, + Label: tool.Label, + Desc: tool.Desc, + Tag: tool.Tag, + Cover: tool.Cover, + ExtraInfo: decodeExtraInfo(tool.ExtraInfoJSON), + Prompt: tool.Prompt, + Ratio: tool.Ratio, + MediaURL: tool.MediaURL, + OwnerID: tool.OwnerID, + Source: tool.Source, + Enabled: tool.Enabled, + Visibility: tool.Visibility, + SortWeight: tool.SortWeight, + Favorited: favorited, + CreatedAt: tool.CreatedAt, + UpdatedAt: tool.UpdatedAt, + } +} + +func buildToolItemPtr(tool model.Tool, favorited bool, favoritedAt *time.Time) *ToolItem { + item := buildToolItem(tool, favorited, favoritedAt) + return &item +} + +// IsToolNotFound 判断是否为工具不存在(含他人私有工具不可见)。 +func IsToolNotFound(err error) bool { + var appErr *kernel.AppError + if errors.As(err, &appErr) { + return appErr.Status == 404 + } + return false +} diff --git a/backend/internal/tools/tools_integration_test.go b/backend/internal/tools/tools_integration_test.go new file mode 100644 index 000000000..d7ea30437 --- /dev/null +++ b/backend/internal/tools/tools_integration_test.go @@ -0,0 +1,100 @@ +package tools_test + +import ( + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/tools" + "path/filepath" + "strings" + "testing" +) + +func TestToolsVisibilityTokensSeedAndReferences(t *testing.T) { + db, err := database.Open(database.Config{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "tools.db")}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + if err = database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + repo := repository.New(db) + svc := tools.New(repo) + for range 2 { + if err := tools.EnsureBuiltinTools(repo); err != nil { + t.Fatal(err) + } + } + list, err := svc.List("alice", tools.ToolListRequest{PageSize: 80}) + if err != nil || list.TotalCount != 87 || !list.HasMore { + t.Fatalf("seed/pagination: %+v %v", list, err) + } + custom, err := svc.Create("alice", tools.ToolMutationRequest{Type: "style", Label: "private", Prompt: "private style"}) + if err != nil || custom.ID <= 87 { + t.Fatalf("custom: %+v %v", custom, err) + } + if _, err := svc.Detail("bob", custom.ID); err == nil { + t.Fatal("private tool leaked") + } + if _, err := svc.SetFavorite("bob", custom.ID, true); err == nil { + t.Fatal("private favorite accepted") + } + if err := svc.Delete("bob", custom.ID); err == nil { + t.Fatal("cross-user delete accepted") + } + if _, err := svc.SetFavorite("alice", custom.ID, true); err != nil { + t.Fatal(err) + } + if _, err := svc.SetFavorite("alice", custom.ID, true); err != nil { + t.Fatal(err) + } + for _, scope := range []string{"", "public", "favorites", "custom"} { + got, err := svc.List("bob", tools.ToolListRequest{Scope: scope, Search: "private"}) + if err != nil || got.TotalCount != 0 { + t.Fatalf("scope %q leaked: %+v %v", scope, got, err) + } + } + valid := "scene @[tool:style:1:label:Palette]" + expanded, err := svc.ResolveToolMentionTokens("alice", "image", valid) + if err != nil || strings.Contains(expanded, "@[tool:") { + t.Fatalf("expand: %s %v", expanded, err) + } + for _, token := range []string{"@[tool:style:0:x:X]", "@[tool:effect:1:x:X]", "@[tool:style:999999:x:X]", "@[tool:bogus:1:x:X]", "@[tool:style:1:broken]"} { + if _, err := svc.ResolveToolMentionTokens("alice", "image", token); err == nil { + t.Fatalf("accepted %s", token) + } + } + if _, err := svc.ResolveToolMentionTokens("alice", "video", valid); err == nil { + t.Fatal("wrong mode accepted") + } + if _, err := svc.ResolveToolMentionTokens("", "image", valid); err == nil { + t.Fatal("anonymous accepted") + } + if err := db.Model(&model.Tool{}).Where("id = ?", 1).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + if _, err := svc.ResolveToolMentionTokens("alice", "image", valid); err == nil { + t.Fatal("disabled accepted") + } + if err := svc.Delete("alice", custom.ID); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.ToolFavorite{}).Where("tool_id = ?", custom.ID).Count(&count) + if count != 0 { + t.Fatal("orphan favorites") + } + if err := db.Model(&model.Tool{}).Where("id = ?", 1).Updates(map[string]any{"source": "user", "owner_id": "alice", "label": "keep"}).Error; err != nil { + t.Fatal(err) + } + if err := tools.EnsureBuiltinTools(repo); err == nil { + t.Fatal("seed collision accepted") + } + var preserved model.Tool + db.First(&preserved, 1) + if preserved.Label != "keep" { + t.Fatal("user tool overwritten") + } +} diff --git a/backend/internal/tools/tools_seed.go b/backend/internal/tools/tools_seed.go new file mode 100644 index 000000000..493413971 --- /dev/null +++ b/backend/internal/tools/tools_seed.go @@ -0,0 +1,163 @@ +package tools + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +//go:embed seed/tools.json +var builtinToolsJSON []byte + +type toolSeedItem struct { + ID int64 `json:"id"` + LabelEn string `json:"label_en"` + Label string `json:"label"` + Desc string `json:"desc"` + Tag string `json:"tag"` + Cover string `json:"cover"` + ExtraInfo []string `json:"extra_info"` + Prompt string `json:"prompt"` + Ratio string `json:"ratio"` + MediaURL string `json:"media_url"` + OwnerID string `json:"owner_id"` + Enabled bool `json:"enabled"` + Visibility string `json:"visibility"` + CreateAt string `json:"create_at"` + UpdateAt string `json:"update_at"` +} + +type toolSeedGroup struct { + Title string `json:"title"` + Tags map[string]string `json:"tags"` + List []toolSeedItem `json:"list"` +} + +type builtinToolsFile struct { + Style toolSeedGroup `json:"style"` + Motion toolSeedGroup `json:"motion"` + NineGrid toolSeedGroup `json:"nine_grid"` +} + +const ( + ToolTypeStyle = "style" + ToolTypeMotion = "motion" + ToolTypeNineGrid = "nine_grid" + ToolTypeEffect = "effect" +) + +const seedTimeLayout = "2006-01-02 15:04:05" + +// EnsureBuiltinTools 将预设工具列表幂等写入数据库;重复启动只更新内容字段,不删除已有数据。 +// 初始化不下载第三方媒体,避免启动依赖外部 CDN 或产生无归属资源。 +func EnsureBuiltinTools(repo Repository) error { + var file builtinToolsFile + if err := json.Unmarshal(builtinToolsJSON, &file); err != nil { + return fmt.Errorf("解析内置工具失败: %w", err) + } + + var tools []model.Tool + sortWeight := 0 + + groups := []struct { + typ string + group toolSeedGroup + }{ + {ToolTypeStyle, file.Style}, + {ToolTypeMotion, file.Motion}, + {ToolTypeNineGrid, file.NineGrid}, + } + + seen := make(map[int64]struct{}) + for _, g := range groups { + for _, item := range g.group.List { + if item.ID <= 0 { + return fmt.Errorf("内置工具 ID 无效: type=%s label_en=%q", g.typ, item.LabelEn) + } + if _, exists := seen[item.ID]; exists { + return fmt.Errorf("内置工具 ID 重复: %d", item.ID) + } + seen[item.ID] = struct{}{} + uniqueKey := fmt.Sprintf("%s:%d", g.typ, item.ID) + + labelEn := strings.TrimSpace(item.LabelEn) + if labelEn == "" { + return fmt.Errorf("内置工具英文标识为空: %s", uniqueKey) + } + label := strings.TrimSpace(item.Label) + if label == "" { + return fmt.Errorf("内置工具名称为空: %s", uniqueKey) + } + prompt := strings.TrimSpace(item.Prompt) + if prompt == "" { + return fmt.Errorf("内置工具提示词为空: %s", uniqueKey) + } + visibility := strings.TrimSpace(item.Visibility) + if visibility != "public" && visibility != "private" { + return fmt.Errorf("内置工具可见性无效: %s visibility=%q", uniqueKey, item.Visibility) + } + + createdAt, err := parseSeedTime(item.CreateAt) + if err != nil { + return fmt.Errorf("内置工具创建时间无效: %s: %w", uniqueKey, err) + } + updatedAt, err := parseSeedTime(item.UpdateAt) + if err != nil { + return fmt.Errorf("内置工具更新时间无效: %s: %w", uniqueKey, err) + } + + extraInfoJSON := "" + if len(item.ExtraInfo) > 0 { + data, err := json.Marshal(item.ExtraInfo) + if err != nil { + return fmt.Errorf("序列化工具 %s 扩展信息失败: %w", uniqueKey, err) + } + extraInfoJSON = string(data) + } + + cover := strings.TrimSpace(item.Cover) + mediaURL := strings.TrimSpace(item.MediaURL) + sortWeight++ + tools = append(tools, model.Tool{ + ID: item.ID, + Type: g.typ, + LabelEn: labelEn, + Label: label, + Desc: strings.TrimSpace(item.Desc), + Tag: strings.TrimSpace(item.Tag), + Cover: cover, + ExtraInfoJSON: extraInfoJSON, + Prompt: prompt, + Ratio: strings.TrimSpace(item.Ratio), + MediaURL: mediaURL, + OwnerID: item.OwnerID, + Source: ToolSourceBuiltin, + Enabled: item.Enabled, + Visibility: visibility, + SortWeight: sortWeight, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + } + + if len(tools) == 0 { + return fmt.Errorf("内置工具列表为空") + } + if err := repo.UpsertBuiltinTools(tools); err != nil { + return fmt.Errorf("同步内置工具失败: %w", err) + } + return nil +} + +func parseSeedTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("时间为空") + } + return time.ParseInLocation(seedTimeLayout, value, time.Local) +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index eb7e2c515..bdc50d6ae 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -254,3 +254,13 @@ schema v27 为 `channel_model_price_tiers` 和 `billing_orders` 添加 `cost_con 账号文件容量只统计 `ready` 资源,并按 Provider、Endpoint、Bucket、ObjectKey 对同一物理对象去重。上传中或失败记录不占用已存储容量。后台每小时检查资源生命周期:超过 1 小时的 `pending/failed` 记录,以及超过 24 小时仍没有任何素材、画布、任务或其他业务引用的 `ready` 记录,会在再次校验引用后删除数据库记录并写入 `resource_deletion_jobs`,由幂等 Worker 清理物理对象。 素材状态为 `archived` 时进入回收站。后台按“资源与请求策略”中的 `recycleBinRetentionDays` 定期扫描过期项;`0` 表示永久保留。自动清理与用户手动彻底删除复用同一 service 引用校验和 `resource_deletion_jobs` Outbox,仍被画布、项目、任务或其他业务记录引用的素材不会被删除。 + +## 画布生成工具 + +Schema 30 新增 `tools`,31 新增 `tool_favorites`;主分支已有 28(Agent 执行日志)、29(资源租约)编号和 checksum 不变。 + +- `tools`:自增 `id`、类型 `type`、名称 `label/label_en`、描述/标签、`prompt`、预览 `cover/media_url/extra_info_json`、比例、`owner_id`、`source`、`enabled`、`visibility`、排序权重和时间。 +- `tool_favorites`:`id`、`user_id`、`tool_id`、收藏时间;用户和工具联合唯一。删除自己的自定义工具时事务清理收藏,不删除预览文件。 +- 启动幂等装载 87 个预设(风格 45、运镜 33、九宫格 9),不下载第三方媒体。显式 ID 与用户工具冲突时启动报错,不覆盖用户数据;PostgreSQL 同步自增序列。 +- 工具预览纳入资源引用检查;自定义预览必须是当前用户已上传完成的资源。公开工具暂不接受私人预览资源。 +- SQLite → PostgreSQL 迁移包含上述两表。首次启动前备份测试数据;回退旧代码不能视为数据库降级,应使用备份恢复,不手工重写迁移记录。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 9da179474..19f15b0b3 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -133,3 +133,11 @@ HTTP 请求 -> handler -> service(稳定导入面 / 别名)-> app(组合 - HTTP 参数与响应只在 handler 处理,业务规则进入 service。 - 页面保留流程协调;可独立理解和测试的算法进入同业务域的 `lib`。 - 新增功能时只更新本页的主要入口,不增加逐文件清单。 + +## 画布工具链路 + +- `backend/internal/handler/tools.go`:登录态工具列表、详情、收藏、创建与删除。 +- `internal/service` 别名 → `internal/app/tools_bridge.go`(资源归属校验)→ `internal/tools`(可见性策略、输入合同、token 展开和内置种子)→ `internal/repository/tools.go`。 +- 任务入队 `internal/app/task_creation.go` 与生成执行 `internal/app/provider.go` 共用严格 token 解析。 +- 前端 `canvas-tool-presentation.ts` 共享轻量展示配置,`canvas-resource-references.ts` 统一 token;节点提示词与工具面板共用同一持久状态,不复制到 metadata 风格字段。 +- `canvas-workspace-panel.tsx` 提供常驻左侧菜单,按需挂载节点、资产、工具、任务和历史面板;资产面板复用素材分页服务与画布插入合同;主分支项目章节侧栏与工作区互斥。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 3641a0d95..9b77e9013 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -243,3 +243,17 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两 模型错误把已有字符串码放进 `reason`(如 `model_capability_not_supported`),`code` 仍为 400。 常量:后端 `service/error_codes.go`,前端 `web/src/services/api/error-codes.ts`。 + +## 画布工具 API + +以下接口均要求登录,使用标准业务信封: + +| 方法 | 路径 | 行为 | +| --- | --- | --- | +| GET | `/api/tools` | 分页列表;`scope=public/favorites/custom`,默认 public;可按 `type=style/motion/nine_grid/effect`、tag、search 筛选。page 从 1 开始,pageSize 默认 20、最大 80;返回 tools、totalCount、page、pageSize、hasMore | +| GET | `/api/tools/:id` | 可见且启用的工具详情,包含 prompt 和 extraInfo;列表不含这两个大字段 | +| POST | `/api/tools` | 新建自己的工具;type、label、prompt 必填,visibility 默认 private;预览仅支持本人已上传完成的资源,公开工具暂不接受私人预览 | +| POST / DELETE | `/api/tools/:id/favorite` | 幂等收藏 / 取消收藏,仅允许操作可见且启用工具 | +| DELETE | `/api/tools/:id` | 仅删除自己的用户工具,不能删除内置或他人工具 | + +生成提示词工具引用格式为 `@[tool:type:ID:label:icon]`,label 使用 URI 编码。名称和图标只用于展示,不作为授权依据。服务端在任务入队及执行前检查用户可见性、启用状态、工具类型和生成模式;无效、越权、嵌套工具或数据库错误必须失败,不能把未解析 token 发送给模型。风格和九宫格仅用于图片,运镜和特效仅用于视频。入队校验不冻结工具快照:排队期间删除工具将导致执行失败。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 9fdfc8ff8..ce52ddb88 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -400,3 +400,7 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph - `web/src/services/api/timeline-tasks.ts`:转写与渲染任务客户端。 - `backend/internal/service/task_timeline.go`、`task_render.go`、`timeline_render_plan.go`、`transcription_whisper.go`:后端转写、渲染与渲染计划任务。 - `backend/internal/repository/project_workbench_read.go`:项目概览指标聚合(含成片完成度计数)。 + +## 画布生成工具(待本地验收) + +画布支持风格、运镜、九宫格提示词工具及自定义特效工具,提供收藏和私有自定义工具。工具仅加入提示词或创建待生成子节点,选择工具不自动发起生成。画布左侧提供主页、折叠、节点、资产、任务和历史菜单,不再提供独立工具菜单,展开面板沿用主分支主题。资产支持分类、搜索、分页和直接插入,复用现有素材服务及插入合同;任务和历史独立,历史明确展示最近 30 条。图片节点直接展示风格和预设,不再展示提示词模板入口;其他节点的提示词模板保持不变。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index b1d4bcd10..ed083edb2 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -2467,3 +2467,33 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客 - [ ] 打开用户资料菜单,确认管理员后台(管理员账号)、设置、退出登录仍可用;退出失败时不静默成功。 - [ ] 390px 窄屏检查侧栏抽屉、资料菜单、顶栏积分入口和首页 Composer 无横向溢出。 - [ ] 真实登录态检查 `/`、`/tasks`、`/wallet` 关键路由及登录过期跳转。 + +## PR #547 本地选择性整合验收 + +以主分支 UI 为准移植功能,不整包合并 PR 页面。尚需真实浏览器和真实模型验收: + +- 左侧常驻主页、节点、资产、任务、历史菜单,已移除工具菜单,工作区面板默认折叠;菜单采用小字细线图标、加大纵向间距,展开/收起按钮置底。验证点击分类展开、再次点击收起、短窗口滚动、键盘焦点和明暗主题;与原有项目章节栏互斥,小屏抽屉、专注模式、历史版本预览无误,不能通过历史预览编辑当前画布。 +- 节点边框、主题和提示词面板原尺寸不变;图片节点直接展示风格 / 预设,移除提示词模板入口(包含放大编辑器和斜杠触发),其他节点模板入口保留;明暗主题、窄屏、浮层滚轮、键盘和点击外部关闭。 +- 风格选取/替换/清除、保存重载;节点风格优先于项目画风,清除后恢复继承。运镜可多个但不能重复;特效没有内置预设,可创建自定义工具。 +- 九宫格从已有图片创建独立子节点和引用连线,原图不变,不自动扣费或生成;用户明确点击生成后确认模型收到展开的提示词。 +- 节点内工具选取、收藏幂等、跨账号隔离、搜索分页、请求失败提示;自定义删除验证 API 权限。公开工具预览暂不接受私人上传资源;相对路径种子预览不显示,不在启动时下载。 +- 资产面板直接展示分类、名称/标签搜索、媒体类型、刷新及分页;使用现有素材服务与插入合同。验证文本、图片、视频、音频插入,entity/model 不应出现虚假的插入成功;管理素材 / 项目素材仍打开原有入口。 +- 任务列表明确展示最近 30 条,不是完整历史;进行中过滤普通生成任务,历史包含 Agent;刷新、取消、切换项目/账号后不串数据。 +- 工具被删除、停用、不可见、类型错误或标签损坏时,生成必须报错,不能静默忽略;排队后删除工具也应失败。 +- 使用备份的本地测试数据库验证 schema 29 → 31 和第二次启动幂等;PostgreSQL 序列、迁移工具实库验证待补。不要用生产数据库直接试。 + +自动检查不能替代以上真实页面/生成验收;本次不自动启动服务,不调用付费模型。左侧菜单补齐后尝试访问已有本地服务,内置浏览器停在登录页,未代填凭据,登录态视觉验收仍待进行。 + +本地自动验证记录:前端 8 个专项测试文件共 59 项通过,生产构建通过(仍有大分块提示);后端 tools、database、repository、迁移命令、handler 测试及工具权限、项目封面引用专项通过。初次后端全量测试未全绿:项目封面测试夹具缺少 tools 表,补齐后专项通过;资源删除 cancelled_output 用例出现 SQLite 锁表,单独复跑通过。修复后未重跑整套后端测试,不将其记录为全量通过。PostgreSQL 实库、真实浏览器和模型调用仍待验收。 + +侧栏补齐复测:前端生产构建通过,9 个专项文件共 63 项通过(含 4 项侧栏结构守护测试);源码守护不代表浏览器视觉验收通过。 + +侧栏配色对齐:左侧菜单直接复用画布工具条的 `canvasDockStyle` 和画布主题,统一背景、图标文字、悬停和选中态,保留默认折叠与纵向间距。侧栏与主题隔离专项共 10 项通过,生产构建通过(仍有大分块提示);明暗主题下与工具条的实际视觉一致性仍待登录态页面验收。 + +移除画布底部独立的「素材 + 数量」悬浮入口,保留左侧资产面板及原有缩放控件;待确认刷新后不再显示该按钮,左侧资产浏览与插入正常。 + +提示词模板清空回填修复:统一提示词写入器显式同步 `generationSpec.prompt`,避免旧合同覆盖清空或连续编辑后的草稿;已有媒体的原始生成提示词保持不变。新增回归测试先复现失败,修复后提示词、引用、生成引用及工具整合 4 个文件共 48 项通过,TypeScript 类型检查通过。待真实页面确认:图片节点选择模板 → 清空 → 点击画布取消聚焦 → 重新选择节点仍为空;再次修改及保存重载后内容正确。 + +入口精简与工具草稿复测:移除左侧工具菜单及图片节点提示词模板入口,保留图片风格 / 预设和其他节点模板入口。工具引用列表读取草稿改用空值判断,明确清空时不再从原始生成提示词回读旧标签。风格和预设的选择、替换、连续编辑、标签移除、全文清空及保存重载覆盖有图/空节点两种情况;5 个专项文件共 58 项通过,TypeScript 类型检查通过。实际页面仍需确认普通/放大编辑器均无图片提示词模板入口,以及风格和预设清空后重新聚焦不回填。 + +主分支提交前复测:前端 `bun run test` 全量 1925 项通过,生产构建、类型检查、UI 退场 lint 通过;构建仍有大分块提示。同步了技能/工具标签结构与多行连接菜单的源码守护断言。后端 `go test ./...` 全量通过,工具、迁移、资源引用、handler 及工具预览权限/项目封面专项通过。本轮未执行真实模型调用、PostgreSQL 实库迁移或登录态浏览器验收,这些项目继续保留待验收。 diff --git a/web/src/components/canvas/canvas-choose-effect-picker.tsx b/web/src/components/canvas/canvas-choose-effect-picker.tsx new file mode 100644 index 000000000..c7ec69ccf --- /dev/null +++ b/web/src/components/canvas/canvas-choose-effect-picker.tsx @@ -0,0 +1,181 @@ +import { useUserStore } from "@/stores/use-user-store"; +import { useEffect, useState } from "react"; +import { Dropdown } from "antd"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { LoaderCircle, Sparkles, Wrench, X, XCircle } from "lucide-react"; + +import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools"; +import { FEED_TABS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation"; + +const EFFECT_TOOL_PAGE_SIZE = 40; +const SEARCH_DEBOUNCE_MS = 250; + +export function CanvasChooseEffectPicker({ + open, + onOpenChange, + activeToolId, + activeLabel, + scope = "public", + onSelect, + onClear, +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** 当前提示词中 effect 工具标签的 toolId,未选择时为 undefined。 */ + activeToolId?: number; + /** 当前 effect 标签的 label 段,列表未加载或查不到时用于按钮回显。 */ + activeLabel?: string; + /** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */ + scope?: ToolScope; + onSelect: (toolId: number, label: string) => void; + /** 关闭已选特效:从提示词移除 effect 工具标签。 */ + onClear?: () => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchText, setSearchText] = useState(""); + const [scopeTab, setScopeTab] = useState(scope); + const actualOpen = open ?? internalOpen; + const setOpen = (next: boolean) => { + setInternalOpen(next); + onOpenChange?.(next); + }; + + useEffect(() => { + const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [searchInput]); + + const userId = useUserStore((s) => s.user?.id); + const toolsQuery = useInfiniteQuery({ + queryKey: ["canvas-tools", userId, "effect-picker", scopeTab, searchText], + queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: EFFECT_TOOL_PAGE_SIZE, scope: scopeTab, type: "effect", search: searchText || undefined }, { signal }), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), + enabled: actualOpen && Boolean(userId), + }); + const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? []; + const activeTool = tools.find((tool) => tool.id === activeToolId); + const resolvedLabel = activeTool?.label || activeLabel; + + const handleSelect = (tool: ToolSummary) => { + setOpen(false); + onSelect(tool.id, tool.label); + }; + + return ( + ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > +
+
+
+
+ {FEED_TABS.map((tab) => ( + + ))} +
+ setSearchInput(event.target.value)} aria-label="搜索特效" /> + {searchInput ? ( + + ) : null} +
+
+
+ {toolsQuery.isPending ? ( +
+ +
+ ) : toolsQuery.isError ? ( +
特效工具加载失败,请稍后重试
+ ) : tools.length === 0 ? ( +
{searchText ? "没有匹配的特效" : "暂无特效工具"}
+ ) : ( + tools.map((tool) => ) + )} +
+ {toolsQuery.hasNextPage && ( + + )} +
+
+ )} + > +
setOpen(true)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setOpen(true); + } + }} + onPointerDown={(event) => event.stopPropagation()} + > + + {resolvedLabel || "特效"} + {activeToolId != null && onClear ? ( + + ) : null} +
+
+ ); +} + +function EffectToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) { + const coverUrl = toAbsoluteUrl(tool.cover); + return ( + + ); +} diff --git a/web/src/components/canvas/canvas-choose-image-style-picker.tsx b/web/src/components/canvas/canvas-choose-image-style-picker.tsx new file mode 100644 index 000000000..1d7b4f351 --- /dev/null +++ b/web/src/components/canvas/canvas-choose-image-style-picker.tsx @@ -0,0 +1,192 @@ +import { useUserStore } from "@/stores/use-user-store"; +import { useEffect, useState } from "react"; +import { Dropdown } from "antd"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { LoaderCircle, Palette, Wrench, X } from "lucide-react"; + +import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools"; +import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation"; + +const STYLE_TOOL_PAGE_SIZE = 40; +const SEARCH_DEBOUNCE_MS = 250; + +export function CanvasChooseImageStylePicker({ + open, + onOpenChange, + activeToolId, + activeLabel, + scope = "public", + tag, + onSelect, + onClear, +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** 当前提示词中 style 工具标签的 toolId,未选择时为 undefined。 */ + activeToolId?: number; + /** 当前 style 标签的 label 段,列表未加载或查不到时用于按钮回显。 */ + activeLabel?: string; + /** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */ + scope?: ToolScope; + /** 标签过滤初始值,弹层内可切换。 */ + tag?: string; + onSelect: (toolId: number, label: string) => void; + /** 关闭已选风格:从提示词移除 style 工具标签。 */ + onClear?: () => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchText, setSearchText] = useState(""); + const [scopeTab, setScopeTab] = useState(scope); + const [tagFilter, setTagFilter] = useState(tag ?? ""); + const actualOpen = open ?? internalOpen; + const setOpen = (next: boolean) => { + setInternalOpen(next); + onOpenChange?.(next); + }; + + useEffect(() => { + const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [searchInput]); + + const userId = useUserStore((s) => s.user?.id); + const toolsQuery = useInfiniteQuery({ + queryKey: ["canvas-tools", userId, "style-picker", scopeTab, tagFilter, searchText], + queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: STYLE_TOOL_PAGE_SIZE, scope: scopeTab, type: "style", tag: tagFilter || undefined, search: searchText || undefined }, { signal }), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), + enabled: actualOpen && Boolean(userId), + }); + const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? []; + const activeTool = tools.find((tool) => tool.id === activeToolId); + const resolvedLabel = activeTool?.label || activeLabel; + + const handleSelect = (tool: ToolSummary) => { + setOpen(false); + onSelect(tool.id, tool.label); + }; + + return ( + ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > +
+
+
+
+ {FEED_TABS.map((tab) => ( + + ))} +
+ setSearchInput(event.target.value)} aria-label="搜索风格" /> + {searchInput ? ( + + ) : null} +
+
+ {[{ id: "", label: "全部" }, ...SUB_TAB_TAGS.style].map((chip) => ( + + ))} +
+
+
+ {toolsQuery.isPending ? ( +
+ +
+ ) : toolsQuery.isError ? ( +
风格工具加载失败,请稍后重试
+ ) : tools.length === 0 ? ( +
{searchText ? "没有匹配的风格" : "暂无风格工具"}
+ ) : ( + tools.map((tool) => ) + )} +
+ {toolsQuery.hasNextPage && ( + + )} +
+
+ )} + > +
setOpen(true)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setOpen(true); + } + }} + onPointerDown={(event) => event.stopPropagation()} + > + + {resolvedLabel || "风格"} + {activeToolId != null && onClear ? ( + + ) : null} +
+
+ ); +} + +function StyleToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) { + const coverUrl = toAbsoluteUrl(tool.cover); + return ( + + ); +} diff --git a/web/src/components/canvas/canvas-choose-motion-picker.tsx b/web/src/components/canvas/canvas-choose-motion-picker.tsx new file mode 100644 index 000000000..928f5b1f7 --- /dev/null +++ b/web/src/components/canvas/canvas-choose-motion-picker.tsx @@ -0,0 +1,212 @@ +import { useUserStore } from "@/stores/use-user-store"; +import { useEffect, useRef, useState } from "react"; +import { Dropdown } from "antd"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Camera, LoaderCircle, Wrench, X } from "lucide-react"; + +import { listTools, type ToolScope, type ToolSummary } from "@/services/api/tools"; +import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation"; + +const MOTION_TOOL_PAGE_SIZE = 40; +const SEARCH_DEBOUNCE_MS = 250; + +export function CanvasChooseMotionPicker({ + open, + onOpenChange, + activeToolIds, + activeLabel, + scope = "public", + tag, + onSelect, + onClear, +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** 当前提示词中所有 motion 工具标签的 toolId 列表,未选择时为空。 */ + activeToolIds?: number[]; + /** 当前 motion 标签的 label 段,列表未加载或查不到时用于按钮回显。 */ + activeLabel?: string; + /** 工具范围初始值(public/favorites/custom),弹层内可切换,语义与侧边栏工具面板一致。 */ + scope?: ToolScope; + /** 标签过滤初始值,弹层内可切换。 */ + tag?: string; + onSelect: (toolId: number, label: string) => void; + /** 关闭已选运镜:从提示词移除 motion 工具标签。 */ + onClear?: () => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchText, setSearchText] = useState(""); + const [scopeTab, setScopeTab] = useState(scope); + const [tagFilter, setTagFilter] = useState(tag ?? ""); + const actualOpen = open ?? internalOpen; + const setOpen = (next: boolean) => { + setInternalOpen(next); + onOpenChange?.(next); + }; + + useEffect(() => { + const timer = window.setTimeout(() => setSearchText(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [searchInput]); + + const userId = useUserStore((s) => s.user?.id); + const toolsQuery = useInfiniteQuery({ + queryKey: ["canvas-tools", userId, "motion-picker", scopeTab, tagFilter, searchText], + queryFn: ({ signal, pageParam }) => listTools({ page: pageParam, pageSize: MOTION_TOOL_PAGE_SIZE, scope: scopeTab, type: "motion", tag: tagFilter || undefined, search: searchText || undefined }, { signal }), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), + enabled: actualOpen && Boolean(userId), + }); + const tools = toolsQuery.data?.pages.flatMap((page) => page.tools) ?? []; + const activeIdSet = new Set(activeToolIds ?? []); + const activeTool = tools.find((tool) => activeIdSet.has(tool.id)); + const resolvedLabel = activeTool?.label || activeLabel; + + const handleSelect = (tool: ToolSummary) => { + setOpen(false); + onSelect(tool.id, tool.label); + }; + + return ( + ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > +
+
+
+
+ {FEED_TABS.map((tab) => ( + + ))} +
+ setSearchInput(event.target.value)} aria-label="搜索运镜" /> + {searchInput ? ( + + ) : null} +
+
+ {[{ id: "", label: "全部" }, ...SUB_TAB_TAGS.motion].map((chip) => ( + + ))} +
+
+
+ {toolsQuery.isPending ? ( +
+ +
+ ) : toolsQuery.isError ? ( +
运镜工具加载失败,请稍后重试
+ ) : tools.length === 0 ? ( +
{searchText ? "没有匹配的运镜" : "暂无运镜工具"}
+ ) : ( + tools.map((tool) => ) + )} +
+ {toolsQuery.hasNextPage && ( + + )} +
+
+ )} + > +
setOpen(true)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setOpen(true); + } + }} + onPointerDown={(event) => event.stopPropagation()} + > + + {resolvedLabel || "运镜"} + {activeIdSet.size > 0 && onClear ? ( + + ) : null} +
+
+ ); +} + +function MotionToolCard({ tool, selected, onSelect }: { tool: ToolSummary; selected: boolean; onSelect: (tool: ToolSummary) => void }) { + const coverUrl = toAbsoluteUrl(tool.cover); + const mediaUrl = toAbsoluteUrl(tool.mediaUrl); + const videoRef = useRef(null); + const [hovered, setHovered] = useState(false); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + if (hovered && mediaUrl) { + video.currentTime = 0; + void video.play().catch(() => {}); + } else { + video.pause(); + } + }, [hovered, mediaUrl]); + + return ( + + ); +} diff --git a/web/src/components/canvas/canvas-grid-split-picker.css b/web/src/components/canvas/canvas-grid-split-picker.css index 729f177f2..37f91f457 100644 --- a/web/src/components/canvas/canvas-grid-split-picker.css +++ b/web/src/components/canvas/canvas-grid-split-picker.css @@ -13,7 +13,7 @@ min-width: 13.5rem; overflow: hidden; border: 1px solid var(--workspace-overlay-border, var(--dock-border)); - border-radius: 8px; + border-radius: var(--r-md); background: var(--workspace-overlay-bg-strong, var(--dock-surface)); } @@ -73,6 +73,30 @@ height: 14px; } +/* 九宫格菜单按 section 分两列,缩短纵向长度 */ +.canvas-node-toolbar-menu-nine-grid .ant-dropdown-menu { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: var(--space-2); +} + +.canvas-node-toolbar-menu-nine-grid .ant-dropdown-menu-item-group-title { + white-space: nowrap; +} + +/* 风格工具弹层外壳:cover 卡片网格自定义渲染,视觉与 split 弹层一致; + 固定宽度避免 truncate 标签的 max-content 把弹层撑宽; + 背景对齐九宫格菜单:spatial 页用 --workspace-overlay-bg-strong(antd 菜单 + 被 body.app-spatial-overlays 全局覆盖为同一 token),画布编辑器等非 spatial + 页回退 --popover(皮肤注入,与 antd colorBgElevated 同源) */ +.canvas-node-toolbar-menu-style .canvas-node-toolbar-menu-stack { + width: min(40rem, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid var(--workspace-overlay-border, var(--dock-border)); + border-radius: var(--r-md); + background: var(--workspace-overlay-bg-strong, var(--popover)); +} + .canvas-grid-split-menu-item.is-open { background: var(--workspace-overlay-hover, color-mix(in srgb, var(--popover-foreground) 6%, var(--popover))); } diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index 475a9d664..9d56e9eca 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -1,10 +1,10 @@ -import type { ReactNode } from "react"; -import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Layers3, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling, WandSparkles } from "lucide-react"; +import { isValidElement, type ReactNode } from "react"; +import { Brush, Camera, Clapperboard, Contrast, Copy, FastForward, FileText, Globe2, Grid2x2, Grid3x3, Layers3, Lock, LockOpen, Maximize2, Package, PencilLine, PersonStanding, Crop, Rewind, ScanFace, SlidersHorizontal, Smile, Sun, Upload, Scaling, WandSparkles } from "lucide-react"; import type { CanvasNodeData } from "@/types/canvas"; import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry"; -type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view"; +type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view" | "multi_camera_nine_grid" | "story_pitch_four_grid" | "character_face_three_view" | "product_three_view" | "storyboard_25_grid" | "character_three_view_generation" | "cinematic_light_correction" | "image_projection_after_3s" | "image_projection_before_5s"; type ImageToolHandlers = { onUpload: (node: CanvasNodeData) => void; @@ -26,6 +26,7 @@ type ImageToolHandlers = { onViewImage: (node: CanvasNodeData) => void; onCopyPrompt: (node: CanvasNodeData) => void; onReversePrompt: (node: CanvasNodeData) => void; + onNineGrid: (node: CanvasNodeData, toolId: number, label: string, icon: string) => void; }; type ImageToolDefinition = { @@ -37,9 +38,25 @@ type ImageToolDefinition = { section?: string; description?: string; active?: (node: CanvasNodeData) => boolean; - run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void; + run: (node: CanvasNodeData, handlers: ImageToolHandlers, tool: ImageToolDefinition) => void; + /** 仅 group === "nine_grid" 时使用,对应后端工具 ID */ + toolId?: number; }; +/** lucide 组件把 PascalCase 图标名写入 displayName,从 icon 渲染结果反查图标名,未命中时回退 Grid3x3。 */ +function resolveToolIconName(tool: ImageToolDefinition, node: CanvasNodeData) { + const element = tool.icon(node); + const icon_type = isValidElement(element) ? element.type : null; + const name = icon_type && typeof icon_type !== "string" ? (icon_type as { displayName?: string }).displayName : undefined; + return name || "Grid3x3"; +} + +/** 九宫格工具共用行为:run 收到自身定义后直接取 toolId/label,图标名从 icon 反查。 */ +function nineGridRun(node: CanvasNodeData, handlers: ImageToolHandlers, tool: ImageToolDefinition) { + if (tool.toolId == null) return; + handlers.onNineGrid(node, tool.toolId, resolveToolText(tool.label, node), resolveToolIconName(tool, node)); +} + const imageToolDefinitions: ImageToolDefinition[] = [ { id: "copyPrompt", @@ -222,6 +239,106 @@ const imageToolDefinitions: ImageToolDefinition[] = [ order: 80, run: (node, handlers) => handlers.onPanorama(node), }, + // 九宫格工具组——对应后端 nine_grid 种子数据 + { + id: "multi_camera_nine_grid", + label: "多机位九宫格", + section: "宫格生成", + description: "生成 3x3 多机位联系表", + icon: () => , + group: "nine_grid", + order: 10, + toolId: 79, + run: nineGridRun, + }, + { + id: "story_pitch_four_grid", + label: "剧情推演四宫格", + section: "宫格生成", + description: "生成 2x2 剧情推演联系表", + icon: () => , + group: "nine_grid", + order: 20, + toolId: 80, + run: nineGridRun, + }, + { + id: "character_face_three_view", + label: "角色脸部三视图", + section: "设定图", + description: "生成 3x2 角色脸部联系表", + icon: () => , + group: "nine_grid", + order: 30, + toolId: 81, + run: nineGridRun, + }, + { + id: "product_three_view", + label: "产品三视图", + section: "设定图", + description: "生成 3x2 产品联系表", + icon: () => , + group: "nine_grid", + order: 40, + toolId: 82, + run: nineGridRun, + }, + { + id: "storyboard_25_grid", + label: "25宫格连贯分镜", + section: "宫格生成", + description: "生成 5x5 连贯分镜联系表", + icon: () => , + group: "nine_grid", + order: 50, + toolId: 83, + run: nineGridRun, + }, + { + id: "character_three_view_generation", + label: "角色三视图", + section: "设定图", + description: "生成 16:9 角色三视图联系表", + icon: () => , + group: "nine_grid", + order: 60, + toolId: 85, + run: nineGridRun, + }, + { + id: "cinematic_light_correction", + label: "电影级光影校正", + section: "光影", + description: "修正电影灯光,使场景更真实", + icon: () => , + group: "nine_grid", + order: 70, + toolId: 84, + run: nineGridRun, + }, + { + id: "image_projection_after_3s", + label: "画面推演-3秒后", + section: "画面推演", + description: "生成 3 秒后的画面帧", + icon: () => , + group: "nine_grid", + order: 80, + toolId: 86, + run: nineGridRun, + }, + { + id: "image_projection_before_5s", + label: "画面推演-5秒前", + section: "画面推演", + description: "生成 5 秒前的画面帧", + icon: () => , + group: "nine_grid", + order: 90, + toolId: 87, + run: nineGridRun, + }, ]; export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) { @@ -234,10 +351,34 @@ export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageTool section: tool.section, description: tool.description, active: tool.active?.(node), - onClick: () => tool.run(node, handlers), + onClick: () => tool.run(node, handlers, tool), })); } function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) { return typeof value === "function" ? value(node) : value; } + +export type NineGridMenuItem = { + id: string; + label: string; + section: string; + description: string; + toolId: number; + toolIconName: string; +}; + +/** 从 imageToolDefinitions 中筛选 nine_grid 组,返回九宫格菜单项数据 */ +export function getNineGridMenuItems(): NineGridMenuItem[] { + return imageToolDefinitions + .filter((tool) => tool.group === "nine_grid" && tool.toolId != null) + .sort((a, b) => a.order - b.order) + .map((tool) => ({ + id: tool.id, + label: typeof tool.label === "function" ? tool.label({} as CanvasNodeData) : tool.label, + section: tool.section || "常用操作", + description: tool.description || "", + toolId: tool.toolId!, + toolIconName: resolveToolIconName(tool, {} as CanvasNodeData), + })); +} diff --git a/web/src/components/canvas/canvas-nine-grid-picker.tsx b/web/src/components/canvas/canvas-nine-grid-picker.tsx new file mode 100644 index 000000000..1fc230bc6 --- /dev/null +++ b/web/src/components/canvas/canvas-nine-grid-picker.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { Dropdown, type MenuProps } from "antd"; +import { Grid3x3 } from "lucide-react"; + +import { TOOL_ICON_MAP } from "./canvas-resource-mention-textarea"; +import { getNineGridMenuItems } from "./canvas-image-toolbar-tools"; + +function NineGridToolIcon({ iconName, className = "size-3.5 shrink-0" }: { iconName: string; className?: string }) { + const Icon = TOOL_ICON_MAP[iconName] ?? Grid3x3; + return ; +} + +function buildNineGridMenuItems(onSelect: (toolId: number, label: string, icon: string) => void): MenuProps["items"] { + const items = getNineGridMenuItems(); + const sections = new Map(); + for (const tool of items) { + const list = sections.get(tool.section) || []; + list.push(tool); + sections.set(tool.section, list); + } + return [...sections].map(([section, entries]) => ({ + type: "group" as const, + key: section, + label: section, + children: entries.map((tool) => ({ + key: tool.id, + label: ( +
+ + + {tool.label} + + {tool.description ?
{tool.description}
: null} +
+ ), + onClick: () => { + onSelect(tool.toolId, tool.label, tool.toolIconName); + }, + })), + })); +} + +export function CanvasNineGridPicker({ + open, + onOpenChange, + icon = "Grid3x3", + onSelect, +}: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** 当前已选九宫格工具的 lucide 图标名,无选择时回退 Grid3x3。 */ + icon?: string; + onSelect: (toolId: number, label: string, icon: string) => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const actualOpen = open ?? internalOpen; + const setOpen = (next: boolean) => { + setInternalOpen(next); + onOpenChange?.(next); + }; + return ( + { + setOpen(false); + onSelect(toolId, label, toolIcon); + }), + }} + popupRender={(menu) => ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > +
{menu}
+
+ )} + > + +
+ ); +} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 34a23062e..cc8d934fa 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,7 +1,7 @@ import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { ArrowLeftRight, ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -23,10 +23,14 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; +import { CanvasNineGridPicker } from "./canvas-nine-grid-picker"; +import { CanvasChooseImageStylePicker } from "./canvas-choose-image-style-picker"; +import { CanvasChooseEffectPicker } from "./canvas-choose-effect-picker"; +import { CanvasChooseMotionPicker } from "./canvas-choose-motion-picker"; import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; -import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { applyToolMention, removeToolMentions, autoMentionCanvasResourceReferences, buildToolMentionReference, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, overwriteSameTypeToolMention, parseToolMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; @@ -79,6 +83,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); const simpleMode = workspaceMode === "simple"; const mode = defaultMode(node.type); + const showPromptTemplates = !simpleMode && mode !== "image"; node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); @@ -86,6 +91,14 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const [prompt, setPrompt] = useState(savedPrompt); const [presetOpen, setPresetOpen] = useState(false); const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); + const [nineGridOpen, setNineGridOpen] = useState(false); + const [expandedNineGridOpen, setExpandedNineGridOpen] = useState(false); + const [styleToolOpen, setStyleToolOpen] = useState(false); + const [expandedStyleToolOpen, setExpandedStyleToolOpen] = useState(false); + const [effectToolOpen, setEffectToolOpen] = useState(false); + const [expandedEffectToolOpen, setExpandedEffectToolOpen] = useState(false); + const [motionToolOpen, setMotionToolOpen] = useState(false); + const [expandedMotionToolOpen, setExpandedMotionToolOpen] = useState(false); const [expandedPromptOpen, setExpandedPromptOpen] = useState(false); const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null); const expandedModalRef = useRef(null); @@ -96,8 +109,22 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); const [autoLinkEnabled, setAutoLinkEnabled] = useState(true); const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId }); + const promptToolReferences = useMemo(() => parseToolMentionTokens(prompt).map(({toolId, label, type, icon}) => buildToolMentionReference(toolId, label, type, icon)), [prompt]); + const textareaReferences = useMemo(() => { + if (!promptToolReferences.length) return resolvedMentionReferences; + const existingIds = new Set(resolvedMentionReferences.map((r) => r.id)); + const extras = promptToolReferences.filter((r) => !existingIds.has(r.id)); + return extras.length ? [...resolvedMentionReferences, ...extras] : resolvedMentionReferences; + }, [resolvedMentionReferences, promptToolReferences]); + // 当前提示词持有的九宫格工具图标,用于触发按钮回显已选工具。 + const activeNineGridIcon = useMemo(() => parseToolMentionTokens(prompt).find((tool) => tool.type === "nine_grid")?.icon ?? "Grid3x3", [prompt]); + const activeStyleTool = parseToolMentionTokens(prompt).find(t => t.type === "style"); + const activeEffectTool = parseToolMentionTokens(prompt).find(t => t.type === "effect"); + // 当前提示词持有的运镜工具标签(可多个),用于运镜按钮回显与菜单高亮。 + const activeMotionTools = useMemo(() => parseToolMentionTokens(prompt).filter((tool) => tool.type === "motion"), [prompt]); + const activeMotionTool = activeMotionTools[0]; const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]); - const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill"); + const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill" && item.kind !== "tool"); const requirements: ModelRequirements = { capability: mode, input: { @@ -232,7 +259,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const updatePrompt = (value: string) => { setPrompt(value); onPromptChange(node.id, value); - if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { + if (showPromptTemplates && /(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { if (expandedPromptOpen) setExpandedPresetOpen(true); else setPresetOpen(true); } @@ -246,15 +273,19 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const insertPromptReference = (reference: CanvasResourceReference) => { const insertText = `${canvasResourceMentionToken(reference)} `; const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt); - if (pendingMentionMatch) { - const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, ""); - updatePrompt(prefix ? `${prefix} ${insertText}` : insertText); + const basePrompt = pendingMentionMatch ? prompt.slice(0, pendingMentionMatch.index) : prompt; + // 同类型工具标签(style/nine_grid/effect)唯一:已有旧标签时原位覆盖,不再追加。 + const overwrittenPrompt = overwriteSameTypeToolMention(basePrompt, reference); + if (overwrittenPrompt != null) { + updatePrompt(overwrittenPrompt.replace(/\s+$/, "")); return; } - const basePrompt = prompt.replace(/\s*$/, ""); - updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText); + const trimmedBase = basePrompt.replace(/\s*$/, ""); + updatePrompt(trimmedBase ? `${trimmedBase} ${insertText}` : insertText); }; + const removeMotionToolMention = () => updatePrompt(removeToolMentions(prompt, "motion")); + const submit = () => { const text = prompt.trim(); if (!text || isRunning) return false; @@ -265,6 +296,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const submitExpandedPrompt = () => { if (submit()) { setExpandedPresetOpen(false); + setExpandedNineGridOpen(false); setExpandedPromptOpen(false); } }; @@ -293,8 +325,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} )} -
- {!simpleMode ? : null} +
+ {!simpleMode && (mode === "image" || mode === "video") ?
e.stopPropagation()}> + {mode === "image" ? <> + updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} /> + updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} /> + : <> + updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} /> + t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} /> + } +
: null} + {showPromptTemplates ? : null} {canOptimizePrompt ? ( + ))} +
+
+
+ setInput(event.target.value)} /> +
+
+ {!userId ? ( +

登录后查看资产。

+ ) : query.isPending ? ( +

+ 正在加载资产… +

+ ) : null} + {query.isError ? ( +
+ 资产读取失败,已加载的内容仍保留。 +
+ ) : null} + {assets.map((asset) => { + const insertable = ["image", "video", "audio", "text"].includes(asset.kind); + const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); + return ( +
+
+ } /> +
+
+
+ {asset.title} +
+
+ {resourceStorageLabel("storageKey" in asset.data ? asset.data.storageKey : undefined)} + {asset.tags?.length ? ` · ${asset.tags[0]}` : ""} +
+
+
+ ); + })} + {query.isSuccess && !assets.length ? : null} + {query.hasNextPage ? ( + + ) : null} +
+
+ + {onProjectAssets ? ( + + ) : null} +
+ + ); +} diff --git a/web/src/components/canvas/canvas-workspace-history-panel.tsx b/web/src/components/canvas/canvas-workspace-history-panel.tsx new file mode 100644 index 000000000..6b95766fd --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-history-panel.tsx @@ -0,0 +1,35 @@ +import { History, RefreshCw } from "lucide-react"; + +import { WorkspaceState } from "@/components/layout/workspace-state"; +import type { GenerationTask } from "@/services/api/task-center"; +import { TaskListItem } from "./canvas-workspace-task-list-item"; + +// 历史面板:展示项目最近任务(含云端 Agent 根任务与步骤任务),与任务面板互不依赖。 +export function CanvasWorkspaceHistoryPanel({ tasks, refreshing, onRefresh, onCancelTask }: { tasks: GenerationTask[]; refreshing?: boolean; onRefresh?: () => void; onCancelTask?: (task: GenerationTask) => void }) { + return ( + <> +
+ + 历史 · 最近 30 条(含 Agent) + {tasks.length.toLocaleString("zh-CN")} + + + +
+ +
+ {tasks.length ? ( +
+ {tasks.map((task) => ( + + ))} +
+ ) : ( + + )} +
+ + ); +} diff --git a/web/src/components/canvas/canvas-workspace-node-list-panel.tsx b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx new file mode 100644 index 000000000..ecbb42e13 --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx @@ -0,0 +1,148 @@ +import { memo, useState } from "react"; +import { AudioLines, Clock3, FileText, Image, Layers, Pencil, Search, X } from "lucide-react"; + +import { WorkspaceState } from "@/components/layout/workspace-state"; +import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes } from "@/lib/canvas/canvas-node-search"; +import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview"; +import { getNodeListLabel } from "@/lib/canvas/node-registry"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { Video as CanvasVideoIcon } from "lucide-react"; + +export function CanvasWorkspaceNodeListPanel({ + nodes, + results, + query, + deferredQuery, + selectedNodeIds, + onQueryChange, + onFocus, +}: { + nodes: CanvasNodeData[]; + results: CanvasNodeData[]; + query: string; + deferredQuery: string; + selectedNodeIds: Set; + onQueryChange: (value: string) => void; + onFocus: (nodeId: string) => void; +}) { + return ( + <> +
+ + 画布节点 + {nodes.length.toLocaleString("zh-CN")} +
+
+ + {deferredQuery ?
找到 {results.length.toLocaleString("zh-CN")} 个节点
: null} +
+
+ {results.length ? ( + results.map((node) => onFocus(node.id)} />) + ) : ( + + )} +
+ + ); +} + +const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, active, onSelect }: { node: CanvasNodeData; active: boolean; onSelect: () => void }) { + const times = canvasNodeSearchTimes(node); + const materialSummary = canvasNodeMaterialSummary(node); + const context = canvasNodeSearchContext(node); + return ( + + ); +}); + +function CanvasNodeListThumbnail({ node }: { node: CanvasNodeData }) { + const [failed, setFailed] = useState(false); + const mediaSource = + node.type === CanvasNodeType.Video + ? canvasNodeVideoPreviewUrl(node) + : node.metadata?.drawingPreviewUrl || + node.metadata?.characterCoverUrl || + node.metadata?.folder?.themeCover || + (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade ? node.metadata?.content : undefined); + const commonClass = "h-9 w-11 rounded-[var(--r-sm)] border object-cover"; + const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" }; + + if (mediaSource && !failed) { + return setFailed(true)} />; + } + + const textPreview = node.metadata?.previewContent || node.metadata?.composerContent || node.metadata?.prompt || node.metadata?.content; + if (textPreview && (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script)) { + return ( + + ); + } + + return ( + + ); +} + +function fullTime(value?: string) { + if (!value) return "时间未记录"; + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index f7b558ce5..9bb404f03 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -48,7 +48,7 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre toolbarRef.current.classList.toggle("-translate-y-full", placement === "above"); return; } - setAnchor((current) => current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement }); + setAnchor((current) => (current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement })); }; update(); @@ -79,15 +79,45 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - - 已选 {count} + + + 已选 {count} +
{children}
); } -export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, allowOverflow = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; allowOverflow?: boolean; children: ReactNode }) { +export function CanvasNodePanelOverlay({ + node, + viewport, + containerRef, + panelWidth, + panelHeight = 190, + dragOffset, + isDragging = false, + allowOverflow = false, + children, +}: { + node: CanvasNodeData; + viewport: ViewportTransform; + containerRef: RefObject; + panelWidth?: number; + panelHeight?: number; + dragOffset?: Position | null; + isDragging?: boolean; + allowOverflow?: boolean; + children: ReactNode; +}) { const panelRef = useRef(null); const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)"); const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth); @@ -109,9 +139,7 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth); panel.style.width = `${nextWidth}px`; const nodeElement = container.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`); - const position = nodeElement - ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) - : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset); + const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset); panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`; }; update(viewport); @@ -154,7 +182,31 @@ function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920); } -export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub") => void; onClose: () => void }) { +export function CanvasConnectionCreateMenu({ + pending, + viewport, + viewportSize, + containerRef, + canCreateDrawing, + getDisabledReason, + onCreate, + onClose, +}: { + pending: PendingConnectionCreate; + viewport: ViewportTransform; + viewportSize: { width: number; height: number }; + containerRef: RefObject; + canCreateDrawing: boolean; + getDisabledReason: ( + type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, + provider?: "runninghub", + ) => string; + onCreate: ( + type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, + provider?: "runninghub", + ) => void; + onClose: () => void; +}) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); const menuRef = useRef(null); @@ -226,28 +278,137 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co onPointerDown={(event) => event.stopPropagation()} >
- } title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> - } title="批量创作表" description="汇总多张图片,批量执行换装或创意生图" disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} onClick={() => onCreate(CanvasNodeType.BatchTable)} /> - } title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> - {canCreateDrawing ? } title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} - } title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> - } title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} /> - } title="转换" description="本地处理图片或视频" disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} onClick={() => onCreate(CanvasNodeType.MediaConversion)} /> + } + title="文本生成" + description="引用当前内容,生成或改写文本" + disabledReason={getDisabledReason(CanvasNodeType.Text)} + onClick={() => onCreate(CanvasNodeType.Text)} + /> + } + title="分镜脚本" + description="根据剧情拆解镜头,编排分镜脚本" + disabledReason={getDisabledReason(CanvasNodeType.Script)} + onClick={() => onCreate(CanvasNodeType.Script)} + /> + } + title="批量创作表" + description="汇总多张图片,批量执行换装或创意生图" + disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} + onClick={() => onCreate(CanvasNodeType.BatchTable)} + /> + } + title="图片生成" + description="结合提示词和参考图,生成新的画面" + disabledReason={getDisabledReason(CanvasNodeType.Image)} + onClick={() => onCreate(CanvasNodeType.Image)} + /> + } + title="生成配置" + description="选择模型,或使用已启用的工作流插件" + disabledReason={getDisabledReason(CanvasNodeType.Config)} + onClick={() => onCreate(CanvasNodeType.Config)} + /> + {canCreateDrawing ? ( + } + title="绘图" + description="以参考图片为底图,自由绘制和标注" + disabledReason={getDisabledReason(CanvasNodeType.Drawing)} + onClick={() => onCreate(CanvasNodeType.Drawing)} + /> + ) : null} + } + title="视频生成" + description="结合提示词与参考素材,生成动态视频" + disabledReason={getDisabledReason(CanvasNodeType.Video)} + onClick={() => onCreate(CanvasNodeType.Video)} + /> + } + title="音频参考" + description="连接文本或角色卡,创建音频生成节点" + disabledReason={getDisabledReason(CanvasNodeType.Audio)} + onClick={() => onCreate(CanvasNodeType.Audio)} + /> + } + title="转换" + description="本地处理图片或视频" + disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} + onClick={() => onCreate(CanvasNodeType.MediaConversion)} + />
); } -function ConnectionCreateOption({ expanded, motionEnabled, icon, title, description, disabledReason, onClick }: { expanded: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; disabledReason?: string; onClick: () => void }) { +function ConnectionCreateOption({ + expanded, + motionEnabled, + icon, + title, + description, + disabledReason, + onClick, +}: { + expanded: boolean; + motionEnabled: boolean; + icon: ReactNode; + title: string; + description: string; + disabledReason?: string; + onClick: () => void; +}) { const theme = canvasThemes[useActiveTheme()]; return ( - ); diff --git a/web/src/components/canvas/canvas-workspace-panel.css b/web/src/components/canvas/canvas-workspace-panel.css new file mode 100644 index 000000000..52b637d73 --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-panel.css @@ -0,0 +1,131 @@ +.canvas-workspace-panel .sidebar-subtabs, +.canvas-workspace-panel .col-feed-tabs, +.canvas-node-toolbar-menu .col-feed-tabs, +.canvas-workspace-panel .col-tag-filter, +.canvas-node-toolbar-menu .col-tag-filter { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} +.canvas-workspace-panel .sidebar-subtabs button, +.canvas-workspace-panel .col-feed-tab, +.canvas-node-toolbar-menu .col-feed-tab, +.canvas-workspace-panel .col-tag-chip, +.canvas-node-toolbar-menu .col-tag-chip { + padding: var(--space-1) var(--space-2); + border-radius: var(--r-md); + font-size: var(--fs-caption); +} +.canvas-workspace-panel .sidebar-subtabs button.active, +.canvas-workspace-panel button.on, +.canvas-node-toolbar-menu button.on { + background: var(--surface-active); + color: var(--foreground); +} +.canvas-workspace-panel .asset-filter-row, +.canvas-node-toolbar-menu .asset-filter-row { + display: flex; + align-items: center; + gap: var(--space-1); +} +.canvas-workspace-panel .asset-search, +.canvas-node-toolbar-menu .asset-search { + min-width: 0; + width: 100%; + background: transparent; + font-size: var(--fs-caption); +} + +.canvas-node-tool-controls-inline .canvas-node-fixed-chip { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + height: var(--space-6); + max-width: 7rem; + padding: 0 var(--space-1); + border-radius: var(--r-md); + font-size: var(--fs-tiny); + font-weight: 500; +} +.canvas-node-tool-controls-inline .canvas-node-fixed-chip > svg { + width: 12px; + height: 12px; +} +.canvas-workspace-rail { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: var(--space-16); + min-height: 0; + padding: var(--space-4) var(--space-2); + gap: var(--space-3); + overflow-y: auto; + border-right: 1px solid var(--dock-border); + backdrop-filter: blur(var(--dock-blur)); +} +.canvas-workspace-rail-items { + display: flex; + flex-direction: column; + gap: var(--space-3); + flex-shrink: 0; +} +.canvas-workspace-rail-divider { + height: 1px; + margin-inline: var(--space-2); + background: var(--dock-tooltip-border); + flex-shrink: 0; +} +.canvas-workspace-rail .canvas-workspace-rail-button { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + min-height: var(--space-12); + width: 100%; + flex-shrink: 0; + border-radius: var(--r-lg); + font-size: var(--fs-tiny); + line-height: 1.4; + font-weight: 500; + color: inherit; + transition: + background-color var(--motion-instant) ease-out, + color var(--motion-instant) ease-out; +} +.canvas-workspace-rail svg { + width: var(--space-5); + height: var(--space-5); + stroke-width: 1.65; +} +.canvas-workspace-rail .canvas-workspace-rail-button:hover { + background: var(--dock-command-hover); +} +.canvas-workspace-rail .canvas-workspace-rail-button[aria-pressed="true"] { + background: var(--dock-command-active); + color: var(--dock-command-active-text); +} +.canvas-workspace-rail-button[aria-pressed="true"]::before { + content: ""; + position: absolute; + inset-inline-start: calc(-1 * var(--space-2)); + top: var(--space-4); + bottom: var(--space-4); + width: 2px; + border-radius: var(--r-full); + background: currentColor; +} +.canvas-workspace-rail .canvas-workspace-rail-toggle { + margin-top: auto; +} +.canvas-workspace-rail :focus-visible { + outline: 2px solid var(--dock-command-active-text); + outline-offset: -2px; +} +@media (prefers-reduced-motion: reduce) { + .canvas-workspace-rail .canvas-workspace-rail-button { + transition: none; + } +} diff --git a/web/src/components/canvas/canvas-workspace-panel.tsx b/web/src/components/canvas/canvas-workspace-panel.tsx new file mode 100644 index 000000000..b1b3de7dc --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-panel.tsx @@ -0,0 +1,145 @@ +import { Link } from "react-router"; +import { CanvasWorkspaceAssetPanel } from "./canvas-workspace-asset-panel"; +import { CanvasWorkspaceHistoryPanel } from "./canvas-workspace-history-panel"; +import type { InsertAssetPayload } from "./asset-picker-modal"; +import { useDeferredValue, useState } from "react"; +import { Button, Grid } from "antd"; +import { Home, PanelLeftClose, PanelLeftOpen, Layers, Images, ListChecks, History, X } from "lucide-react"; +import { AppDrawer } from "@/components/ui/product/app-drawer"; +import { searchCanvasNodes } from "@/lib/canvas/canvas-node-search"; +import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import type { CanvasNodeData } from "@/types/canvas"; +import type { GenerationTask } from "@/services/api/task-center"; +import { useCanvasWorkspaceTasks } from "@/pages/canvas/use-canvas-workspace-tasks"; +import { CanvasWorkspaceNodeListPanel } from "./canvas-workspace-node-list-panel"; +import { CanvasWorkspaceTaskPanel } from "./canvas-workspace-task-panel"; +import "./canvas-workspace-panel.css"; + +export function CanvasWorkspacePanel({ + projectId, + nodes, + selectedNodeIds, + onClose, + open, + onOpen, + onInsertAssets, + onFocus, + onAssets, + onProjectAssets, + onCancelTask, +}: { + projectId: string; + nodes: CanvasNodeData[]; + selectedNodeIds: Set; + onClose: () => void; + open: boolean; + onOpen: () => void; + onInsertAssets: (payloads: InsertAssetPayload[]) => Promise; + onFocus: (id: string) => void; + onAssets: () => void; + onProjectAssets?: () => void; + onCancelTask: (task: GenerationTask) => void; +}) { + const [tab, setTab] = useState("nodes"); + const theme = canvasThemes[useActiveTheme()]; + const [query, setQuery] = useState(""); + const history = tab === "history"; + const deferredQuery = useDeferredValue(query); + const screens = Grid.useBreakpoint(); + const tasks = useCanvasWorkspaceTasks(projectId, open && (tab === "tasks" || history)); + const content = ( +
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> +
+ 工作区 + +
+ {tab === "nodes" && ( + + )} + {tab === "assets" && } + {(tab === "tasks" || history) && ( + <> + {tasks.error ? ( +
+ 任务加载失败。 +
+ ) : tasks.loading ? ( +

正在加载任务…

+ ) : history ? ( + void tasks.refetch()} onCancelTask={onCancelTask} /> + ) : ( + t.status === "queued" || t.status === "running")} + refreshing={tasks.refreshing} + onRefresh={() => void tasks.refetch()} + onCancelTask={onCancelTask} + /> + )} + + )} +
+ ); + const entries = [ + { id: "nodes", label: "节点", icon: Layers }, + { id: "assets", label: "资产", icon: Images }, + { id: "tasks", label: "任务", icon: ListChecks }, + { id: "history", label: "历史", icon: History }, + ]; + return ( + <> + + {open && + (screens.lg ? ( + + ) : ( + + {content} + + ))} + + ); +} diff --git a/web/src/components/canvas/canvas-workspace-task-list-item.tsx b/web/src/components/canvas/canvas-workspace-task-list-item.tsx new file mode 100644 index 000000000..50d2d9956 --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-task-list-item.tsx @@ -0,0 +1,130 @@ +import { memo, useEffect, useState } from "react"; +import { CheckCircle2, ChevronDown, ChevronUp, Clock3, Coins, LoaderCircle, MoreHorizontal, XCircle } from "lucide-react"; +import { Dropdown, type MenuProps } from "antd"; + +import { formatCredits } from "@/constant/credits"; +import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display"; +import type { GenerationTask } from "@/services/api/task-center"; +import { useUserStore } from "@/stores/use-user-store"; + +// 任务列表项:任务面板与历史面板共用;进度、时长、计费与取消菜单都在这里渲染。 +export const TaskListItem = memo(function TaskListItem({ task, onCancelTask }: { task: GenerationTask; onCancelTask?: (task: GenerationTask) => void }) { + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); + const [expanded, setExpanded] = useState(false); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (task.status !== "queued" && task.status !== "running") return; + const timer = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, [task.status]); + + const showsProgress = generationTaskShowsProgress(task); + const progress = showsProgress && typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : undefined; + const startedAt = task.startedAt || task.createdAt; + const elapsedMs = Math.max(0, (task.completedAt ? parseTime(task.completedAt) : now) - parseTime(startedAt)); + const durationLabel = `${task.status === "queued" ? "已等待" : "已运行"} ${formatDuration(elapsedMs)}`; + const billingLabel = task.billing ? `${({ reserved: "冻结", running: "处理中", settled: "已结算", refunded: "已退还", uncertain: "待核对" } as const)[task.billing.status]} ${formatCredits(task.billing.amountMicrocredits)} 积分` : ""; + const isActive = task.status === "queued" || task.status === "running"; + const statusColor = task.status === "running" ? "var(--primary)" : task.status === "succeeded" ? "var(--success, #16a34a)" : task.status === "failed" ? "var(--danger, #dc2626)" : "color-mix(in srgb, var(--foreground) 40%, transparent)"; + + const menuItems: MenuProps["items"] = [...(isActive && onCancelTask ? [{ key: "cancel", danger: true, icon: , label: "取消任务", onClick: () => onCancelTask(task) }] : [])]; + + return ( +
+ + + {expanded ? ( +
+
+ 当前阶段 + {generationTaskStageLabel(task)} +
+ {task.model ? ( +
+ 模型 + {task.model} +
+ ) : null} + {task.prompt ? ( +
+ 提示词 + {task.prompt} +
+ ) : null} + {creditsEnabled && task.billing ? ( +
+ + + 计费 + + {billingLabel} +
+ ) : null} +
+ ) : null} + + {menuItems.length > 0 ? ( + + + + ) : null} +
+ ); +}); + +function parseTime(value?: string) { + if (!value) return Date.now(); + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : Date.now(); +} + +function formatDuration(value: number) { + const totalSeconds = Math.floor(value / 1_000); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} diff --git a/web/src/components/canvas/canvas-workspace-task-panel.tsx b/web/src/components/canvas/canvas-workspace-task-panel.tsx new file mode 100644 index 000000000..32f52e53b --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-task-panel.tsx @@ -0,0 +1,35 @@ +import { ListChecks, RefreshCw } from "lucide-react"; + +import { WorkspaceState } from "@/components/layout/workspace-state"; +import type { GenerationTask } from "@/services/api/task-center"; +import { TaskListItem } from "./canvas-workspace-task-list-item"; + +// 任务面板:展示用户直接发起的生成任务;云端 Agent 内部任务由历史面板承载。 +export function CanvasWorkspaceTaskPanel({ tasks, refreshing, onRefresh, onCancelTask }: { tasks: GenerationTask[]; refreshing?: boolean; onRefresh?: () => void; onCancelTask?: (task: GenerationTask) => void }) { + return ( + <> +
+ + 任务 + {tasks.length.toLocaleString("zh-CN")} + + + +
+ +
+ {tasks.length ? ( +
+ {tasks.map((task) => ( + + ))} +
+ ) : ( + + )} +
+ + ); +} diff --git a/web/src/components/canvas/canvas-workspace-tool-panel.tsx b/web/src/components/canvas/canvas-workspace-tool-panel.tsx new file mode 100644 index 000000000..6a3a8d3c8 --- /dev/null +++ b/web/src/components/canvas/canvas-workspace-tool-panel.tsx @@ -0,0 +1,581 @@ +import { useUserStore } from "@/stores/use-user-store"; +import { FEED_TABS, SUB_TAB_TAGS, toAbsoluteUrl } from "@/lib/canvas/canvas-tool-presentation"; +type FeedTab = ToolScope; +import { memo, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Eye, Loader2, MoreHorizontal, Plus, Star, Trash2, Upload, Wrench, X } from "lucide-react"; +import { App, Button, Dropdown, Form, Input, Radio, Select, Space, type MenuProps } from "antd"; + +import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview"; +import { AppModal } from "@/components/ui/product/app-modal"; +import { VideoPlayer } from "@/components/video-player"; +import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state"; +import { uploadMediaFile } from "@/services/file-storage"; +import { uploadImage } from "@/services/image-storage"; +import { createTool, deleteTool, listTools, setToolFavorite, type ToolScope, type ToolSummary, type ToolType, type ToolVisibility } from "@/services/api/tools"; + +type ToolSubTab = "style" | "effect" | "motion" | "nine_grid"; + +// 工具类型与后端一致。 +const TOOL_SUB_TABS: Array<{ id: ToolSubTab; type: ToolType; label: string }> = [ + { id: "style", type: "style", label: "风格" }, + { id: "nine_grid", type: "nine_grid", label: "九宫格" }, + { id: "effect", type: "effect", label: "特效" }, + { id: "motion", type: "motion", label: "运镜" }, +]; + +const TOOL_PAGE_SIZE = 40; +const TOOL_SEARCH_DEBOUNCE_MS = 250; + +const VIDEO_URL_RE = /\.(mp4|webm|mov|m4v)(?:$|[?#])/i; +// 上传到资源服务的文件 URL(/api/resources/:id/file)没有扩展名,按路径形态识别。 +const RESOURCE_FILE_URL_RE = /\/resources\/[^/?#]+\/file(?:[?#]|$)/i; + +function isVideoUrl(url: string) { + return VIDEO_URL_RE.test(url) || RESOURCE_FILE_URL_RE.test(url); +} + +// 悬停播放预览视频的工具类型:运镜、特效 +const HOVER_VIDEO_TYPES = new Set(["motion", "effect"]); + +type CreateToolFormValues = { + label: string; + tag?: string; + visibility: ToolVisibility; + desc?: string; + prompt: string; + cover?: string; + mediaUrl?: string; + ratio?: string; +}; + +export type CanvasWorkspaceToolPanelProps = { + onInsert?: (tool: ToolSummary) => void; +}; + +// 种子数据的 mediaUrl 多为相对路径(如 "period_idol/cover.webp"),无法直接预览; +// 绝对 http(s) URL 原样保留,站内资源路径(/api/resources/…)按当前 origin 绝对化。 + +function resolveToolPreview(tool: ToolSummary): { kind: "video" | "image"; src: string } | null { + const media = toAbsoluteUrl(tool.mediaUrl); + if (media && isVideoUrl(media)) return { kind: "video", src: media }; + const cover = toAbsoluteUrl(tool.cover) || media; + return cover ? { kind: "image", src: cover } : null; +} + +export function CanvasWorkspaceToolPanel({ onInsert }: CanvasWorkspaceToolPanelProps) { + const userId = useUserStore((s) => s.user?.id); + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); + const [subTab, setSubTab] = useState("style"); + const [feedTab, setFeedTab] = useState("public"); + const [tagFilter, setTagFilter] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [searchText, setSearchText] = useState(""); + const [previewTool, setPreviewTool] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm(); + const [coverUploading, setCoverUploading] = useState(false); + const [mediaUploading, setMediaUploading] = useState(false); + const coverInputRef = useRef(null); + const mediaInputRef = useRef(null); + + // 上传成功才回填表单;本地降级(pendingRemoteUpload)拿到的是页面级 objectURL, + // 刷新即失效,绝不能写进工具数据。 + async function handleCoverUpload(file: File) { + setCoverUploading(true); + try { + const image = await uploadImage(file); + if (image.pendingRemoteUpload) throw new Error(image.remoteUploadError || "图片暂存本机,尚未上传到云端,请稍后重试"); + createForm.setFieldValue("cover", toAbsoluteUrl(image.url)); + message.success("封面已上传"); + } catch (error) { + message.error(error instanceof Error ? error.message : "封面上传失败"); + } finally { + setCoverUploading(false); + } + } + + async function handleMediaUpload(file: File) { + setMediaUploading(true); + try { + const uploaded = await uploadMediaFile(file, "video"); + if (uploaded.pendingRemoteUpload) throw new Error(uploaded.remoteUploadError || "视频暂存本机,尚未上传到云端,请稍后重试"); + createForm.setFieldValue("mediaUrl", toAbsoluteUrl(uploaded.url)); + message.success("演示视频已上传"); + } catch (error) { + message.error(error instanceof Error ? error.message : "视频上传失败"); + } finally { + setMediaUploading(false); + } + } + + useEffect(() => { + const timer = window.setTimeout(() => setSearchText(searchInput.trim()), TOOL_SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [searchInput]); + + const activeType = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.type ?? "style"; + const subTabLabel = TOOL_SUB_TABS.find((tab) => tab.id === subTab)?.label || ""; + const feedTabLabel = FEED_TABS.find((tab) => tab.id === feedTab)?.label || ""; + + const tagChips = useMemo(() => { + const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : []; + return [{ id: "", label: "全部" }, ...tags]; + }, [subTab]); + + const tagOptions = useMemo(() => { + const tags = subTab === "style" ? SUB_TAB_TAGS.style : subTab === "motion" ? SUB_TAB_TAGS.motion : []; + return tags.map((tag) => ({ value: tag.id, label: tag.label })); + }, [subTab]); + + const toolsQuery = useInfiniteQuery({ + queryKey: ["canvas-tools", userId, feedTab, activeType, tagFilter, searchText], + queryFn: ({ pageParam, signal }) => + listTools( + { + page: pageParam as number, + pageSize: TOOL_PAGE_SIZE, + scope: feedTab, + type: activeType, + tag: tagFilter || undefined, + search: searchText || undefined, + }, + { signal }, + ), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined), + enabled: Boolean(userId), + }); + + const tools = useMemo(() => toolsQuery.data?.pages.flatMap((page) => page.tools) ?? [], [toolsQuery.data]); + const totalCount = toolsQuery.data?.pages[0]?.totalCount ?? 0; + + const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ["canvas-tools"] }); + + const favoriteMutation = useMutation({ + mutationFn: ({ id, favorite }: { id: number; favorite: boolean }) => setToolFavorite(id, favorite), + onSuccess: () => void invalidateTools(), + onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteTool, + onSuccess: () => { + void invalidateTools(); + message.success("工具已删除"); + }, + onError: (error) => message.error(error instanceof Error ? error.message : "工具删除失败"), + }); + + const createMutation = useMutation({ + mutationFn: (values: CreateToolFormValues) => + createTool({ + type: activeType, + label: values.label.trim(), + desc: values.desc?.trim() || undefined, + tag: values.tag?.trim() || undefined, + cover: values.cover?.trim() || undefined, + prompt: values.prompt.trim(), + ratio: values.ratio?.trim() || undefined, + mediaUrl: values.mediaUrl?.trim() || undefined, + visibility: values.visibility, + }), + onSuccess: () => { + message.success("工具已创建"); + setCreateOpen(false); + setFeedTab("custom"); + void invalidateTools(); + }, + onError: (error) => message.error(error instanceof Error ? error.message : "工具创建失败"), + }); + + function confirmDelete(tool: ToolSummary) { + modal.confirm({ + title: `删除“${tool.label}”?`, + content: "删除后不可恢复,该工具的收藏记录也会一同移除。", + okText: "删除", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: () => deleteMutation.mutateAsync(tool.id), + }); + } + + function handleScroll(event: UIEvent) { + const el = event.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight > 120) return; + if (toolsQuery.hasNextPage && !toolsQuery.isFetchingNextPage) void toolsQuery.fetchNextPage(); + } + + const emptyTitle = feedTab === "favorites" ? "还没有收藏工具" : feedTab === "custom" ? "还没有自定义工具" : "没有匹配工具"; + const emptyDescription = feedTab === "custom" ? "点击右上角“+”新建一个工具。" : "换一个分类或关键词继续搜索。"; + const preview = previewTool ? resolveToolPreview(previewTool) : null; + + return ( + <> +
+ + 工具 + {totalCount.toLocaleString("zh-CN")} +
+ +
+ {TOOL_SUB_TABS.map((tab) => ( + + ))} +
+ +
+
+ {FEED_TABS.map((tab) => ( + + ))} + + + +
+
+ setSearchInput(event.target.value)} aria-label="搜索工具" /> + {searchInput ? ( + + ) : null} +
+
+ {tagChips.map((tag) => ( + + ))} +
+
+ +
+ {toolsQuery.isLoading ? ( + + ) : toolsQuery.isError ? ( + void toolsQuery.refetch()} /> + ) : tools.length ? ( + <> +
+ {tools.map((tool) => ( + favoriteMutation.mutate({ id: item.id, favorite: !item.favorited })} + onDelete={confirmDelete} + /> + ))} +
+ {toolsQuery.isFetchingNextPage ? ( +
+ + 正在加载… +
+ ) : null} + + ) : ( + + )} +
+ + {previewTool && preview ? ( + preview.kind === "video" ? ( + setPreviewTool(null)} footer={null} width="min(1200px, calc(100vw - 32px))"> + + + ) : ( + setPreviewTool(null)} /> + ) + ) : null} + + setCreateOpen(false)} + onOk={() => createForm.submit()} + afterClose={() => createForm.resetFields()} + > + form={createForm} layout="vertical" requiredMark={false} initialValues={{ visibility: "private" }} onFinish={(values) => createMutation.mutate(values)} className="max-h-[72vh] overflow-y-auto p-6 px-8"> + + + +
+ + {tagOptions.length ? } + +

预览仅支持本人上传的资源;公开工具请移除私人预览。

+ + + +
+ + + + + + + + + + + + + + + {HOVER_VIDEO_TYPES.has(activeType) ? ( + + + + + + + + + ) : null} + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleCoverUpload(file); + }} + /> + {HOVER_VIDEO_TYPES.has(activeType) ? ( + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleMediaUpload(file); + }} + /> + ) : null} + {/* + + */} + +
+ + ); +} + +const ToolGridSkeleton = memo(function ToolGridSkeleton() { + return ( +
+ {Array.from({ length: 8 }, (_, index) => ( +
+
+
+
+ ))} +
+ ); +}); + +const ToolPresetCard = memo(function ToolPresetCard({ + tool, + feedTabLabel, + favoritePending, + onInsert, + onView, + onToggleFavorite, + onDelete, +}: { + tool: ToolSummary; + feedTabLabel: string; + favoritePending: boolean; + onInsert?: (tool: ToolSummary) => void; + onView?: (tool: ToolSummary) => void; + onToggleFavorite?: (tool: ToolSummary) => void; + onDelete?: (tool: ToolSummary) => void; +}) { + const [failed, setFailed] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [hovered, setHovered] = useState(false); + const [videoReady, setVideoReady] = useState(false); + const [videoFailed, setVideoFailed] = useState(false); + const rootRef = useRef(null); + const videoRef = useRef(null); + const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" }; + const coverUrl = toAbsoluteUrl(tool.cover); + const reduceMotion = useMemo(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches, []); + const mediaAbsoluteUrl = toAbsoluteUrl(tool.mediaUrl); + const hoverVideoUrl = mediaAbsoluteUrl && isVideoUrl(mediaAbsoluteUrl) ? mediaAbsoluteUrl : ""; + const showHoverVideo = !reduceMotion && HOVER_VIDEO_TYPES.has(tool.type) && Boolean(hoverVideoUrl) && !videoFailed; + + useEffect(() => { + if (!menuOpen) return; + const handler = (e: PointerEvent) => { + const target = e.target instanceof Element ? e.target : null; + if (target && !rootRef.current?.contains(target) && !target.closest(".ant-dropdown")) { + setMenuOpen(false); + } + }; + document.addEventListener("pointerdown", handler, true); + return () => document.removeEventListener("pointerdown", handler, true); + }, [menuOpen]); + + // 视频只在悬停时挂载;浏览器自动播放策略要求静音,React 首次渲染的 muted 属性不一定写入 DOM 属性,需手动赋值后再 play。 + useEffect(() => { + if (!hovered || !showHoverVideo) { + setVideoReady(false); + return; + } + const video = videoRef.current; + if (!video) return; + video.muted = true; + // 缓存命中时 canplay 可能已派发,直接按 readyState 标记可淡入。 + if (video.readyState >= 3) setVideoReady(true); + const handle = window.setTimeout(() => { + void video.play().catch(() => {}); + }, 0); + return () => window.clearTimeout(handle); + }, [hovered, showHoverVideo]); + + const menuItems: MenuProps["items"] = useMemo(() => { + const items: NonNullable = []; + items.push({ key: "insert", icon: , label: "插入画布", onClick: () => onInsert?.(tool) }); + items.push({ key: "view", icon: , label: "查看", onClick: () => onView?.(tool) }); + items.push({ type: "divider" as const }); + items.push({ + key: "favorite", + icon: , + label: tool.favorited ? "取消收藏" : "收藏", + onClick: () => onToggleFavorite?.(tool), + }); + if (tool.source === "user" && tool.ownerId === useUserStore.getState().user?.id) { + items.push({ type: "divider" as const }); + items.push({ key: "delete", danger: true, icon: , label: "删除", onClick: () => onDelete?.(tool) }); + } + return items; + }, [tool, onInsert, onView, onToggleFavorite, onDelete]); + + return ( +
{ + if (showHoverVideo) setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + onContextMenu={(e) => { + e.preventDefault(); + setMenuOpen(true); + }} + > +
+ {coverUrl && !failed ? ( + setFailed(true)} /> + ) : ( +
+ +
+ )} + {/* 悬停播放的预览视频:仅 hover 时挂载,静音/循环/内联满足各浏览器自动播放策略,首帧就绪后淡入覆盖封面 */} + {showHoverVideo && hovered ? ( +
+ {tool.label} + {tool.desc ? {tool.desc} : null} +
+ ); +}); diff --git a/web/src/lib/canvas/canvas-image-source.ts b/web/src/lib/canvas/canvas-image-source.ts index 101cae6c9..a2eeab26b 100644 --- a/web/src/lib/canvas/canvas-image-source.ts +++ b/web/src/lib/canvas/canvas-image-source.ts @@ -28,3 +28,18 @@ export function createPortraitTextureNode(source: CanvasNodeData, id: string): C }, }; } +export function createNineGridNode(source: CanvasNodeData, id: string, toolId: number, label: string, type: string, icon: string): CanvasNodeData { + return { + id, + type: CanvasNodeType.Image, + title: `${source.title || "图片"} · ${label}`, + position: { x: source.position.x + source.width + 96, y: source.position.y }, + width: source.width, + height: source.height, + metadata: { + prompt: `@[node:${source.id}] @[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`, + composerContent: `@[node:${source.id}] @[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`, + generationType: "edit", + }, + }; +} diff --git a/web/src/lib/canvas/canvas-node-prompt.test.ts b/web/src/lib/canvas/canvas-node-prompt.test.ts index 2ae2b2266..5cc6c4846 100644 --- a/web/src/lib/canvas/canvas-node-prompt.test.ts +++ b/web/src/lib/canvas/canvas-node-prompt.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import "./node-registry/definitions/index.ts"; // @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。 import { canvasNodeHasCommittedContent, writeCanvasNodePrompt } from "./canvas-node-prompt.ts"; +import { canonicalGenerationMetadata } from "./generation-contract"; // @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。 import { CanvasNodeType, type CanvasNodeData } from "../../types/canvas.ts"; @@ -49,6 +50,28 @@ test("空媒体草稿同时初始化提交提示词和编辑提示词", () => { assert.equal(metadata?.generationSpec?.prompt, "第一版提示词"); }); +test("模板提示词清空后重新读取和保存重载均保持为空", () => { + for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio]) { + for (const content of [undefined, `https://example.com/${type}`]) { + const current = node(type, { content, prompt: "已提交提示词" }); + const applied = writeCanvasNodePrompt(current, "模板预设文字"); + const cleared = writeCanvasNodePrompt(applied, ""); + assert.equal(cleared.metadata?.composerContent, ""); + assert.equal(cleared.metadata?.generationSpec?.prompt, ""); + assert.equal(cleared.metadata?.prompt, content ? "已提交提示词" : ""); + const reloaded = JSON.parse(JSON.stringify(cleared)) as CanvasNodeData; + assert.equal(canonicalGenerationMetadata(reloaded, type).composerContent, ""); + } + } +}); + +test("已有生成合同的提示词支持连续编辑而不回填旧模板", () => { + const applied = writeCanvasNodePrompt(node(CanvasNodeType.Image), "模板预设文字"); + const edited = writeCanvasNodePrompt(applied, "修改后的文字"); + assert.equal(edited.metadata?.generationSpec?.prompt, "修改后的文字"); + assert.equal(canonicalGenerationMetadata(edited, "image").composerContent, "修改后的文字"); +}); + test("图片仅保留 storageKey 时仍视为已有媒体结果", () => { const current = node(CanvasNodeType.Image, { storageKey: "canvas/image/result.png", diff --git a/web/src/lib/canvas/canvas-node-prompt.ts b/web/src/lib/canvas/canvas-node-prompt.ts index aa15065ab..4ac080414 100644 --- a/web/src/lib/canvas/canvas-node-prompt.ts +++ b/web/src/lib/canvas/canvas-node-prompt.ts @@ -29,10 +29,11 @@ export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string, opti const promptTemplateMetadata = clearPromptTemplate ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } : {}; - return synchronizeGenerationSpec({ - ...node, - metadata: canvasNodeHasCommittedContent(node) - ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } - : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt }, - }); + // 显式提交草稿补丁,否则已有 generationSpec 会把旧提示词投影回编辑框。 + return synchronizeGenerationSpec( + { ...node, metadata }, + canvasNodeHasCommittedContent(node) + ? { ...promptTemplateMetadata, composerContent: prompt } + : { ...promptTemplateMetadata, prompt, composerContent: prompt }, + ); } diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index fd6f0a53b..ef0d02431 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -7,7 +7,7 @@ import type { Skill } from "@/services/api/skills"; import type { Asset, AssetCategory } from "@/stores/use-asset-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas"; -export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character"; +export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character" | "tool"; export type CanvasResourceReference = { id: string; @@ -31,26 +31,89 @@ export type CanvasResourceReference = { assetId?: string; category?: AssetCategory; mentionToken?: string; + /** 仅 kind === "tool" 时使用,对应后端工具 ID。 */ + toolId?: number; + /** 仅 kind === "tool" 时使用,对应工具类型(style/nine_grid/effect/motion)。 */ + toolType?: string; + /** 仅 kind === "tool" 时使用,lucide 图标名称。 */ + toolIcon?: string; }; export function canvasSkillMentionToken(skillId: string) { return `@[skill:${skillId}]`; } +export function canvasToolMentionToken(toolId: number, label: string, type: string, icon: string) { + return `@[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`; +} + +const TOOL_REF_PATTERN = /@\[tool:(\w+):(\d+):([^:\]]+):([^\]]+)\]/g; + +/** 从文本中解析所有 @[tool:type:ID:label:icon] 令牌,返回去重的工具引用。 */ +export function parseToolMentionTokens(text: string): { type: string; toolId: number; label: string; icon: string }[] { + const results: { type: string; toolId: number; label: string; icon: string }[] = []; + const seen = new Set(); + for (const match of text.matchAll(TOOL_REF_PATTERN)) { + const toolId = Number(match[2]); + if (seen.has(toolId)) continue; + seen.add(toolId); + let label = match[3]; + try { label = decodeURIComponent(label); } catch { /* Keep readable text for malformed imported labels. */ } + results.push({ type: match[1], toolId, label, icon: match[4] }); + } + return results; +} + +export function removeToolMentions(prompt: string, type: string) { + return prompt.replace(new RegExp(`@\\[tool:${escapeRegExp(type)}:\\d+:[^\\]]*\\]`, "gu"), "").trim(); +} + +export function applyToolMention(prompt: string, tool: { id: number; type: string; label: string }, icon = "Wrench") { + const reference = buildToolMentionReference(tool.id, tool.label, tool.type, icon); + const replaced = overwriteSameTypeToolMention(prompt, reference); + if (replaced != null) return replaced; + if (parseToolMentionTokens(prompt).some((item) => item.type === tool.type && item.toolId === tool.id)) return prompt; + return [prompt.trim(), reference.mentionToken].filter(Boolean).join(" "); +} + export function canvasNodeMentionToken(nodeId: string) { return `@[node:${nodeId}]`; } +/** 这些工具类型的标签在提示词中唯一,再次选择时直接覆盖旧标签而不是追加。 */ +const UNIQUE_TOOL_MENTION_TYPES = new Set(["style", "nine_grid", "effect"]); + +/** + * 用新工具标签覆盖提示词中已有的同类型(style/nine_grid/effect)标签: + * 第一处原位替换,多余同类型标签连同其后一个空白一并清理。 + * 没有同类型旧标签时返回 null,由调用方走普通插入。 + */ +export function overwriteSameTypeToolMention(prompt: string, reference: CanvasResourceReference): string | null { + if (reference.kind !== "tool" || reference.toolId == null) return null; + const toolType = reference.toolType ?? "nine_grid"; + if (!UNIQUE_TOOL_MENTION_TYPES.has(toolType)) return null; + const token = canvasResourceMentionToken(reference); + const pattern = new RegExp(`@\\[tool:${escapeRegExp(toolType)}:\\d+:[^\\]]*\\]`, "gu"); + const segments = prompt.split(pattern); + if (segments.length === 1) return null; + let result = `${segments[0]}${token}${segments[1]}`; + for (let index = 2; index < segments.length; index += 1) { + result += segments[index].replace(/^\s/, ""); + } + return result; +} + export function canvasResourceMentionToken(reference: CanvasResourceReference) { if (reference.mentionToken) return reference.mentionToken; if (reference.kind === "skill" && reference.skill?.skillId) return canvasSkillMentionToken(reference.skill.skillId); + if (reference.kind === "tool" && reference.toolId != null) return canvasToolMentionToken(reference.toolId, reference.label, reference.toolType ?? "nine_grid", reference.toolIcon ?? "Grid3x3"); if (reference.assetId) return `@[asset:${reference.assetId}]`; return `@${reference.label}`; } export function normalizeCanvasNodeMentionTokens(prompt: string, references: CanvasResourceReference[]) { return references.reduce((value, reference) => { - if (!reference.nodeId || reference.assetId || reference.kind === "skill") return value; + if (!reference.nodeId || reference.assetId || reference.kind === "skill" || reference.kind === "tool") return value; return value.split(canvasNodeMentionToken(reference.nodeId)).join(`@${reference.label}`); }, prompt); } @@ -59,7 +122,7 @@ export function normalizeCanvasNodeMentionTokens(prompt: string, references: Can export function autoMentionCanvasResourceReferences(prompt: string, references: CanvasResourceReference[]) { const referenceByName = new Map(); references - .filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill") + .filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool") .forEach((reference, index) => { canvasResourceMentionNames(reference, index, true).forEach((name) => { if (!referenceByName.has(name)) { @@ -103,7 +166,7 @@ export function findCanvasResourceAutoLinkMatch(prompt: string, cursor: number, if (/@(?:\[[^\]]*|[^\s@,.;:!?,。;:!?、)\]}】)]*)$/u.test(prefix)) return null; const aliases = new Map(); references - .filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill") + .filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool") .forEach((reference, index) => { canvasResourceMentionNames(reference, index, true).forEach((name) => { if (!aliases.has(name)) aliases.set(name, reference); @@ -158,7 +221,7 @@ const CANVAS_RESOURCE_MENTION_BOUNDARY = /(?:$|\s|[,.!?;:,。!?;:、)\ function canvasResourceReferenceMentionTokens(reference: CanvasResourceReference) { const tokens = [canvasResourceMentionToken(reference), `@${reference.label}`]; - if (reference.nodeId && !reference.assetId && reference.kind !== "skill") tokens.push(canvasNodeMentionToken(reference.nodeId)); + if (reference.nodeId && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool") tokens.push(canvasNodeMentionToken(reference.nodeId)); return [...new Set(tokens.filter(Boolean))]; } @@ -207,7 +270,7 @@ export function applyCanvasConnectionPromptSync(previousNodes: CanvasNodeData[], } function canvasNodeBoundReferences(references: CanvasResourceReference[]) { - return references.filter((reference) => reference.nodeId && !reference.assetId && reference.kind !== "skill"); + return references.filter((reference) => reference.nodeId && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool"); } function canvasReferenceIdentityChanged(previousReferences: CanvasResourceReference[], nextReferences: CanvasResourceReference[]) { @@ -461,7 +524,7 @@ function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[] } function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) { - const counts: Record = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0 }; + const counts: Record = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0, tool: 0 }; let drawingCount = 0; return nodes.flatMap((node): CanvasResourceReference[] => { const kind = resourceKind(node); @@ -500,6 +563,7 @@ function labelForKind(kind: CanvasResourceKind, index: number) { if (kind === "video") return seedanceReferenceLabel("video", index); if (kind === "audio") return seedanceReferenceLabel("audio", index); if (kind === "skill") return `技能${index + 1}`; + if (kind === "tool") return `工具${index + 1}`; return `文本${index + 1}`; } @@ -518,3 +582,18 @@ function skillResourceText(node: CanvasNodeData) { if (!skill) return node.metadata?.content || ""; return [skill.name, skill.description, skill.template, skill.outputContract].filter(Boolean).join("\n\n"); } + +export function buildToolMentionReference(toolId: number, label: string, type: string, icon: string): CanvasResourceReference { + return { + id: `tool:${toolId}`, + nodeId: `tool:${toolId}`, + kind: "tool", + label, + title: label, + active: true, + toolId, + toolType: type, + toolIcon: icon, + mentionToken: canvasToolMentionToken(toolId, label, type, icon), + }; +} diff --git a/web/src/lib/canvas/canvas-style-execution.ts b/web/src/lib/canvas/canvas-style-execution.ts index e422eb57f..4e3796e12 100644 --- a/web/src/lib/canvas/canvas-style-execution.ts +++ b/web/src/lib/canvas/canvas-style-execution.ts @@ -1,6 +1,7 @@ import { applyStyleExecutionPlan, createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile, type StyleExecutionPlan, type StyleProfileSnapshot } from "@/lib/canvas/style-profile"; import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { CanvasNodeData } from "@/types/canvas"; +import { parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references"; export type CanvasStyleExecutionRuntime = { profile: StyleProfileSnapshot; @@ -10,6 +11,7 @@ export type CanvasStyleExecutionRuntime = { }; export function resolveCanvasStyleExecution(nodes: CanvasNodeData[], sourceNode: CanvasNodeData | undefined, prompt: string, config: AiConfig, mode: "image" | "video"): CanvasStyleExecutionRuntime | null { + if (mode === "image" && parseToolMentionTokens(prompt).some((tool) => tool.type === "style")) return null; const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard"); if (!styleNode || sourceNode?.metadata?.workflowKind === "styleboard") return null; const profile = parseStyleProfile(styleNode.metadata?.styleProfileJson) || legacyStyleNodeProfile(styleNode); diff --git a/web/src/lib/canvas/canvas-tool-presentation.ts b/web/src/lib/canvas/canvas-tool-presentation.ts new file mode 100644 index 000000000..c6ef9d532 --- /dev/null +++ b/web/src/lib/canvas/canvas-tool-presentation.ts @@ -0,0 +1,38 @@ +import type { ToolScope } from "@/services/api/tools"; +// 各子 Tab 的独立标签集,与 backend internal/tools/seed/tools.json 的 tags 对齐 +export const SUB_TAB_TAGS: Record<"style" | "motion", Array<{ id: string; label: string }>> = { + style: [ + { id: "period", label: "古装" }, + { id: "city", label: "都市" }, + { id: "decade", label: "年代" }, + { id: "life", label: "生活" }, + { id: "science_fiction", label: "科幻" }, + { id: "type", label: "类型" }, + { id: "poetic", label: "写意" }, + { id: "animation", label: "动画" }, + { id: "drawing", label: "绘画" }, + { id: "myth", label: "神话" }, + ], + motion: [ + { id: "basic", label: "基础控制" }, + { id: "follow", label: "人物跟拍" }, + { id: "reveal", label: "揭示转场" }, + { id: "emotion", label: "情绪强化" }, + { id: "aerial", label: "空间航拍" }, + ], +}; + +type FeedTab = ToolScope; + +export const FEED_TABS: Array<{ id: FeedTab; label: string }> = [ + { id: "public", label: "公共" }, + { id: "favorites", label: "收藏" }, + { id: "custom", label: "自定义" }, +]; + +export function toAbsoluteUrl(value?: string) { + const text = (value || "").trim(); + if (/^https?:\/\//i.test(text)) return text; + if (text.startsWith("/") && !text.startsWith("//")) return new URL(text, window.location.origin).href; + return ""; +} diff --git a/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx index 6b4b3f7c0..ea1a74fa7 100644 --- a/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/main-toolbar-tools.tsx @@ -1,4 +1,4 @@ -import { Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react"; +import { PanelsTopLeft, Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react"; import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry"; import type { CanvasToolMode } from "@/types/canvas"; @@ -9,6 +9,7 @@ const canvasModeOptions = [ ]; export const mainToolbarTools: ToolDefinition[] = [ + { id: "tool-workspace", toolbar: "main", category: "navigation", label: "工作区", icon: , defaultVisible: true, defaultOrder: 65, run: ctx => ctx.handlers.onOpenWorkspace?.() }, { id: "tool-canvas-mode", toolbar: "main", diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts index 4cf890bdf..adea31f72 100644 --- a/web/src/lib/canvas/tool-registry/tool-definition.ts +++ b/web/src/lib/canvas/tool-registry/tool-definition.ts @@ -7,7 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo /** 工具栏标识——每个工具栏有独立的注册表与偏好 */ export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu"; -export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "panorama" | "process" | "workspace" | "utility" | "more"; +export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "panorama" | "process" | "nine_grid" | "workspace" | "utility" | "more"; /** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */ export type ToolCategory = @@ -50,6 +50,7 @@ export type ToolbarHandlers = { onOpenDirector: () => void; // 主工具栏——资源 onUpload: () => void; + onOpenWorkspace?: () => void; onOpenMyAssets: () => void; onOpenProjectCharacters: () => void; // 主工具栏——外观 diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 7f64bc822..402707724 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,3 +1,4 @@ +import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel"; import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; @@ -35,7 +36,6 @@ import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel"; import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel"; import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel"; -import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray"; import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar"; import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal"; import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node"; @@ -372,6 +372,7 @@ function InfiniteCanvasPage() { const activeChatIdRef = useRef(activeChatId); const selectedNodeIdsRef = useRef(selectedNodeIds); const viewportRef = useRef(viewport); + const [workspaceOpen, setWorkspaceOpen] = useState(false); const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise) | null>(null); useEffect(() => { @@ -732,7 +733,6 @@ function InfiniteCanvasPage() { closeUploadModal, closeAssetPicker, createVideoNodeFromBlob, - createImageAssetNode, fileDropActive, handleAssetsInsert, handleDrop, @@ -848,6 +848,7 @@ function InfiniteCanvasPage() { extractVideoFrames, extractingVideoFramesNodeId, frameDialogNodeId, + generateNineGridNode, generateAngleNode, generateLightingNode, openPanoramaConfig, @@ -1384,14 +1385,12 @@ function InfiniteCanvasPage() { annotationNode, batchChildCountById, batchMotionById, - canvasImageNodes, configInputsById, connectionLayerBounds, contextMenuNode, cropNode, displayConnections, frameChildrenById, - imageAssets, infoNode, maskEditNode, imageEditNode, @@ -2439,7 +2438,15 @@ function InfiniteCanvasPage() { 跳转到画布主内容
- {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( + {!focusMode && !versions.preview ? setWorkspaceOpen(true)} onInsertAssets={handleProjectAssetsInsert} projectId={projectId} nodes={nodes} selectedNodeIds={selectedNodeIds} onClose={() => setWorkspaceOpen(false)} onAssets={() => openCanvasAssetLibrary()} onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} onCancelTask={cancelCanvasTask} onFocus={nodeId => { + const target = nodeById.get(nodeId); + const parent = target?.parentId ? nodeById.get(target.parentId) : null; + if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); + const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; + if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); + const selection = new Set([nodeId]); selectedNodeIdsRef.current = selection; setSelectedNodeIds(selection); setSelectedConnectionId(null); focusCanvasNode(nodeId); + }} /> : null} + {!workspaceOpen && !focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( openProjectAssets()} /> ) : null} @@ -2691,6 +2698,7 @@ function InfiniteCanvasPage() { onSaveAppearanceDefault={saveCanvasAppearanceDefault} onBackgroundModeChange={setBackgroundMode} onShowImageInfoChange={setShowImageInfo} + onOpenWorkspace={() => setWorkspaceOpen(value => !value)} onOpenMyAssets={() => { openCanvasAssetLibrary(); }} @@ -2883,6 +2891,7 @@ function InfiniteCanvasPage() { extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId} extractingAudio={segmentRunningMode === "audio"} trimmingVideo={segmentRunningMode === "video"} + onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)} onReversePrompt={createImageReversePromptNodes} onRetry={retryCanvasNode} onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} @@ -2913,14 +2922,6 @@ function InfiniteCanvasPage() { onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} /> - void createImageAssetNode(asset)} - onFocusCanvasImage={focusCanvasImageNode} - /> ) : null} diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index 5d6718d71..93b7e4118 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -282,7 +282,7 @@ export default function SharedCanvasPage() { }} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)} - setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} /> + setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} onNineGrid={unauthorized} />
访客操作仅在当前页面临时生效
diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 14da960be..424d120fb 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -37,7 +37,7 @@ import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size" import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion"; import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture"; import { IMAGE_PROMPT_REVERSE } from "@/lib/prompts"; -import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source"; +import { createPortraitTextureNode, createNineGridNode } from "@/lib/canvas/canvas-image-source"; import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame"; import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes"; import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge"; @@ -47,6 +47,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { defaultImageParamsForModel } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedVideo } from "@/services/api/video"; +import { getTool } from "@/services/api/tools"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; @@ -1100,6 +1101,21 @@ export function useCanvasMediaTools({ } }, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]); + const generateNineGridNode = useCallback(async (node: CanvasNodeData, toolId: number, label: string, icon: string) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { + message.warning("图片节点为空,无法执行九宫格工具"); + return; + } + const child = createNineGridNode(node, nanoid(), toolId, label, "nine_grid",icon); + setHoveredNodeId(null); + setToolbarNodeId(null); + setNodes((current) => [...current, child]); + setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]); + setSelectedNodeIds(new Set([child.id])); + setSelectedConnectionId(null); + setDialogNodeId(child.id); + }, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]); + const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => { if (!node.metadata?.content) return; const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" }; @@ -1211,6 +1227,7 @@ export function useCanvasMediaTools({ frameDialogNodeId, handleSegmentConfirm, generateAngleNode, + generateNineGridNode, generateLightingNode, openPanoramaConfig, createPanoramaViewerWithConfig, diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index 24f30874e..0d494ca4f 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -4,7 +4,7 @@ import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/component import { isFrameNode } from "@/lib/canvas/canvas-frame"; import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain"; import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode"; -import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references"; +import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references"; import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions"; import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index"; import type { Skill } from "@/services/api/skills"; @@ -353,12 +353,32 @@ export function useCanvasRenderModel({ ); const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]); const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]); + const toolMentionReferencesByNodeId = useMemo(() => { + const map = new Map[]>(); + for (const node of semanticNodes) { + const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; + const tokens = parseToolMentionTokens(text); + if (!tokens.length) continue; + const seen = new Set(); + const refs: ReturnType[] = []; + for (const { type, toolId, label, icon } of tokens) { + if (seen.has(toolId)) continue; + seen.add(toolId); + refs.push(buildToolMentionReference(toolId, label, type, icon)); + } + if (refs.length) map.set(node.id, refs); + } + return map; + }, [semanticNodes]); const mentionReferencesByNodeId = useMemo(() => { const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes); - if (!skillMentionReferences.length) return map; - map.forEach((references, nodeId) => map.set(nodeId, [...references, ...skillMentionReferences])); + if (!skillMentionReferences.length && toolMentionReferencesByNodeId.size === 0) return map; + map.forEach((references, nodeId) => { + const extras = [...skillMentionReferences, ...(toolMentionReferencesByNodeId.get(nodeId) ?? [])]; + if (extras.length) map.set(nodeId, [...references, ...extras]); + }); return map; - }, [connections, semanticNodes, skillMentionReferences, visibleNodes]); + }, [connections, semanticNodes, skillMentionReferences, toolMentionReferencesByNodeId, visibleNodes]); return { activeDirectorNode, diff --git a/web/src/pages/canvas/use-canvas-workspace-tasks.ts b/web/src/pages/canvas/use-canvas-workspace-tasks.ts new file mode 100644 index 000000000..df9f83c97 --- /dev/null +++ b/web/src/pages/canvas/use-canvas-workspace-tasks.ts @@ -0,0 +1,51 @@ +import { useUserStore } from "@/stores/use-user-store"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { listGenerationTasks, type GenerationTask } from "@/services/api/task-center"; + +export function useCanvasWorkspaceTasks(projectId: string, enabled: boolean) { + const userId = useUserStore((s) => s.user?.id); + const query = useQuery({ + queryKey: ["canvas-workspace-tasks", userId, projectId], + queryFn: () => listGenerationTasks(30, { projectId }), + enabled: enabled && Boolean(projectId) && Boolean(userId), + refetchInterval: (current) => { + const data = current.state.data; + if (!data?.length) return 10_000; + const hasActive = data.some((task) => (task.status === "queued" || task.status === "running") && !isInternalAgentTask(task)); + return hasActive ? 3_000 : 10_000; + }, + refetchOnWindowFocus: true, + }); + + useEffect(() => { + if (!enabled) return; + const handleTaskChanged = (event: Event) => { + const task = (event as CustomEvent<{ task?: GenerationTask }>).detail?.task; + if (task?.projectId === projectId) void query.refetch(); + }; + window.addEventListener("canvas:task-created", handleTaskChanged); + window.addEventListener("canvas:task-cancelled", handleTaskChanged); + return () => { + window.removeEventListener("canvas:task-created", handleTaskChanged); + window.removeEventListener("canvas:task-cancelled", handleTaskChanged); + }; + }, [enabled, projectId, query.refetch]); + + // 任务面板隐藏 Agent 内部任务;历史面板消费全量 allTasks。 + const tasks = useMemo(() => (query.data || []).filter((task) => !isInternalAgentTask(task)), [query.data]); + + return { + tasks, + allTasks: query.data || [], + error: query.error, + loading: query.isLoading, + refreshing: query.isFetching, + refetch: query.refetch, + }; +} + +function isInternalAgentTask(task: GenerationTask) { + return task.operation?.startsWith("cloud_agent") === true; +} diff --git a/web/src/services/api/tools.ts b/web/src/services/api/tools.ts new file mode 100644 index 000000000..8569f3b68 --- /dev/null +++ b/web/src/services/api/tools.ts @@ -0,0 +1,84 @@ +import { http, compactApiParams, serializeApiParams, type ApiParams, type HttpRequestConfig } from "@/services/api/request"; + +export type ToolType = "style" | "motion" | "nine_grid" | "effect"; +export type ToolScope = "public" | "favorites" | "custom"; +export type ToolSource = "builtin" | "user"; +export type ToolVisibility = "public" | "private"; + +// 列表摘要项:不含 prompt、extraInfo 等大字段 +export type ToolSummary = { + id: number; + type: ToolType | string; + labelEn: string; + label: string; + desc: string; + tag: string; + cover: string; + ratio: string; + mediaUrl: string; + ownerId: string; + source: ToolSource | string; + enabled: boolean; + visibility: ToolVisibility | string; + sortWeight: number; + favorited: boolean; + createdAt: string; + updatedAt: string; +}; + +// 详情/写操作返回的完整工具 +export type ToolItem = ToolSummary & { + extraInfo: string[]; + prompt: string; +}; + +export type ToolList = { + tools: ToolSummary[]; + totalCount: number; + page: number; + pageSize: number; + hasMore: boolean; +}; + +export type ListToolsInput = { + page?: number; + pageSize?: number; + scope?: ToolScope; + type?: ToolType | string; + tag?: string; + search?: string; +}; + +export type ToolMutationInput = { + type: ToolType | string; + label: string; + desc?: string; + tag?: string; + cover?: string; + extraInfo?: string[]; + prompt: string; + ratio?: string; + mediaUrl?: string; + visibility?: ToolVisibility; +}; + +export function listTools(input: ListToolsInput = {}, config?: HttpRequestConfig) { + const params = serializeApiParams(compactApiParams(input as ApiParams)); + return http.get(`/tools?${params.toString()}`, config); +} + +export function getTool(id: number, config?: HttpRequestConfig) { + return http.get(`/tools/${encodeURIComponent(String(id))}`, config); +} + +export function setToolFavorite(id: number, favorite: boolean) { + return favorite ? http.post(`/tools/${encodeURIComponent(String(id))}/favorite`) : http.delete(`/tools/${encodeURIComponent(String(id))}/favorite`); +} + +export function createTool(input: ToolMutationInput) { + return http.post("/tools", input); +} + +export function deleteTool(id: number) { + return http.delete<{ deleted: boolean }>(`/tools/${encodeURIComponent(String(id))}`); +} diff --git a/web/test/canvas-connection-create-menu.test.ts b/web/test/canvas-connection-create-menu.test.ts index 154e31eb2..b867db94d 100644 --- a/web/test/canvas-connection-create-menu.test.ts +++ b/web/test/canvas-connection-create-menu.test.ts @@ -9,7 +9,7 @@ test("connection menu omits the header and provides descriptions for every optio expect(component).not.toContain(">创建下一步"); expect(component).not.toContain("ChevronRight"); expect(component).not.toContain("absolute inset-x-8 top-0 h-px"); - const options = component.match(//g) || []; expect(options).toHaveLength(9); for (const option of options) expect(option).toMatch(/description="[^"]+"/); expect(component).toContain('title="批量创作表"'); diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index d1760f545..fdfbb672f 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -130,9 +130,11 @@ describe("canvas resource mention editor", () => { const chat = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx"); const css = source("../src/components/canvas/canvas-cloud-agent.css"); - expect(component).toContain('chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`'); - expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"'); - expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));'); + expect(component).toContain('const isDecorated = reference.kind === "skill" || reference.kind === "tool"'); + expect(component).toContain('chip.className = `canvas-resource-inline-mention ${isDecorated ? `is-${reference.kind}` : ""}`'); + expect(component).toMatch(/if \(reference.kind === "skill"\)\s*\{\s*prefix.textContent = "✦"/); + expect(component).toContain('prefix.textContent = "@"'); + expect(component).toContain("if (!isDecorated) chip.appendChild(createInlinePreview(reference));"); expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))'); expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}'); expect(chat).toContain("agent-composer-resize-handle"); diff --git a/web/test/canvas-tool-integration.test.ts b/web/test/canvas-tool-integration.test.ts new file mode 100644 index 000000000..17fbb74a8 --- /dev/null +++ b/web/test/canvas-tool-integration.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { applyToolMention, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references"; +import { createNineGridNode } from "../src/lib/canvas/canvas-image-source"; +import { CanvasNodeType } from "../src/types/canvas"; +import type { CanvasNodeData } from "../src/types/canvas"; +import "../src/lib/canvas/node-registry/definitions/index"; +import { writeCanvasNodePrompt } from "../src/lib/canvas/canvas-node-prompt"; +import { canonicalGenerationMetadata } from "../src/lib/canvas/generation-contract"; + +for (const type of ["style", "nine_grid"]) { + test(`${type} selection, replacement, removal and clearing persist across prompt panel reads`, () => { + for (const content of [undefined, "/generated-image.png"]) { + let node: CanvasNodeData = { + id: "image", + type: CanvasNodeType.Image, + title: "图片", + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: { content, prompt: "原始生成记录" }, + }; + const editAndRead = (prompt: string) => { + node = writeCanvasNodePrompt(node, prompt); + node = JSON.parse(JSON.stringify(node)) as CanvasNodeData; + const metadata = canonicalGenerationMetadata(node, "image"); + expect(metadata.composerContent).toBe(prompt); + expect(metadata.generationSpec?.prompt).toBe(prompt); + if (content) expect(node.metadata?.prompt).toBe("原始生成记录"); + return metadata.composerContent!; + }; + let prompt = editAndRead(applyToolMention("场景描述", { id: 1, type, label: "初始选择" })); + prompt = editAndRead(applyToolMention(prompt, { id: 2, type, label: "替换选择" })); + expect(parseToolMentionTokens(prompt).map((token) => token.toolId)).toEqual([2]); + prompt = editAndRead(`${prompt}\n连续编辑`); + prompt = editAndRead(removeToolMentions(prompt, type)); + expect(prompt).toBe("场景描述 \n连续编辑"); + expect(parseToolMentionTokens(prompt)).toEqual([]); + editAndRead(applyToolMention(prompt, { id: 3, type, label: "再次选择" })); + expect(parseToolMentionTokens(editAndRead(""))).toEqual([]); + editAndRead("清空后的新内容"); + } + }); +} + +test("tool labels round trip; style replaces, motion deduplicates; clear preserves prose", () => { + const style = { id: 1, type: "style", label: "风格: [a] 中文" }; + const first = applyToolMention("第一行\n第二行", style); + expect(parseToolMentionTokens(first)[0].label).toBe(style.label); + const changed = applyToolMention(first, { ...style, id: 2 }); + expect(parseToolMentionTokens(changed).map((t) => t.toolId)).toEqual([2]); + expect(removeToolMentions(changed, "style")).toBe("第一行\n第二行"); + const motion = { id: 46, type: "motion", label: "推镜" }; + const combined = applyToolMention(changed, motion); + expect(applyToolMention(combined, motion)).toBe(combined); + expect(parseToolMentionTokens(combined)).toHaveLength(2); +}); + +test("nine grid creates idle child using stable source token, not an auto generation", () => { + const child = createNineGridNode({ id: "source", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "/image.png" } }, "child", 79, "九宫格", "nine_grid", "Grid3x3"); + expect(child.metadata?.prompt).toContain("@[node:source]"); + expect(parseToolMentionTokens(child.metadata?.prompt ?? "")[0].toolId).toBe(79); + expect(child.metadata?.content).toBeFalsy(); +}); + +test("node style overrides project style and removing it restores inheritance", async () => { + const { resolveCanvasStyleExecution } = await import("../src/lib/canvas/canvas-style-execution"); + const { defaultConfig } = await import("../src/stores/use-config-store"); + const styleNode = { id: "style", type: CanvasNodeType.Config, title: "画风", position: { x: 0, y: 0 }, width: 300, height: 200, metadata: { workflowKind: "styleboard" as const, stylePresetId: "test", prompt: "project style" } }; + const prompt = applyToolMention("scene", { id: 1, type: "style", label: "custom" }); + expect(resolveCanvasStyleExecution([styleNode], undefined, prompt, defaultConfig, "image")).toBeNull(); + expect(resolveCanvasStyleExecution([styleNode], undefined, removeToolMentions(prompt, "style"), defaultConfig, "image")).not.toBeNull(); +}); diff --git a/web/test/canvas-workspace-sidebar.test.ts b/web/test/canvas-workspace-sidebar.test.ts new file mode 100644 index 000000000..d35a617ae --- /dev/null +++ b/web/test/canvas-workspace-sidebar.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { canvasDockStyle } from "../src/lib/canvas/canvas-aceternity-style"; +import { canvasThemes } from "../src/lib/canvas-theme"; + +const component = (name: string) => readFileSync(new URL(`../src/components/canvas/${name}`, import.meta.url), "utf8"); + +test("canvas removes the standalone asset tray while retaining sidebar assets and zoom controls", () => { + const page = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8"); + expect(page).not.toContain("CanvasAssetTray"); + expect(page).toContain(" { + const source = component("canvas-workspace-panel.tsx"); + for (const label of ["主页", "节点", "资产", "任务", "历史"]) expect(source).toContain(label); + expect(source).not.toContain('id: "tools"'); + expect(source).not.toContain("CanvasWorkspaceToolPanel"); + expect(source).not.toContain("onInsertTool"); + expect(source).toContain('aria-label="画布左侧菜单"'); + expect(source).toContain("aria-pressed={open && tab === id}"); + expect(source).toContain(" { + const source = component("canvas-workspace-asset-panel.tsx"); + expect(source).toContain('["canvas-workspace-assets", userId, category, kind, search]'); + expect(source).toContain("loadAssetLibraryPage"); + expect(source).toContain("assetPickerItemsToInsertPayloads"); + expect(source).toContain("query.fetchNextPage()"); + expect(source).toContain('role="alert"'); + expect(source).toContain("disabled={!insertable || Boolean(inserting)}"); +}); + +test("image style and presets remain visible without the prompt template picker", () => { + const source = component("canvas-node-prompt-panel.tsx"); + expect(source).toContain("canvas-node-tool-controls-inline"); + expect(source).toContain(" { + expect(component("canvas-workspace-history-panel.tsx")).toContain("最近 30 条(含 Agent)"); +}); + +test("cleared tool references do not fall back to the original generation prompt", () => { + const source = readFileSync(new URL("../src/pages/canvas/use-canvas-render-model.ts", import.meta.url), "utf8"); + const toolReferences = source.split("const toolMentionReferencesByNodeId = useMemo")[1].split("const tokens =")[0]; + expect(toolReferences).toContain('node.metadata?.composerContent ?? node.metadata?.prompt ?? ""'); + expect(toolReferences).not.toContain("||"); +}); + +test("workspace starts collapsed and keeps separated navigation and bottom toggle", () => { + const page = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8"); + expect(page).toContain("const [workspaceOpen, setWorkspaceOpen] = useState(false)"); + const panel = component("canvas-workspace-panel.tsx"); + expect(panel).toContain('className="canvas-workspace-rail-items"'); + expect(panel.indexOf("canvas-workspace-rail-toggle")).toBeGreaterThan(panel.indexOf("entries.map")); + const css = component("canvas-workspace-panel.css"); + expect(css).toContain("gap: var(--space-3)"); + expect(css).toContain("margin-top: auto"); + expect(css).toContain("prefers-reduced-motion: reduce"); +}); + +test("rail shares canvas dock colors rather than muted workspace navigation colors", () => { + const panel = component("canvas-workspace-panel.tsx"); + expect(panel).toContain("canvasThemes[useActiveTheme()]"); + expect(panel).toContain('...canvasDockStyle(theme), boxShadow: "none"'); + const railCss = component("canvas-workspace-panel.css").split(".canvas-workspace-rail {")[1]; + expect(railCss).not.toContain("var(--muted-foreground)"); + expect(railCss).not.toContain("var(--workspace-navigation)"); + for (const token of ["--dock-command-hover", "--dock-command-active", "--dock-command-active-text", "--dock-tooltip-border"]) expect(railCss).toContain(`var(${token})`); + for (const theme of Object.values(canvasThemes)) { + const style = canvasDockStyle(theme); + expect(style.background).toBe("var(--dock-surface)"); + expect(style.color).toBe(theme.toolbar.item); + } +});