diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 2afd8d045..b6859e0f2 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -238,7 +238,8 @@ func migrations() []tableMigration { migrateTable[model.Asset]("assets"), migrateTable[model.CanvasProject]("canvas_projects"), migrateTable[model.CanvasShare]("canvas_shares"), - migrateTable[model.StoryboardPromptTemplate]("storyboard_prompt_templates"), + migrateTable[model.PromptTemplate]("prompt_templates"), + migrateTable[model.UserPromptCustomization]("user_prompt_customizations"), migrateTable[model.Announcement]("announcements"), migrateTable[model.UserAnnouncementRead]("user_announcement_reads"), migrateTable[model.Task]("tasks"), diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5aa424aa3..f79846a03 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -45,7 +45,7 @@ func main() { if err := svc.EnsureSystemChannelModels(); err != nil { log.Fatal(err) } - if err := svc.EnsureDefaultStoryboardPromptTemplate(); err != nil { + if err := svc.EnsureDefaultPromptTemplates(); err != nil { log.Fatal(err) } if err := svc.EnsureBuiltinProjectWorkflowTemplate(); err != nil { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 33c72a6af..b6f1f3d55 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -49,7 +49,8 @@ func Models() []any { &model.WorkflowStepTask{}, &model.CanvasProject{}, &model.CanvasShare{}, - &model.StoryboardPromptTemplate{}, + &model.PromptTemplate{}, + &model.UserPromptCustomization{}, &model.Announcement{}, &model.UserAnnouncementRead{}, &model.Task{}, diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 552e04bd2..2e3cd8b44 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -381,62 +381,64 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"ok": true}) }) - r.GET("/admin/storyboard-prompts", func(c *gin.Context) { + r.GET("/admin/prompt-templates", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } - templates, variables, err := svc.AdminStoryboardPromptTemplates(user) + templates, definitions, err := svc.AdminPromptTemplates(user) if err != nil { failService(c, err) return } - ok(c, gin.H{"templates": templates, "variables": variables}) + ok(c, gin.H{"templates": templates, "definitions": definitions}) }) - r.POST("/admin/storyboard-prompts", func(c *gin.Context) { + r.POST("/admin/prompt-templates", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } - var req service.StoryboardPromptTemplateRequest + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.PromptTemplateRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } - template, err := svc.CreateStoryboardPromptTemplate(user, req) + template, err := svc.CreatePromptTemplate(user, req) if err != nil { failService(c, err) return } ok(c, gin.H{"template": template}) }) - r.PATCH("/admin/storyboard-prompts/:id", func(c *gin.Context) { + r.PATCH("/admin/prompt-templates/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } - var req service.StoryboardPromptTemplateRequest + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.PromptTemplateRequest if err := c.ShouldBindJSON(&req); err != nil { fail(c, http.StatusBadRequest, err) return } - template, err := svc.UpdateStoryboardPromptTemplate(user, c.Param("id"), req) + template, err := svc.UpdatePromptTemplate(user, c.Param("id"), req) if err != nil { failService(c, err) return } ok(c, gin.H{"template": template}) }) - r.DELETE("/admin/storyboard-prompts/:id", func(c *gin.Context) { + r.DELETE("/admin/prompt-templates/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { failService(c, err) return } - if err := svc.DeleteStoryboardPromptTemplate(user, c.Param("id")); err != nil { + if err := svc.DeletePromptTemplate(user, c.Param("id")); err != nil { failService(c, err) return } diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 7d2936ca6..28680532c 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -16,6 +16,50 @@ import ( ) func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/settings/prompt-templates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + preferences, err := svc.UserPromptPreferences(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"preferences": preferences}) + }) + r.PATCH("/settings/prompt-templates/:operation", 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.UserPromptCustomizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + customization, err := svc.UpdateUserPromptCustomization(user, c.Param("operation"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"customization": customization}) + }) + r.DELETE("/settings/prompt-templates/:operation", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.ResetUserPromptCustomization(user, c.Param("operation")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) r.GET("/settings/oss", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 5554f100b..8da363159 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -688,14 +688,29 @@ type CanvasShare struct { UpdatedAt time.Time `json:"updatedAt"` } -type StoryboardPromptTemplate struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Name string `json:"name" gorm:"size:120"` - Content string `json:"content" gorm:"type:text"` - Enabled bool `json:"enabled" gorm:"index"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` +type PromptTemplate struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_prompt_template_operation_version,priority:1"` + Name string `json:"name" gorm:"size:120"` + Version int `json:"version" gorm:"uniqueIndex:idx_prompt_template_operation_version,priority:2"` + Content string `json:"content" gorm:"type:text"` + OutputType string `json:"outputType" gorm:"size:24"` + Enabled bool `json:"enabled" gorm:"index;index:idx_prompt_template_active,priority:2"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// UserPromptCustomization 只保存用户的创作策略层,动态上下文和输出契约始终由服务端编译器注入。 +type UserPromptCustomization struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_prompt_operation,priority:1"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_user_prompt_operation,priority:2"` + Mode string `json:"mode" gorm:"size:24"` + Content string `json:"content" gorm:"type:text"` + BaseTemplateID string `json:"baseTemplateId" gorm:"size:36;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type Announcement struct { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index c9c21898b..00fdcdd49 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1436,38 +1436,44 @@ func (r *Repository) ReplaceCanvasProjects(userID string, projects []model.Canva }) } -func (r *Repository) StoryboardPromptTemplates() ([]model.StoryboardPromptTemplate, error) { - var templates []model.StoryboardPromptTemplate - err := r.db.Order("updated_at desc").Find(&templates).Error +func (r *Repository) PromptTemplates() ([]model.PromptTemplate, error) { + var templates []model.PromptTemplate + err := r.db.Order("operation asc, version desc").Find(&templates).Error return templates, err } -func (r *Repository) StoryboardPromptTemplate(id string) (*model.StoryboardPromptTemplate, error) { - var template model.StoryboardPromptTemplate +func (r *Repository) PromptTemplate(id string) (*model.PromptTemplate, error) { + var template model.PromptTemplate if err := r.db.First(&template, "id = ?", id).Error; err != nil { return nil, err } return &template, nil } -func (r *Repository) ActiveStoryboardPromptTemplate() (*model.StoryboardPromptTemplate, error) { - var template model.StoryboardPromptTemplate - if err := r.db.Order("updated_at desc").First(&template, "enabled = ?", true).Error; err != nil { +func (r *Repository) ActivePromptTemplate(operation string) (*model.PromptTemplate, error) { + var template model.PromptTemplate + if err := r.db.Order("version desc").First(&template, "operation = ? AND enabled = ?", operation, true).Error; err != nil { return nil, err } return &template, nil } -func (r *Repository) StoryboardPromptTemplateCount() (int64, error) { +func (r *Repository) PromptTemplateCount(operation string) (int64, error) { var count int64 - err := r.db.Model(&model.StoryboardPromptTemplate{}).Count(&count).Error + err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Count(&count).Error return count, err } -func (r *Repository) SaveStoryboardPromptTemplate(template *model.StoryboardPromptTemplate) error { +func (r *Repository) NextPromptTemplateVersion(operation string) (int, error) { + var version int + err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Select("COALESCE(MAX(version), 0)").Scan(&version).Error + return version + 1, err +} + +func (r *Repository) SavePromptTemplate(template *model.PromptTemplate) error { return r.db.Transaction(func(tx *gorm.DB) error { if template.Enabled { - if err := tx.Model(&model.StoryboardPromptTemplate{}).Where("id <> ?", template.ID).Update("enabled", false).Error; err != nil { + if err := tx.Model(&model.PromptTemplate{}).Where("operation = ? AND id <> ?", template.Operation, template.ID).Update("enabled", false).Error; err != nil { return err } } @@ -1475,6 +1481,28 @@ func (r *Repository) SaveStoryboardPromptTemplate(template *model.StoryboardProm }) } -func (r *Repository) DeleteStoryboardPromptTemplate(id string) error { - return r.db.Delete(&model.StoryboardPromptTemplate{}, "id = ?", id).Error +func (r *Repository) DeletePromptTemplate(id string) error { + return r.db.Delete(&model.PromptTemplate{}, "id = ?", id).Error +} + +func (r *Repository) UserPromptCustomizations(userID string) ([]model.UserPromptCustomization, error) { + var customizations []model.UserPromptCustomization + err := r.db.Where("user_id = ?", userID).Order("operation asc").Find(&customizations).Error + return customizations, err +} + +func (r *Repository) UserPromptCustomization(userID string, operation string) (*model.UserPromptCustomization, error) { + var customization model.UserPromptCustomization + if err := r.db.First(&customization, "user_id = ? AND operation = ?", userID, operation).Error; err != nil { + return nil, err + } + return &customization, nil +} + +func (r *Repository) SaveUserPromptCustomization(customization *model.UserPromptCustomization) error { + return r.db.Save(customization).Error +} + +func (r *Repository) DeleteUserPromptCustomization(userID string, operation string) error { + return r.db.Delete(&model.UserPromptCustomization{}, "user_id = ? AND operation = ?", userID, operation).Error } diff --git a/backend/internal/service/prompt_template.go b/backend/internal/service/prompt_template.go new file mode 100644 index 000000000..8eae7ea37 --- /dev/null +++ b/backend/internal/service/prompt_template.go @@ -0,0 +1,405 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +const ( + promptCustomizationInherit = "inherit" + promptCustomizationAppend = "append" + promptCustomizationRewrite = "rewrite" +) + +var promptPlaceholderPattern = regexp.MustCompile(`\{\{[^{}]+\}\}`) + +type PromptTemplateVariable struct { + Label string `json:"label"` + Placeholder string `json:"placeholder"` +} + +type PromptOperationDefinition struct { + Operation string `json:"operation"` + Label string `json:"label"` + Category string `json:"category"` + Description string `json:"description"` + OutputType string `json:"outputType"` + SchemaKey string `json:"schemaKey,omitempty"` + Variables []PromptTemplateVariable `json:"variables"` + OutputContract string `json:"outputContract"` + DefaultContent string `json:"-"` +} + +type PromptTemplateRequest struct { + Operation string `json:"operation"` + Name string `json:"name"` + Content string `json:"content"` + Enabled *bool `json:"enabled"` +} + +type UserPromptCustomizationRequest struct { + Mode string `json:"mode"` + Content string `json:"content"` +} + +type UserPromptPreference struct { + Definition PromptOperationDefinition `json:"definition"` + Template *model.PromptTemplate `json:"template"` + Customization *model.UserPromptCustomization `json:"customization,omitempty"` + Outdated bool `json:"outdated"` +} + +type CompiledPrompt struct { + Content string + TemplateID string + TemplateVersion int + CustomizationID string + CustomizationUpdated string +} + +func promptDefinitions() []PromptOperationDefinition { + definitions := defaultPromptDefinitions() + for index := range definitions { + definitions[index].OutputContract = promptOutputContract(definitions[index].Operation) + } + return definitions +} + +func promptDefinition(operation string) (PromptOperationDefinition, bool) { + for _, definition := range promptDefinitions() { + if definition.Operation == operation { + return definition, true + } + } + return PromptOperationDefinition{}, false +} + +func (s *Service) EnsureDefaultPromptTemplates() error { + for _, definition := range promptDefinitions() { + count, err := s.repo.PromptTemplateCount(definition.Operation) + if err != nil { + return err + } + if count > 0 { + continue + } + if err := s.repo.SavePromptTemplate(&model.PromptTemplate{ + ID: newID(), Operation: definition.Operation, Name: "默认" + definition.Label + "模板", Version: 1, + Content: definition.DefaultContent, OutputType: definition.OutputType, Enabled: true, + }); err != nil { + return err + } + } + return nil +} + +func (s *Service) AdminPromptTemplates(actor *model.User) ([]model.PromptTemplate, []PromptOperationDefinition, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, nil, err + } + templates, err := s.repo.PromptTemplates() + if err != nil { + return nil, nil, err + } + return templates, promptDefinitions(), nil +} + +func (s *Service) CreatePromptTemplate(actor *model.User, req PromptTemplateRequest) (*model.PromptTemplate, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + definition, ok := promptDefinition(strings.TrimSpace(req.Operation)) + if !ok { + return nil, BadAuthRequest("不支持的提示词模板类型") + } + name, content, err := validatePromptTemplateContent(definition, req.Name, req.Content) + if err != nil { + return nil, err + } + version, err := s.repo.NextPromptTemplateVersion(definition.Operation) + if err != nil { + return nil, err + } + template := &model.PromptTemplate{ + ID: newID(), Operation: definition.Operation, Name: name, Version: version, Content: content, + OutputType: definition.OutputType, Enabled: req.Enabled != nil && *req.Enabled, CreatedBy: actor.ID, + } + if err := s.repo.SavePromptTemplate(template); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "prompt_template.create", "prompt_template", template.ID, "创建提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version, "enabled": template.Enabled}); err != nil { + return nil, err + } + return template, nil +} + +func (s *Service) UpdatePromptTemplate(actor *model.User, id string, req PromptTemplateRequest) (*model.PromptTemplate, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + template, err := s.repo.PromptTemplate(id) + if err != nil { + return nil, err + } + definition, ok := promptDefinition(template.Operation) + if !ok { + return nil, BadAuthRequest("提示词模板类型已经失效") + } + if template.Enabled && (strings.TrimSpace(req.Content) != strings.TrimSpace(template.Content) || strings.TrimSpace(req.Name) != template.Name) { + return nil, BadAuthRequest("启用中的版本不可直接修改,请基于它新建版本") + } + if req.Enabled != nil && !*req.Enabled && template.Enabled { + return nil, BadAuthRequest("启用中的版本不能直接停用,请先启用同类型的其他版本") + } + name, content, err := validatePromptTemplateContent(definition, req.Name, req.Content) + if err != nil { + return nil, err + } + template.Name = name + template.Content = content + if req.Enabled != nil { + template.Enabled = *req.Enabled + } + if err := s.repo.SavePromptTemplate(template); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "prompt_template.update", "prompt_template", template.ID, "更新提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version, "enabled": template.Enabled}); err != nil { + return nil, err + } + return template, nil +} + +func (s *Service) DeletePromptTemplate(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + template, err := s.repo.PromptTemplate(id) + if err != nil { + return err + } + if template.Enabled { + return BadAuthRequest("启用中的版本不能删除,请先启用同类型的其他版本") + } + if err := s.repo.DeletePromptTemplate(id); err != nil { + return err + } + return s.appendAdminAudit(actor, "prompt_template.delete", "prompt_template", template.ID, "删除提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version}) +} + +func (s *Service) UserPromptPreferences(user *model.User) ([]UserPromptPreference, error) { + if user == nil || user.ID == "" { + return nil, BadAuthRequest("请先登录") + } + customizations, err := s.repo.UserPromptCustomizations(user.ID) + if err != nil { + return nil, err + } + customizationByOperation := make(map[string]*model.UserPromptCustomization, len(customizations)) + for index := range customizations { + customization := &customizations[index] + customizationByOperation[customization.Operation] = customization + } + preferences := make([]UserPromptPreference, 0) + for _, definition := range promptDefinitions() { + template, err := s.repo.ActivePromptTemplate(definition.Operation) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + customization := customizationByOperation[definition.Operation] + preference := UserPromptPreference{Definition: definition, Template: template, Customization: customization} + preference.Outdated = customization != nil && customization.Mode == promptCustomizationRewrite && template != nil && customization.BaseTemplateID != template.ID + preferences = append(preferences, preference) + } + return preferences, nil +} + +func (s *Service) UpdateUserPromptCustomization(user *model.User, operation string, req UserPromptCustomizationRequest) (*model.UserPromptCustomization, error) { + if user == nil || user.ID == "" { + return nil, BadAuthRequest("请先登录") + } + definition, ok := promptDefinition(strings.TrimSpace(operation)) + if !ok { + return nil, BadAuthRequest("不支持的提示词模板类型") + } + mode := strings.TrimSpace(req.Mode) + if mode != promptCustomizationInherit && mode != promptCustomizationAppend && mode != promptCustomizationRewrite { + return nil, BadAuthRequest("不支持的提示词定制方式") + } + content := strings.TrimSpace(req.Content) + if mode == promptCustomizationInherit { + content = "" + } + if mode != promptCustomizationInherit && content == "" { + return nil, BadAuthRequest("请填写个人提示词要求") + } + if len([]rune(content)) > 12_000 { + return nil, BadAuthRequest("个人提示词最多 12000 个字符") + } + if err := validatePromptPlaceholders(definition, content); err != nil { + return nil, err + } + active, err := s.repo.ActivePromptTemplate(definition.Operation) + if err != nil { + return nil, errors.New("当前模板类型没有启用版本") + } + customization, err := s.repo.UserPromptCustomization(user.ID, definition.Operation) + if errors.Is(err, gorm.ErrRecordNotFound) { + customization = &model.UserPromptCustomization{ID: newID(), UserID: user.ID, Operation: definition.Operation} + } else if err != nil { + return nil, err + } + customization.Mode = mode + customization.Content = content + customization.BaseTemplateID = active.ID + if err := s.repo.SaveUserPromptCustomization(customization); err != nil { + return nil, err + } + return customization, nil +} + +func (s *Service) ResetUserPromptCustomization(user *model.User, operation string) error { + if user == nil || user.ID == "" { + return BadAuthRequest("请先登录") + } + if _, ok := promptDefinition(strings.TrimSpace(operation)); !ok { + return BadAuthRequest("不支持的提示词模板类型") + } + return s.repo.DeleteUserPromptCustomization(user.ID, operation) +} + +func (s *Service) compilePrompt(userID string, operation string, values map[string]string) (CompiledPrompt, error) { + definition, ok := promptDefinition(operation) + if !ok { + return CompiledPrompt{}, fmt.Errorf("不支持的提示词模板类型:%s", operation) + } + template, err := s.repo.ActivePromptTemplate(operation) + if errors.Is(err, gorm.ErrRecordNotFound) { + template = &model.PromptTemplate{Operation: operation, Name: "内置默认", Version: 1, Content: definition.DefaultContent, OutputType: definition.OutputType} + } else if err != nil { + return CompiledPrompt{}, err + } + creative := template.Content + compiled := CompiledPrompt{TemplateID: template.ID, TemplateVersion: template.Version} + customization, customizationErr := s.repo.UserPromptCustomization(userID, operation) + if customizationErr == nil { + compiled.CustomizationID = customization.ID + compiled.CustomizationUpdated = customization.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") + switch customization.Mode { + case promptCustomizationAppend: + creative += "\n\n【用户个性化创作要求】\n" + customization.Content + case promptCustomizationRewrite: + creative = customization.Content + } + } else if !errors.Is(customizationErr, gorm.ErrRecordNotFound) { + return CompiledPrompt{}, customizationErr + } + rendered, err := renderPromptTemplate(definition, creative, values) + if err != nil { + return CompiledPrompt{}, err + } + parts := []string{strings.TrimSpace(rendered)} + if protected := strings.TrimSpace(protectedPromptContext(operation, values)); protected != "" { + parts = append(parts, protected) + } + compiled.Content = strings.Join(parts, "\n\n") + return compiled, nil +} + +func validatePromptTemplateContent(definition PromptOperationDefinition, name string, content string) (string, string, error) { + name = strings.TrimSpace(name) + content = strings.TrimSpace(content) + if name == "" { + return "", "", BadAuthRequest("请填写版本名称") + } + if content == "" { + return "", "", BadAuthRequest("请填写提示词模板") + } + if len([]rune(content)) > 30_000 { + return "", "", BadAuthRequest("提示词模板最多 30000 个字符") + } + if err := validatePromptPlaceholders(definition, content); err != nil { + return "", "", err + } + return name, content, nil +} + +func validatePromptPlaceholders(definition PromptOperationDefinition, content string) error { + allowed := make(map[string]bool, len(definition.Variables)) + for _, variable := range definition.Variables { + allowed[variable.Placeholder] = true + } + unknown := make([]string, 0) + for _, placeholder := range promptPlaceholderPattern.FindAllString(content, -1) { + if !allowed[placeholder] { + unknown = append(unknown, placeholder) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return BadAuthRequest("模板包含不支持的变量:" + strings.Join(uniqueStrings(unknown), "、")) +} + +func renderPromptTemplate(definition PromptOperationDefinition, content string, values map[string]string) (string, error) { + if err := validatePromptPlaceholders(definition, content); err != nil { + return "", err + } + rendered := content + for _, variable := range definition.Variables { + key := strings.TrimSuffix(strings.TrimPrefix(variable.Placeholder, "{{"), "}}") + rendered = strings.ReplaceAll(rendered, variable.Placeholder, strings.TrimSpace(values[key])) + } + return strings.TrimSpace(rendered), nil +} + +func validatePromptTemplateResult(operation string, result map[string]interface{}) error { + definition, ok := promptDefinition(operation) + if !ok || definition.OutputType != "json" { + return nil + } + text, _ := result["text"].(string) + jsonText, err := extractJSONText(text) + if err != nil { + return fmt.Errorf("%s 返回内容不符合受保护 JSON 契约:%w", definition.Label, err) + } + if operation != promptOperationCharacterExtract { + return nil + } + var payload struct { + Characters *[]map[string]interface{} `json:"characters"` + } + if err := json.Unmarshal([]byte(jsonText), &payload); err != nil { + return fmt.Errorf("角色卡提取返回的 JSON 无法解析:%w", err) + } + required := []string{"name", "aliases", "role", "appearance", "clothing", "physique", "personality", "props", "consistencyPrompt", "multiViewPrompt", "voiceLanguage", "voiceAge", "voiceTimbre"} + if payload.Characters == nil { + return errors.New("角色卡提取结果缺少 characters 数组") + } + for index, character := range *payload.Characters { + for _, field := range required { + if _, exists := character[field]; !exists { + return fmt.Errorf("角色卡提取结果中第 %d 个角色缺少字段 %s", index+1, field) + } + } + } + return nil +} + +func uniqueStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result +} diff --git a/backend/internal/service/prompt_template_defaults.go b/backend/internal/service/prompt_template_defaults.go new file mode 100644 index 000000000..0c83ac6c4 --- /dev/null +++ b/backend/internal/service/prompt_template_defaults.go @@ -0,0 +1,243 @@ +package service + +import ( + "fmt" + "strings" +) + +const ( + promptOperationStoryboardPlan = "storyboard_plan" + promptOperationStoryboardRepair = "storyboard_repair" + promptOperationStoryboardFirstFrame = "storyboard_first_frame" + promptOperationStoryboardVideo = "storyboard_video" + promptOperationCharacterExtract = "character_extract" + promptOperationCharacterTurnaround = "character_turnaround" +) + +func defaultPromptDefinitions() []PromptOperationDefinition { + return []PromptOperationDefinition{ + { + Operation: promptOperationStoryboardPlan, Label: "分镜规划", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3", + Description: "把剧情、项目画风和当前角色版本规划为可执行镜头。", + Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "用户要求", Placeholder: "{{用户要求}}"}}, + DefaultContent: defaultStoryboardPromptTemplate(), + }, + { + Operation: promptOperationStoryboardRepair, Label: "分镜修复", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3", + Description: "修复模型返回的分镜结构、字段和镜头复杂度。", + Variables: []PromptTemplateVariable{{Label: "校验错误", Placeholder: "{{校验错误}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, + DefaultContent: `你是影视分镜 JSON 修复导演。修复结构时必须保留剧情信息,通过拆镜或重新分配内容解决复杂度超限,不得用删除关键剧情掩盖错误。保持原项目的视觉媒介、角色身份、服装、道具和连续性,不要擅自改写画风。只修复校验错误和由此引发的镜头组织问题。`, + }, + { + Operation: promptOperationStoryboardFirstFrame, Label: "分镜首帧", Category: "生成", OutputType: "text", + Description: "把单镜头结构转换为图片模型使用的首帧提示词。", + Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演起始状态", Placeholder: "{{表演起始状态}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, + DefaultContent: `生成单一、可执行的分镜首帧。严格继承项目视觉媒介和角色资产;画面明确主体左右位置、视线、前中后景、遮挡、视觉焦点、可信光源与材质。只描述静止首帧,不提前写后续运动,不添加画外人物、无来源光线、文字或水印。 + +【项目视觉】 +{{项目视觉}} + +【首帧构图】 +{{首帧构图}} + +【表演起始状态】 +{{表演起始状态}} + +【负面要求】 +{{负面要求}}`, + }, + { + Operation: promptOperationStoryboardVideo, Label: "分镜视频", Category: "生成", OutputType: "text", + Description: "把单镜头结构转换为视频模型使用的紧凑执行提示词。", + Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "镜头意图", Placeholder: "{{镜头意图}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演与调度", Placeholder: "{{表演与调度}}"}, {Label: "摄影机", Placeholder: "{{摄影机}}"}, {Label: "时间节拍", Placeholder: "{{时间节拍}}"}, {Label: "运动与结尾", Placeholder: "{{运动与结尾}}"}, {Label: "声音", Placeholder: "{{声音}}"}, {Label: "执行优先级", Placeholder: "{{执行优先级}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, + DefaultContent: `生成单一连续镜头的视频执行提示词。一个镜头只保留一个叙事目标、一个主运镜和一条主要动作链;摄影机运动必须有起点、动机和停止点。优先保证角色身份、表演、关键动作和连续性,次要环境效果可以简化。 + +【项目视觉】 +{{项目视觉}} + +【镜头意图】 +{{镜头意图}} + +【首帧构图】 +{{首帧构图}} + +【表演与调度】 +{{表演与调度}} + +【摄影机】 +{{摄影机}} + +【时间节拍】 +{{时间节拍}} + +【运动与结尾】 +{{运动与结尾}} + +【台词与声音】 +{{声音}} + +【执行优先级】 +{{执行优先级}} + +【负面要求】 +{{负面要求}}`, + }, + { + Operation: promptOperationCharacterExtract, Label: "角色卡提取", Category: "角色", OutputType: "json", SchemaKey: "character-breakdown/v1", + Description: "从章节正文提取需要跨镜头保持一致的角色资产。", + Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "章节名称", Placeholder: "{{章节名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, + DefaultContent: `你是短剧角色资产导演。只提取章节中实际出场、发言或对剧情产生明确作用,并且后续制作需要保持视觉或声音一致的角色。忽略系统播报、纯物件、无身份群众和没有持续角色价值的一次性路人;合并同一角色的姓名、专属称谓和别名。正文未明确的信息必须写“正文未明确”,不得自行改变人物关系、时代背景或编造编号式姓名。角色设定要具体、稳定、可用于后续三视图和视频一致性控制。`, + }, + { + Operation: promptOperationCharacterTurnaround, Label: "角色三视图", Category: "角色", OutputType: "text", + Description: "按当前角色版本和项目画风生成正、侧、背三视图。", + Variables: []PromptTemplateVariable{{Label: "角色名称", Placeholder: "{{角色名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "角色设定", Placeholder: "{{角色设定}}"}}, + DefaultContent: `制作专业人物三视图设定表。画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。三个视角必须是同一角色、同一服装、同一发型、同一体型和同一比例,采用站立中性姿势,完整显示头顶到脚底。背景使用纯净中性浅色和均匀设定稿光线,只负责分离轮廓,不得改变项目画风的绘画或渲染媒介。禁止文字、边框、道具说明、表情变化和额外人物。`, + }, + } +} + +func protectedPromptContext(operation string, values map[string]string) string { + switch operation { + case promptOperationStoryboardPlan: + return storyboardProtectedContext(values) + case promptOperationStoryboardRepair: + return storyboardRepairProtectedContext(values) + case promptOperationCharacterExtract: + return characterExtractProtectedContext(values) + case promptOperationCharacterTurnaround: + return strings.Join([]string{"【角色名称】\n" + values["角色名称"], "【项目画风】\n" + values["项目画风"], "【角色设定】\n" + values["角色设定"]}, "\n\n") + default: + return "" + } +} + +func promptOutputContract(operation string) string { + switch operation { + case promptOperationStoryboardPlan, promptOperationStoryboardRepair: + return "服务端固定 JSON Schema storyboard-plan/v3(不可由运营模板或用户定制覆盖):\n" + storyboardPlanJSONSchema + case promptOperationCharacterExtract: + return "服务端固定 JSON Schema character-breakdown/v1(不可由运营模板或用户定制覆盖):\n" + characterBreakdownJSONSchema + default: + return "当前操作输出普通文本提示词,没有 JSON Schema。" + } +} + +const storyboardPlanJSONSchema = `{ + "type": "object", + "additionalProperties": false, + "required": ["title", "logline", "styleGuide", "characters", "locations", "shots"], + "properties": { + "title": {"type": "string"}, + "logline": {"type": "string"}, + "styleGuide": {"type": "string", "maxLength": 120}, + "characters": {"type": "array", "items": {"type": "string"}}, + "locations": {"type": "array", "items": {"type": "string"}}, + "shots": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetTags"], + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "durationSeconds": {"type": "integer", "minimum": 1, "maximum": 60}, + "dialogue": {"type": "string"}, + "characterIds": {"type": "array", "items": {"type": "string"}}, + "narrativeIntent": {"type": "string"}, + "viewerPOV": {"type": "string"}, + "performanceBlocking": {"type": "string"}, + "shotSize": {"type": "string"}, + "emotion": {"type": "string"}, + "lightingAndAtmosphere": {"type": "string"}, + "audioEffects": {"type": "string"}, + "visualPrompt": {"type": "string"}, + "videoPrompt": {"type": "string"}, + "camera": {"type": "string"}, + "motion": {"type": "string"}, + "timeBeats": {"type": "string"}, + "mustHave": {"type": "array", "maxItems": 3, "items": {"type": "string"}}, + "optionalDetails": {"type": "array", "items": {"type": "string"}}, + "continuityOut": {"type": "string"}, + "negativePrompt": {"type": "string"}, + "assetTags": {"type": "array", "items": {"type": "string"}} + } + } + } + } +}` + +const characterBreakdownJSONSchema = `{ + "type": "object", + "additionalProperties": false, + "required": ["characters"], + "properties": { + "characters": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "aliases", "role", "appearance", "clothing", "physique", "personality", "props", "consistencyPrompt", "multiViewPrompt", "voiceLanguage", "voiceAge", "voiceTimbre"], + "properties": { + "name": {"type": "string"}, + "aliases": {"type": "array", "items": {"type": "string"}}, + "role": {"type": "string"}, + "appearance": {"type": "string"}, + "clothing": {"type": "string"}, + "physique": {"type": "string"}, + "personality": {"type": "string"}, + "props": {"type": "string"}, + "consistencyPrompt": {"type": "string"}, + "multiViewPrompt": {"type": "string"}, + "voiceLanguage": {"type": "string"}, + "voiceAge": {"type": "string"}, + "voiceTimbre": {"type": "string"} + } + } + } + } +}` + +func storyboardProtectedContext(values map[string]string) string { + return strings.Join([]string{ + "【剧情】\n" + values["剧情"], + "【用户本次要求】\n" + values["用户要求"], + "【当前画布资产】\n" + values["画布资产"], + "【当前项目画风】\n" + values["项目画风"], + "【当前角色版本】\n" + values["角色版本"], + storyboardExecutionContract(values["单镜头时长规则"], values["镜头数量规则"]), + }, "\n\n") +} + +func storyboardRepairProtectedContext(values map[string]string) string { + return strings.Join([]string{ + "【原始校验错误】\n" + values["校验错误"], + "【当前项目画风】\n" + values["项目画风"], + "【当前角色版本】\n" + values["角色版本"], + storyboardExecutionContract(values["单镜头时长规则"], values["镜头数量规则"]), + "【需要修复的原始输出】\n" + values["原始输出"], + }, "\n\n") +} + +func storyboardExecutionContract(durationRule string, countRule string) string { + return `【受保护执行契约】 +- ` + durationRule + ` +- ` + countRule + ` +- 单镜头最多 2 名主要角色、1 个主运镜、1 条主要动作链、3 个 timeBeats 和 3 个 mustHave;超限必须拆镜或在固定镜头数内重新分配。 +- characterIds 只能引用当前角色版本中的 assetId;没有角色时返回空数组。 +- styleGuide 最多 120 个中文字符;visualPrompt 只描述首帧,videoPrompt 只描述运动和结尾状态。 +- 画幅比例由视频节点参数控制,提示词不得写入具体比例,也不要讨论画幅配置。 +- 只返回完整 JSON,不要 Markdown 或解释。 +- ` + promptOutputContract(promptOperationStoryboardPlan) +} + +func characterExtractProtectedContext(values map[string]string) string { + return strings.Join([]string{ + fmt.Sprintf("【任务】\n从短剧项目《%s》的章节“%s”提取角色。", values["项目名称"], values["章节名称"]), + "【项目画风】\n" + values["项目画风"], + "【章节正文】\n" + values["章节正文"], + "【受保护输出契约】\n" + promptOutputContract(promptOperationCharacterExtract) + "\n严格 JSON 示例:{\"characters\":[{\"name\":\"角色名\",\"aliases\":[],\"role\":\"剧情定位与人物关系\",\"appearance\":\"稳定外貌\",\"clothing\":\"固定服装\",\"physique\":\"体型体态\",\"personality\":\"表演基线\",\"props\":\"\",\"consistencyPrompt\":\"跨镜头一致性约束\",\"multiViewPrompt\":\"三视图结构重点\",\"voiceLanguage\":\"语言口音\",\"voiceAge\":\"声音年龄感\",\"voiceTimbre\":\"音色语速力度\"}]}", + }, "\n\n") +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 6f891db51..fce80e958 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -152,6 +152,15 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string if strings.TrimSpace(input.Prompt) == "" { input.Prompt = fallbackPrompt } + promptTemplateOperation := metadataString(input.Metadata, "promptTemplateOperation") + if promptTemplateOperation != "" { + values := metadataStringValues(input.Metadata["promptTemplateVariables"]) + compiled, compileErr := s.compilePrompt(userID, promptTemplateOperation, values) + if compileErr != nil { + return nil, fmt.Errorf("编译用户提示词失败:%w", compileErr) + } + input.Prompt = compiled.Content + } if strings.TrimSpace(input.Prompt) == "" { return nil, errors.New("prompt is required") } @@ -185,7 +194,11 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string case "image": return runImageTask(ctx, input) case "text": - return runTextTask(ctx, input) + result, taskErr := runTextTask(ctx, input) + if taskErr == nil && promptTemplateOperation != "" { + taskErr = validatePromptTemplateResult(promptTemplateOperation, result) + } + return result, taskErr case "video": return runVideoTask(ctx, input) case "audio": @@ -195,6 +208,18 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +func metadataStringValues(value any) map[string]string { + values := map[string]string{} + raw, ok := value.(map[string]interface{}) + if !ok { + return values + } + for key, item := range raw { + values[key] = strings.TrimSpace(fmt.Sprint(item)) + } + return values +} + func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, requirePublicURL bool) error { groups := [][]providerMedia{input.ReferenceImages, input.ReferenceVideos, input.ReferenceAudios} for _, group := range groups { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 25237b87e..e31647b14 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "os" "path/filepath" + "regexp" "strings" "sync" "time" @@ -40,13 +41,15 @@ const taskWorkerConcurrency = 3 const taskLogPayloadLimit = 4000 type CreateSessionRequest struct { - ProjectID string `json:"projectId"` - Prompt string `json:"prompt"` - CanvasSnapshot map[string]any `json:"canvasSnapshot"` - References []string `json:"references"` - Requirements string `json:"requirements"` - CanvasAssets []storyboardAsset `json:"canvasAssets"` - Config providerConfig `json:"config"` + ProjectID string `json:"projectId"` + Prompt string `json:"prompt"` + CanvasSnapshot map[string]any `json:"canvasSnapshot"` + References []string `json:"references"` + Requirements string `json:"requirements"` + CanvasAssets []storyboardAsset `json:"canvasAssets"` + ProjectStyle storyboardProjectStyle `json:"projectStyle"` + Characters []storyboardCharacterCard `json:"characters"` + Config providerConfig `json:"config"` } type CreateTaskRequest struct { @@ -111,21 +114,38 @@ type TaskListOptions struct { } type agentStoryboardInput struct { - References []string `json:"references"` - CanvasSnapshot map[string]any `json:"canvasSnapshot"` - Requirements string `json:"requirements"` - CanvasAssets []storyboardAsset `json:"canvasAssets"` - Config providerConfig `json:"config"` - ShotDuration int `json:"shotDurationSeconds"` - ShotCount int `json:"shotCount"` + References []string `json:"references"` + CanvasSnapshot map[string]any `json:"canvasSnapshot"` + Requirements string `json:"requirements"` + CanvasAssets []storyboardAsset `json:"canvasAssets"` + ProjectStyle storyboardProjectStyle `json:"projectStyle"` + Characters []storyboardCharacterCard `json:"characters"` + Config providerConfig `json:"config"` + ShotDuration int `json:"shotDurationSeconds"` + ShotCount int `json:"shotCount"` +} + +type storyboardProjectStyle struct { + PresetID string `json:"presetId"` + Title string `json:"title"` + Prompt string `json:"prompt"` +} + +type storyboardCharacterCard struct { + AssetID string `json:"assetId"` + VersionID string `json:"versionId"` + Name string `json:"name"` + Definition map[string]any `json:"definition"` } type storyboardAsset struct { - ID string `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Tags []string `json:"tags"` - Prompt string `json:"prompt"` + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Tags []string `json:"tags"` + Prompt string `json:"prompt"` + CharacterAssetID string `json:"characterAssetId,omitempty"` + CharacterVersionID string `json:"characterVersionId,omitempty"` } type agentStoryboardPlan struct { @@ -153,7 +173,14 @@ type agentStoryboardShot struct { Motion string `json:"motion"` TimeBeats string `json:"timeBeats"` Negative string `json:"negativePrompt"` - AssetTags []string `json:"assetTags"` + AssetTags []string `json:"assetTags"` + CharacterIDs []string `json:"characterIds"` + Intent string `json:"narrativeIntent"` + ViewerPOV string `json:"viewerPOV"` + Performance string `json:"performanceBlocking"` + MustHave []string `json:"mustHave"` + Optional []string `json:"optionalDetails"` + ContinuityOut string `json:"continuityOut"` } func New(repo *repository.Repository, dataDir string) *Service { @@ -202,6 +229,9 @@ func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*Sessi if prompt == "" { return nil, errors.New("prompt is required") } + if err := validateStoryboardContext(req.ProjectStyle, req.Characters); err != nil { + return nil, err + } compactedSnapshot := compactPersistedValue(req.CanvasSnapshot) snapshotJSON, _ := json.Marshal(compactedSnapshot) session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)} @@ -233,7 +263,7 @@ func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*Sessi return nil, err } s.storageMu.Unlock() - taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, Input: map[string]any{"references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "config": req.Config}} + taskReq := CreateTaskRequest{SessionID: session.ID, ProjectID: req.ProjectID, Type: "agent_storyboard", Operation: "storyboard", Prompt: prompt, Provider: "openai-compatible", Model: req.Config.Model, Input: map[string]any{"references": req.References, "canvasSnapshot": compactedSnapshot, "requirements": req.Requirements, "canvasAssets": req.CanvasAssets, "projectStyle": req.ProjectStyle, "characters": req.Characters, "config": req.Config}} if _, err := s.CreateTask(userID, taskReq); err != nil { s.storageMu.Lock() cleanupErr := s.repo.DeleteSessionDraft(userID, session.ID) @@ -1023,28 +1053,53 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas return nil, nil, fmt.Errorf("Agent 会话输入解析失败:%w", err) } } + if !providerConfigReady(input.Config) { + return nil, nil, errors.New("请先配置可用的文本模型") + } + if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { + return nil, nil, err + } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) } - plan := fallbackAgentStoryboardPlan(task.Prompt) - if providerConfigReady(input.Config) { - config, err := s.resolveProviderConfig(input.Config) - if err != nil { - return nil, nil, err - } - result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, 0, 0), Config: config}) - if err != nil { - return nil, nil, err - } - text, _ := result["text"].(string) - nextPlan, err := parseAgentStoryboardPlan(text) - if err != nil { - return nil, nil, err - } - plan = nextPlan + config, err := s.resolveProviderConfig(input.Config) + if err != nil { + return nil, nil, err } - return buildAgentStoryboardResult(task, plan, assets) + plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, 0, 0) + if err != nil { + return nil, nil, err + } + result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config}) + if err != nil { + return nil, nil, err + } + text, _ := result["text"].(string) + plan, err := parseAgentStoryboardPlan(text) + if err == nil { + err = validateStoryboardPlan(plan, 0, 0, input.Characters) + } + if err != nil { + _ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55) + repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, err, input, text) + if promptErr != nil { + return nil, nil, promptErr + } + repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config}) + if repairErr != nil { + return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr) + } + repairedText, _ := repaired["text"].(string) + plan, err = parseAgentStoryboardPlan(repairedText) + if err == nil { + err = validateStoryboardPlan(plan, 0, 0, input.Characters) + } + if err != nil { + return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err) + } + } + return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle) } func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task) (map[string]interface{}, []map[string]interface{}, error) { @@ -1057,6 +1112,9 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task if !providerConfigReady(input.Config) { return nil, nil, errors.New("请先配置可用的文本模型") } + if err := validateStoryboardContext(input.ProjectStyle, input.Characters); err != nil { + return nil, nil, err + } assets := input.CanvasAssets if len(assets) == 0 { assets = extractStoryboardAssets(input.CanvasSnapshot) @@ -1065,21 +1123,25 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task if err != nil { return nil, nil, err } - result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: s.buildAgentStoryboardPlannerPrompt(task.Prompt, input.Requirements, assets, input.ShotDuration, input.ShotCount), Config: config}) + plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount) + if err != nil { + return nil, nil, err + } + result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: plannerPrompt, Config: config}) if err != nil { return nil, nil, err } text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { - err = validateStoryboardShotDuration(plan, input.ShotDuration) - } - if err == nil { - err = validateStoryboardShotCount(plan, input.ShotCount) + err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters) } if err != nil { _ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55) - repairPrompt := fmt.Sprintf("请修复下面的分镜 JSON。原始校验错误:%s。必须保持原有剧情和镜头内容,补齐缺失字段并修复非法字段值;durationSeconds 必须是 1 到 60 的整数。\n\n%s\n\n只返回完整 JSON,不要 markdown 或解释。\n\n原始输出:\n%s", err.Error(), storyboardCinematicQualityContract(input.ShotDuration, input.ShotCount), text) + repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, err, input, text) + if promptErr != nil { + return nil, nil, promptErr + } repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config}) if repairErr != nil { return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr) @@ -1087,10 +1149,7 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task repairedText, _ := repaired["text"].(string) plan, err = parseAgentStoryboardPlan(repairedText) if err == nil { - err = validateStoryboardShotDuration(plan, input.ShotDuration) - } - if err == nil { - err = validateStoryboardShotCount(plan, input.ShotCount) + err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters) } if err != nil { return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err) @@ -1098,6 +1157,16 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task } rows := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { + imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) + videoPromptVariables := storyboardVideoPromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) + imagePrompt, promptErr := s.compileStoryboardImagePrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) + if promptErr != nil { + return nil, nil, promptErr + } + videoPrompt, promptErr := s.compileStoryboardVideoPrompt(task.UserID, input.ProjectStyle.Prompt, plan.StyleGuide, shot) + if promptErr != nil { + return nil, nil, promptErr + } matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) referenceNodeIDs := make([]string, 0, len(matchedAssets)) for _, asset := range matchedAssets { @@ -1105,10 +1174,13 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task } rows = append(rows, map[string]any{ "shotNumber": index + 1, "durationSeconds": shot.Duration, "plotDescription": shot.Description, - "dialogue": shot.Dialogue, "characters": []any{}, "shotSize": shot.ShotSize, "emotion": shot.Emotion, + "dialogue": shot.Dialogue, "characters": storyboardRowCharacters(shot, input.Characters), "shotSize": shot.ShotSize, "emotion": shot.Emotion, "lightingAndAtmosphere": shot.Lighting, "audioEffects": shot.AudioEffects, - "imageGenerationPrompt": shot.VisualPrompt, "videoMotionPrompt": buildStoryboardVideoPrompt(plan.StyleGuide, shot), + "imageGenerationPrompt": imagePrompt, "videoMotionPrompt": videoPrompt, + "imagePromptTemplateVariables": imagePromptVariables, "videoPromptTemplateVariables": videoPromptVariables, "camera": shot.Camera, "motion": shot.Motion, "timeBeats": shot.TimeBeats, "negativePrompt": shot.Negative, + "narrativeIntent": shot.Intent, "viewerPOV": shot.ViewerPOV, "performanceBlocking": shot.Performance, + "mustHave": shot.MustHave, "optionalDetails": shot.Optional, "continuityOut": shot.ContinuityOut, "referenceNodeIds": referenceNodeIDs, "assetTags": shot.AssetTags, }) } @@ -1124,23 +1196,36 @@ func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { if err != nil { return agentStoryboardPlan{}, err } + if err := validateStoryboardJSONFields(jsonText); err != nil { + return agentStoryboardPlan{}, err + } var plan agentStoryboardPlan if err := json.Unmarshal([]byte(jsonText), &plan); err != nil { return agentStoryboardPlan{}, fmt.Errorf("分镜 JSON 解析失败:%w", err) } plan.Title = defaultString(strings.TrimSpace(plan.Title), "影视分镜") plan.Logline = defaultString(strings.TrimSpace(plan.Logline), "根据剧情生成的分镜方案") - plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "真实电影机拍摄,保持角色、空间、道具、色彩和镜头语言一致。") + plan.StyleGuide = defaultString(strings.TrimSpace(plan.StyleGuide), "严格沿用当前项目画风,保持角色、空间、道具、色彩和视觉媒介一致。") if len(plan.Shots) == 0 { return agentStoryboardPlan{}, errors.New("分镜模型没有返回 shots") } if len(plan.Shots) > 12 { - plan.Shots = plan.Shots[:12] + return agentStoryboardPlan{}, fmt.Errorf("分镜数量最多 12 个,实际返回 %d 个", len(plan.Shots)) } for i := range plan.Shots { if strings.TrimSpace(plan.Shots[i].Title) == "" { plan.Shots[i].Title = fmt.Sprintf("镜头 %d", i+1) } + plan.Shots[i].CharacterIDs = nonNilStrings(plan.Shots[i].CharacterIDs) + plan.Shots[i].AssetTags = nonNilStrings(plan.Shots[i].AssetTags) + plan.Shots[i].Optional = nonNilStrings(plan.Shots[i].Optional) + plan.Shots[i].Intent = defaultString(strings.TrimSpace(plan.Shots[i].Intent), strings.TrimSpace(plan.Shots[i].Description)) + plan.Shots[i].ViewerPOV = defaultString(strings.TrimSpace(plan.Shots[i].ViewerPOV), "客观观察当前主要角色与事件") + plan.Shots[i].Performance = defaultString(strings.TrimSpace(plan.Shots[i].Performance), strings.TrimSpace(plan.Shots[i].Description)) + plan.Shots[i].ContinuityOut = defaultString(strings.TrimSpace(plan.Shots[i].ContinuityOut), "保持本镜头结尾的人物位置、动作状态、道具和光线方向进入下一镜") + if len(plan.Shots[i].MustHave) == 0 { + plan.Shots[i].MustHave = []string{"主要角色身份与当前版本稳定", "主要动作完成并有清晰落点", "结尾状态可供下一镜继承"} + } if strings.TrimSpace(plan.Shots[i].VideoPrompt) == "" { plan.Shots[i].VideoPrompt = defaultString(plan.Shots[i].VisualPrompt, plan.Shots[i].Description) } @@ -1157,6 +1242,50 @@ func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { return plan, nil } +func validateStoryboardJSONFields(jsonText string) error { + var root map[string]json.RawMessage + if err := json.Unmarshal([]byte(jsonText), &root); err != nil { + return fmt.Errorf("分镜 JSON 解析失败:%w", err) + } + for _, field := range []string{"title", "logline", "styleGuide", "characters", "locations", "shots"} { + if _, ok := root[field]; !ok { + return fmt.Errorf("分镜 JSON 缺少受保护字段 %s", field) + } + } + var shots []map[string]json.RawMessage + if err := json.Unmarshal(root["shots"], &shots); err != nil { + return errors.New("分镜 JSON 的 shots 必须是数组") + } + required := []string{"title", "description", "durationSeconds", "dialogue", "characterIds", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "visualPrompt", "videoPrompt", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt", "assetTags"} + for index, shot := range shots { + for _, field := range required { + if _, ok := shot[field]; !ok { + return fmt.Errorf("镜头 %d 缺少受保护字段 %s", index+1, field) + } + } + } + return nil +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func validateStoryboardContext(projectStyle storyboardProjectStyle, characters []storyboardCharacterCard) error { + if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" { + return errors.New("请先设置项目画风,再生成分镜") + } + for _, character := range characters { + if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { + return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") + } + } + return nil +} + func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error { if target == 0 { return nil @@ -1172,6 +1301,22 @@ func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error return nil } +func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard) error { + if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 { + return errors.New("styleGuide 最多 120 个中文字符") + } + if err := validateStoryboardShotDuration(plan, shotDuration); err != nil { + return err + } + if err := validateStoryboardShotCount(plan, shotCount); err != nil { + return err + } + if err := validateStoryboardCharacterIDs(plan, characters); err != nil { + return err + } + return validateStoryboardComplexity(plan) +} + func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { if target == 0 { return nil @@ -1185,6 +1330,93 @@ func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { return nil } +func validateStoryboardComplexity(plan agentStoryboardPlan) error { + issues := make([]string, 0) + for index, shot := range plan.Shots { + shotNumber := index + 1 + if len(shot.CharacterIDs) > 2 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, len(shot.CharacterIDs))) + } + if len(shot.MustHave) > 3 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave))) + } + if beats := storyboardBeatCount(shot.TimeBeats); beats > 3 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个时间节拍,最多 3 个", shotNumber, beats)) + } + if movements := storyboardCameraMovementCount(shot.Motion); movements > 1 { + issues = append(issues, fmt.Sprintf("镜头 %d 包含 %d 种主运镜,最多 1 种", shotNumber, movements)) + } + dialogueLimit := max(24, shot.Duration*5) + if dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)); dialogueLength > dialogueLimit { + issues = append(issues, fmt.Sprintf("镜头 %d 台词 %d 字,%d 秒镜头最多约 %d 字", shotNumber, dialogueLength, shot.Duration, dialogueLimit)) + } + } + if len(issues) == 0 { + return nil + } + return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";")) +} + +func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { + allowed := make(map[string]bool, len(characters)) + for _, character := range characters { + allowed[character.AssetID] = true + } + for index, shot := range plan.Shots { + for _, assetID := range shot.CharacterIDs { + if !allowed[assetID] { + return fmt.Errorf("镜头 %d 引用了不存在或非当前版本的角色 assetId:%s", index+1, assetID) + } + } + } + return nil +} + +func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any { + byID := make(map[string]storyboardCharacterCard, len(characters)) + for _, character := range characters { + byID[character.AssetID] = character + } + result := make([]map[string]any, 0, len(shot.CharacterIDs)) + for _, assetID := range shot.CharacterIDs { + character, ok := byID[assetID] + if !ok { + continue + } + result = append(result, map[string]any{ + "characterName": character.Name, + "characterAssetId": character.AssetID, + "characterVersionId": character.VersionID, + }) + } + return result +} + +func storyboardBeatCount(value string) int { + parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' }) + count := 0 + for _, part := range parts { + if strings.TrimSpace(part) != "" { + count++ + } + } + timecodeCount := len(storyboardTimecodePattern.FindAllString(value, -1)) + return max(count, timecodeCount) +} + +var storyboardTimecodePattern = regexp.MustCompile(`\d+(?:\.\d+)?\s*[-~—至到]\s*\d+(?:\.\d+)?\s*秒`) + +func storyboardCameraMovementCount(value string) int { + movements := []string{"推进", "推近", "拉远", "摇摄", "横移", "侧移", "跟拍", "跟随", "升降", "上升", "下降", "环绕", "俯冲", "变焦", "甩镜", "穿越"} + count := 0 + for _, movement := range movements { + if strings.Contains(value, movement) { + count++ + } + } + return count +} + func extractJSONText(raw string) (string, error) { trimmed := strings.TrimSpace(raw) trimmed = strings.TrimPrefix(trimmed, "```json") @@ -1193,49 +1425,12 @@ func extractJSONText(raw string) (string, error) { start := strings.Index(trimmed, "{") end := strings.LastIndex(trimmed, "}") if start < 0 || end < start { - return "", errors.New("分镜模型返回的不是 JSON") + return "", errors.New("模型返回的不是 JSON") } return trimmed[start : end+1], nil } -func fallbackAgentStoryboardPlan(prompt string) agentStoryboardPlan { - title := shortTitle(prompt, 18) - return agentStoryboardPlan{ - Title: title, - Logline: "围绕用户 brief 拆解的影视短片工作流。", - StyleGuide: "真实电影机拍摄,自然曝光,低饱和色彩,保持角色、空间、道具和镜头语言一致。", - Characters: []string{"主角:根据 brief 保持服装、动作动机和情绪连续。"}, - Locations: []string{"主场景:根据 brief 建立前景、中景、远景和可信光源。"}, - Shots: []agentStoryboardShot{ - { - Title: "开场建立", - Description: "建立故事空间、主角状态和情绪基调。", - VisualPrompt: "以真实电影机语言建立主要空间和角色状态,前景、中景、远景层次清晰。", - VideoPrompt: "8 秒连续镜头,从故事空间中的人类尺度前景开始,摄影机缓慢前推,先展示环境细节和主角状态,中段让关键冲突迹象进入画面,结尾停在主角反应。自然曝光,真实高光滚降,空气介质和轻微胶片颗粒,避免廉价特效感、均匀平光和无尺度参照。", - Camera: "中景,平视到轻微低机位,中等焦段", - Motion: "缓慢前推,结尾停住", - }, - { - Title: "冲突推进", - Description: "推进动作、关系变化和核心冲突。", - VisualPrompt: "主体动作、道具和环境反馈同时出现,空间调度明确。", - VideoPrompt: "10 秒连续镜头,摄影机从主角侧后方跟随移动,开始画面聚焦人物动作和关键道具,中段冲突升级,环境中的灯光、尘埃、水汽或人群反应随动作发生变化,结尾用中近景压住情绪。真实电影机拍摄,受控冷暖对比,运动处保留自然模糊,避免过度锐化、过亮轮廓和塑料表面。", - Camera: "中近景,侧后方跟拍,中长焦压缩空间", - Motion: "跟拍加轻微抬镜", - }, - { - Title: "结果与钩子", - Description: "交代结果并留下下一段钩子。", - VisualPrompt: "主角反应、环境后果和悬念信息同框。", - VideoPrompt: "8 秒连续镜头,从冲突后的环境细节开始,摄影机缓慢横移揭示结果,中段主角进入画面并完成关键反应,结尾停在一个可延续的悬念物或空间方向。真实电影摄影质感,暗部保留层次,高光不过曝,低饱和色彩,避免海报式摆拍、干净空白背景和主体完整居中平铺。", - Camera: "中景到近景,横移构图", - Motion: "缓慢横移,结尾定格", - }, - }, - } -} - -func buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset) (map[string]interface{}, []map[string]interface{}, error) { +func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, assets []storyboardAsset, projectStyle storyboardProjectStyle) (map[string]interface{}, []map[string]interface{}, error) { prefix := "agent-" + task.ID scriptID := prefix + "-script" sceneID := prefix + "-scenes" @@ -1247,15 +1442,19 @@ func buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, asset ops := []map[string]any{ nodeOpWithMetadata(scriptID, "text", "剧本 · "+shortTitle(plan.Title, 24), 0, 0, map[string]any{"workflowKind": "script", "workflowTitle": "剧本", "status": "success", "content": strings.Join([]string{plan.Title, "", plan.Logline, "", task.Prompt}, "\n")}), nodeOpWithMetadata(sceneID, "text", "场景设定", sceneX, 0, map[string]any{"workflowKind": "scene", "workflowTitle": "场景", "status": "success", "content": listContent("场景", plan.Locations)}), - nodeOpWithMetadata(styleID, "text", "风格板", styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "风格板", "status": "success", "content": plan.StyleGuide}), + nodeOpWithMetadata(styleID, "text", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "status": "success", "content": projectStyle.Prompt, "prompt": projectStyle.Prompt}), nodeOpWithMetadata(referenceID, "text", "参考素材组", 0, 270, map[string]any{"workflowKind": "reference_set", "workflowTitle": "参考素材组", "status": "success", "content": storyboardAssetsContent(assets)}), nodeOpWithMetadata(finalID, "video", "成片 · 待生成", styleX, 270, map[string]any{"workflowKind": "final", "workflowTitle": "成片", "status": "idle"}), connectOp(scriptID, sceneID), } resultShots := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { + videoPrompt, err := s.compileStoryboardVideoPrompt(task.UserID, projectStyle.Prompt, plan.StyleGuide, shot) + if err != nil { + return nil, nil, err + } shotID := fmt.Sprintf("%s-shot-%d", prefix, index+1) - matchedAssets := matchStoryboardAssets(assets, shot.AssetTags) + matchedAssets := matchStoryboardAssetsForShot(assets, shot) assetIDs := make([]string, 0, len(matchedAssets)) for _, asset := range matchedAssets { assetIDs = append(assetIDs, asset.ID) @@ -1267,9 +1466,9 @@ func buildAgentStoryboardResult(task model.Task, plan agentStoryboardPlan, asset "workflowDescription": shotDescription(shot), "shotIndex": index + 1, "generationMode": "video", - "prompt": buildStoryboardVideoPrompt(plan.StyleGuide, shot), - "composerContent": shotComposerContent(buildStoryboardVideoPrompt(plan.StyleGuide, shot), matchedAssets), - "videoEditOperation": "image_to_video", + "prompt": videoPrompt, + "composerContent": shotComposerContent(videoPrompt, matchedAssets), + "videoEditOperation": "text_to_video", "assetTags": shot.AssetTags, "referenceAssetNodeIds": assetIDs, "status": "idle", @@ -1303,13 +1502,18 @@ func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset { assets := make([]storyboardAsset, 0, len(rawNodes)) for _, raw := range rawNodes { node, _ := raw.(map[string]interface{}) - if node == nil || fmt.Sprint(node["type"]) != "image" { + if node == nil { continue } metadata, _ := node["metadata"].(map[string]interface{}) if metadata == nil { metadata = map[string]interface{}{} } + nodeType := stringValue(node["type"]) + isCharacterCard := stringValue(metadata["workflowKind"]) == "character" && stringValue(metadata["characterAssetId"]) != "" && stringValue(metadata["characterVersionId"]) != "" + if nodeType != "image" && !isCharacterCard { + continue + } id := stringValue(node["id"]) if id == "" { continue @@ -1317,10 +1521,18 @@ func extractStoryboardAssets(snapshot map[string]any) []storyboardAsset { tags := stringSlice(metadata["assetTags"]) prompt := stringValue(metadata["prompt"]) content := stringValue(metadata["content"]) - if len(tags) == 0 && prompt == "" && content == "" { + if len(tags) == 0 && prompt == "" && content == "" && !isCharacterCard { continue } - assets = append(assets, storyboardAsset{ID: id, Title: defaultString(stringValue(node["title"]), "未命名图片"), Type: "image", Tags: tags, Prompt: prompt}) + assets = append(assets, storyboardAsset{ + ID: id, + Title: defaultString(stringValue(node["title"]), "未命名图片"), + Type: defaultString(nodeType, "reference"), + Tags: tags, + Prompt: prompt, + CharacterAssetID: stringValue(metadata["characterAssetId"]), + CharacterVersionID: stringValue(metadata["characterVersionId"]), + }) if len(assets) >= 30 { break } @@ -1359,6 +1571,34 @@ func matchStoryboardAssets(assets []storyboardAsset, shotTags []string) []storyb return matched } +func matchStoryboardAssetsForShot(assets []storyboardAsset, shot agentStoryboardShot) []storyboardAsset { + matched := make([]storyboardAsset, 0, 6) + seen := make(map[string]bool, 6) + wantedCharacters := make(map[string]bool, len(shot.CharacterIDs)) + for _, assetID := range shot.CharacterIDs { + wantedCharacters[assetID] = true + } + for _, asset := range assets { + if len(matched) >= 6 { + break + } + if !seen[asset.ID] && wantedCharacters[asset.CharacterAssetID] { + matched = append(matched, asset) + seen[asset.ID] = true + } + } + for _, asset := range matchStoryboardAssets(assets, shot.AssetTags) { + if len(matched) >= 6 { + break + } + if !seen[asset.ID] { + matched = append(matched, asset) + seen[asset.ID] = true + } + } + return matched +} + func storyboardTokensMatch(wanted map[string]bool, tokens map[string]bool) bool { for want := range wanted { if tokens[want] { @@ -1458,24 +1698,89 @@ func shotDescription(shot agentStoryboardShot) string { return strings.Join(filtered, "\n\n") } -func buildStoryboardVideoPrompt(styleGuide string, shot agentStoryboardShot) string { +func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { + negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") + return map[string]string{ + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), + "表演起始状态": compactPromptText(shot.Performance, 180), + "负面要求": compactPromptText(negative, 140), + } +} + +func buildStoryboardImagePrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { + definition, _ := promptDefinition(promptOperationStoryboardFirstFrame) + prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardImagePromptValues(projectStyle, styleGuide, shot)) + return prompt +} + +func (s *Service) compileStoryboardImagePrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { + compiled, err := s.compilePrompt(userID, promptOperationStoryboardFirstFrame, storyboardImagePromptValues(projectStyle, styleGuide, shot)) + return compiled.Content, err +} + +func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { camera := defaultString(strings.TrimSpace(shot.Camera), strings.TrimSpace(shot.ShotSize)+",平视机位,中等焦段,主体与环境保持空间层次") motion := defaultString(strings.TrimSpace(shot.Motion), "固定机位,主体在画面内完成动作") timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") - parts := []string{ - "【氛围与画质】\n" + strings.TrimSpace(styleGuide), - "【镜头设计】\n" + strings.TrimSpace(shot.ShotSize) + ";" + camera + ";运镜:" + motion, - "【画面内容】\n" + timeBeats, + values := map[string]string{ + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), + "表演与调度": compactPromptText(shot.Performance, 180), + "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), + "时间节拍": compactPromptText(timeBeats, 240), + "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), + "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), + "负面要求": compactPromptText(negative, 160), } - if strings.TrimSpace(shot.Dialogue) != "" || strings.TrimSpace(shot.AudioEffects) != "" { - parts = append(parts, "【台词/声音】\n"+strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects)) + if len(shot.MustHave) > 0 { + priority := "必须完成:" + strings.Join(shot.MustHave, ";") + if len(shot.Optional) > 0 { + priority += "。可以简化:" + strings.Join(shot.Optional, ";") + } + values["执行优先级"] = compactPromptText(priority, 140) } - if strings.TrimSpace(shot.VideoPrompt) != "" { - parts = append(parts, "【执行约束】\n"+strings.TrimSpace(shot.VideoPrompt)) + return values +} + +func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { + definition, _ := promptDefinition(promptOperationStoryboardVideo) + prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) + return prompt +} + +func (s *Service) compileStoryboardVideoPrompt(userID string, projectStyle string, styleGuide string, shot agentStoryboardShot) (string, error) { + compiled, err := s.compilePrompt(userID, promptOperationStoryboardVideo, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) + return compiled.Content, err +} + +func storyboardProjectVisualSummary(projectStyle string, styleGuide string) string { + identity := "" + for _, line := range strings.Split(projectStyle, "\n") { + if strings.TrimSpace(line) != "" { + identity = strings.TrimSpace(line) + break + } } - parts = append(parts, "【负面要求】\n"+negative) - return strings.Join(parts, "\n\n") + parts := make([]string, 0, 2) + if identity != "" { + parts = append(parts, identity) + } + if strings.TrimSpace(styleGuide) != "" { + parts = append(parts, strings.TrimSpace(styleGuide)) + } + return compactPromptText(strings.Join(parts, ";"), 180) +} + +func compactPromptText(value string, limit int) string { + text := strings.TrimSpace(value) + if utf8.RuneCountInString(text) <= limit { + return text + } + runes := []rune(text) + return strings.TrimSpace(string(runes[:limit])) + "。" } func shotComposerContent(prompt string, assets []storyboardAsset) string { diff --git a/backend/internal/service/storyboard_prompt.go b/backend/internal/service/storyboard_prompt.go index a458dfc84..75b66010f 100644 --- a/backend/internal/service/storyboard_prompt.go +++ b/backend/internal/service/storyboard_prompt.go @@ -4,224 +4,81 @@ import ( "encoding/json" "fmt" "strings" - - "infinite-canvas/backend/internal/model" ) -type StoryboardPromptTemplateRequest struct { - Name string `json:"name"` - Content string `json:"content"` - Enabled *bool `json:"enabled"` -} - -type StoryboardPromptVariable struct { - Label string `json:"label"` - Placeholder string `json:"placeholder"` -} - -var storyboardPromptVariables = []StoryboardPromptVariable{ - {Label: "剧情", Placeholder: "{{剧情}}"}, - {Label: "用户要求", Placeholder: "{{用户要求}}"}, - {Label: "画布资产", Placeholder: "{{画布资产}}"}, -} - -func (s *Service) EnsureDefaultStoryboardPromptTemplate() error { - count, err := s.repo.StoryboardPromptTemplateCount() +func (s *Service) buildAgentStoryboardPlannerPrompt(userID string, brief string, requirements string, assets []storyboardAsset, projectStyle storyboardProjectStyle, characters []storyboardCharacterCard, shotDuration int, shotCount int) (string, error) { + values := storyboardPromptValues(brief, requirements, assets, projectStyle, characters, shotDuration, shotCount) + compiled, err := s.compilePrompt(userID, promptOperationStoryboardPlan, values) if err != nil { - return err + return "", err } - if count > 0 { - // 只升级仍保留旧占位符的内置模板,避免覆盖管理员创建或修改的版本。 - templates, err := s.repo.StoryboardPromptTemplates() - if err != nil { - return err - } - for index := range templates { - template := &templates[index] - if template.Name == "默认影视分镜提示词" && strings.Contains(template.Content, "\n....\n") { - template.Content = defaultStoryboardPromptTemplate() - return s.repo.SaveStoryboardPromptTemplate(template) - } - } - return nil - } - return s.repo.SaveStoryboardPromptTemplate(&model.StoryboardPromptTemplate{ - ID: newID(), - Name: "默认影视分镜提示词", - Content: defaultStoryboardPromptTemplate(), - Enabled: true, - }) + return removeFixedMediaRestrictions(compiled.Content), nil } -func (s *Service) AdminStoryboardPromptTemplates(actor *model.User) ([]model.StoryboardPromptTemplate, []StoryboardPromptVariable, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, nil, err - } - templates, err := s.repo.StoryboardPromptTemplates() +func (s *Service) buildStoryboardRepairPrompt(userID string, validationErr error, input agentStoryboardInput, original string) (string, error) { + values := storyboardPromptValues("", input.Requirements, input.CanvasAssets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount) + values["校验错误"] = validationErr.Error() + values["原始输出"] = original + compiled, err := s.compilePrompt(userID, promptOperationStoryboardRepair, values) if err != nil { - return nil, nil, err + return "", err } - return templates, storyboardPromptVariables, nil + return removeFixedMediaRestrictions(compiled.Content), nil } -func (s *Service) CreateStoryboardPromptTemplate(actor *model.User, req StoryboardPromptTemplateRequest) (*model.StoryboardPromptTemplate, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err +func storyboardPromptValues(brief string, requirements string, assets []storyboardAsset, projectStyle storyboardProjectStyle, characters []storyboardCharacterCard, shotDuration int, shotCount int) map[string]string { + assetJSON, _ := json.MarshalIndent(assets, "", " ") + characterJSON, _ := json.MarshalIndent(characters, "", " ") + styleText := strings.TrimSpace(projectStyle.Prompt) + if styleText == "" { + styleText = "项目尚未设置画风;仅允许生成中性分镜草案,不得擅自指定视觉媒介。" } - template, err := storyboardPromptTemplateFromRequest(req, model.StoryboardPromptTemplate{ID: newID(), CreatedBy: actor.ID}) - if err != nil { - return nil, err - } - if req.Enabled == nil { - template.Enabled = false - } - if err := s.repo.SaveStoryboardPromptTemplate(&template); err != nil { - return nil, err - } - return &template, nil -} - -func (s *Service) UpdateStoryboardPromptTemplate(actor *model.User, id string, req StoryboardPromptTemplateRequest) (*model.StoryboardPromptTemplate, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - template, err := s.repo.StoryboardPromptTemplate(id) - if err != nil { - return nil, err - } - if template.Enabled && req.Enabled != nil && !*req.Enabled { - return nil, BadAuthRequest("至少需要保留一个启用的分镜提示词") - } - next, err := storyboardPromptTemplateFromRequest(req, *template) - if err != nil { - return nil, err - } - if err := s.repo.SaveStoryboardPromptTemplate(&next); err != nil { - return nil, err - } - return &next, nil -} - -func (s *Service) DeleteStoryboardPromptTemplate(actor *model.User, id string) error { - if err := s.RequireAdmin(actor); err != nil { - return err - } - template, err := s.repo.StoryboardPromptTemplate(id) - if err != nil { - return err - } - if template.Enabled { - return BadAuthRequest("启用中的分镜提示词不能删除,请先启用其他版本") - } - return s.repo.DeleteStoryboardPromptTemplate(id) -} - -func (s *Service) buildAgentStoryboardPlannerPrompt(brief string, requirements string, assets []storyboardAsset, shotDuration int, shotCount int) string { - template := defaultStoryboardPromptTemplate() - if active, err := s.repo.ActiveStoryboardPromptTemplate(); err == nil && strings.TrimSpace(active.Content) != "" { - template = active.Content - } - return renderStoryboardPromptTemplate(template, brief, requirements, assets) + "\n\n" + storyboardCinematicQualityContract(shotDuration, shotCount) -} - -func storyboardCinematicQualityContract(shotDuration int, shotCount int) string { durationRule := "单个镜头时长由剧情节奏决定,必须是 1 到 60 秒的整数。" if shotDuration == 5 || shotDuration == 10 || shotDuration == 15 || shotDuration == 30 { - durationRule = fmt.Sprintf("本次生成单个镜头时长必须严格等于 %d 秒;模型只决定镜头数量和总时长,不得修改单镜头时长。", shotDuration) + durationRule = fmt.Sprintf("本次生成单个镜头时长必须严格等于 %d 秒。", shotDuration) } countRule := "镜头数量由模型按剧情节奏自动决定。" if shotCount >= 1 && shotCount <= 10 { - countRule = fmt.Sprintf("shots 数组必须严格输出 %d 个镜头,不得多于或少于 %d 个;该规则覆盖模板中的默认镜头数量范围。", shotCount, shotCount) + countRule = fmt.Sprintf("shots 数组必须严格输出 %d 个镜头。", shotCount) + } + return map[string]string{ + "项目名称": projectStyle.Title, "剧情": strings.TrimSpace(brief), "用户要求": strings.TrimSpace(requirements), + "画布资产": string(assetJSON), "项目画风": styleText, "角色版本": string(characterJSON), + "单镜头时长规则": durationRule, "镜头数量规则": countRule, } - contract := `强制电影化质量契约(优先级高于用户 brief 中的泛化叙述): -- ` + durationRule + ` -- ` + countRule + ` -- 不要把剧情段落直接改写成镜头摘要。每一行必须是一个可执行、可拍摄的连续镜头,必须写清主体在画面中的具体动作、空间关系、视觉焦点和可见结果。 -- 每个镜头必须有摄影机设计:shotSize、camera(机位高度/角度/焦段/景别/构图)、motion(推拉摇移跟/环绕/升降/手持及起止方向)。镜头之间要有远近景、角度或运动变化,避免同质中景。 -- timeBeats 必须按时间码拆解该镜头内部动作,例如“0-1.2秒:...;1.2-4.5秒:...;4.5-8秒:...”,覆盖完整 durationSeconds,包含开始画面、中段变化和结尾停点。 -- description 只写可见画面与动作,不使用“意识到、回忆起、感到、关系建立、命运转折”等不可直接拍摄的剧情总结;这些内容必须转译成眼神、停顿、手部动作、走位、道具反应或环境变化。 -- visualPrompt 必须包含主体、前中后景、构图、光线、材质、色彩和真实摄影质感;videoPrompt 必须补充主体运动、环境运动、连续性、时长和转场逻辑。 -- styleGuide、visualPrompt 和 videoPrompt 禁止写入 2.39:1、16:9 等具体画幅比例,也不要讨论画幅配置。 -- negativePrompt 必须针对本镜头列出换脸、服装变化、手部错误、乱码、闪烁、风格突变、动作僵硬等风险,不要只写“无”。 -- 只返回 JSON。shots 中每项必须包含:title、description、durationSeconds、dialogue、shotSize、emotion、lightingAndAtmosphere、audioEffects、visualPrompt、videoPrompt、camera、motion、timeBeats、negativePrompt、assetTags。` - - return contract + "\n\n" + storyboardCameraLanguageGuide() } -func storyboardCameraLanguageGuide() string { - return `分镜镜头语言规则(必须融入每个镜头的设计,服务叙事而非炫技): -- 景别必须从以下六类中选择并写入 shotSize:S01 大远景 ELS(环境/宏大空间)、S02 远景 LS(人物全身与处境)、S03 全景 FS(完整动作)、S04 中景 MS(自然叙事/对话首选)、S05 近景 CU(表情与反应)、S06 特写 ECU(眼睛、手或关键细节)。同一场景按叙事需要渐进变化,避免无逻辑跳跃。 -- 机位角度按叙事需要选择:A01 平视 Eye Level(中性客观)、A02 仰角 Low Angle(强大/压迫)、A03 俯角 High Angle(弱小/脆弱)、A04 鸟瞰 Bird's Eye/Top Down(全知/构图)、A05 倾斜角 Dutch Angle(失衡/不安)、A06 蜗牛视角 Worm's Eye(极度崇高或压迫)。无动机不得突变视角。 -- 运镜从以下体系中按情绪和空间目的选择,并在 motion 写清起止状态:M01 推、M02 拉、M03 横移、M04 水平摇、M05 垂直倾斜、M06 升降、M07 环绕、M08 甩镜、M09 手持、M10 稳定器跟拍、M11 主观跟进、M12 航拍推进、M13 航拍拉升、M14 变焦、M15 希区柯克变焦、M16 过肩。运动镜头必须有动机;以静制动,固定镜头与运动镜头要形成对比。 -- 构图优先从以下方法选择并写入 camera 或 visualPrompt:C01 三分法、C02 框中框、C03 引导线、C04 对称构图、C05 负空间、C06 前景叠层。必须交代主体、前景/中景/远景和视觉焦点。 -- 叙事手法按需要使用:N01 主观镜头、N02 反应镜头、N03 空镜/插入、N04 匹配剪辑、N05 交叉剪辑、N06 跳切。对话优先 MS + OTS(过肩)+ 反应镜头,不要用复杂运动掩盖表演。 -- 节奏约束:推/拉/横移/升降等运动镜头通常保持 5-8 秒以上;甩镜和希区柯克变焦全片各不超过 1-2 次;手持只用于纪实、紧迫或混乱等明确情绪;航拍主要用于开篇或高潮;ECU 每场景最多 2 个。 -- 禁止每 3-5 秒更换一次运镜,禁止同类技法连续重复,禁止无叙事动机的角度突变;希区柯克变焦仅用于顿悟、恐惧或心理扭转等关键时刻。` -} - -func storyboardPromptTemplateFromRequest(req StoryboardPromptTemplateRequest, template model.StoryboardPromptTemplate) (model.StoryboardPromptTemplate, error) { - name := strings.TrimSpace(req.Name) - content := strings.TrimSpace(req.Content) - if name == "" { - return template, BadAuthRequest("请填写提示词名称") +// 兼容历史模板中强制真人媒介的冲突规则;项目画风才是视觉媒介的唯一来源。 +func removeFixedMediaRestrictions(template string) string { + lines := strings.Split(template, "\n") + filtered := lines[:0] + for _, line := range lines { + mentionsPrompt := strings.Contains(line, "visualPrompt") || strings.Contains(line, "videoPrompt") + forbidsNonLiveAction := strings.Contains(line, "不要") || strings.Contains(line, "禁止") || strings.Contains(line, "不得") + mentionsAnimation := strings.Contains(line, "3D") || strings.Contains(line, "三维") || strings.Contains(line, "动画") || strings.Contains(line, "卡通") || strings.Contains(line, "游戏CG") + forcesLiveAction := strings.Contains(line, "默认使用真实电影") || strings.Contains(line, "统一使用真实电影") || strings.Contains(line, "必须使用真实电影") + if (mentionsPrompt && forbidsNonLiveAction && mentionsAnimation) || forcesLiveAction { + continue + } + filtered = append(filtered, line) } - if content == "" { - return template, BadAuthRequest("请填写提示词内容") - } - template.Name = name - template.Content = content - if req.Enabled != nil { - template.Enabled = *req.Enabled - } - return template, nil -} - -func renderStoryboardPromptTemplate(template string, brief string, requirements string, assets []storyboardAsset) string { - assetJSON, _ := json.MarshalIndent(assets, "", " ") - replacer := strings.NewReplacer( - "{{剧情}}", strings.TrimSpace(brief), - "{{用户brief}}", strings.TrimSpace(brief), - "{{用户要求}}", strings.TrimSpace(requirements), - "{{画布资产}}", string(assetJSON), - "{{获取当前画布资产}}", string(assetJSON), - ) - return replacer.Replace(template) + return strings.Join(filtered, "\n") } func defaultStoryboardPromptTemplate() string { - codeFence := "```" - return `你是影视分镜导演和 AI 视频提示词专家。 - 请根据用户剧情 brief 和当前画布资产标签,生成可直接用于 AI 视频生成的分镜 JSON。 + return `你是影视分镜导演和 AI 视频提示词专家。先理解故事目标、人物动机、冲突、情绪曲线和结尾,再把剧情转译为可执行、可拍摄的连续镜头,不要把剧情段落直接改写成镜头摘要。 -创作方法参考成熟分镜提示词实践: -- 先理解故事目标、人物动机、冲突、情绪曲线和结尾。 -- 每个镜头都要明确主体、动作、场景、景别、构图、镜头焦段、机位、运镜、光线、色彩、质感、时长感和转场。 -- durationSeconds 必须是 1 到 60 的整数;没有台词、旁白或音效时返回空字符串。 - - 需要保持角色、服装、道具、空间和风格一致;能复用画布资产时,在 assetTags 中引用对应标签。 - - characters 必须逐角色输出,格式固定为“角色名:剧情定位、外貌、服装、体态、道具、性格动机和跨镜头一致性约束”,不要把多个角色合并成一项。 -- videoPrompt 必须是完整中文视频生成提示词,不要只写关键词。 -- videoPrompt 必须包含镜头时长、开始画面、中段变化、结尾画面、摄影机运动、主体运动、环境运动、可信光源、色彩基调、真实摄影质感和负面视觉约束。 -- 优先使用真实电影机语言:低机位/平视/俯拍、焦段、景深、自然曝光、空气介质、胶片颗粒、真实高光滚降。 -- 不要把主体完整居中平铺;需要有前景/中景/远景和人、物、空间尺度参照。 -- 不要在 visualPrompt 或 videoPrompt 中使用 3D动漫、动画、二次元、游戏CG、游戏截图、角色原画、概念设计图、手办感、卡通渲染、插画风等媒介限定词。 -- assetTags 只能引用当前画布资产里已有的标签或标签值;没有可复用资产时返回空数组。 +每个镜头先确定 narrativeIntent、viewerPOV 和 performanceBlocking,再选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。 - 用户brief: -` + codeFence + ` -{{剧情}} -` + codeFence + ` +大远景只承担空间、规模和处境建立;人物对白与喜剧反应优先使用中景、近景、过肩或独立反应镜头。按叙事需要使用反应、停顿、空镜、插入和匹配剪辑,避免每镜都推进、环绕、航拍或慢动作。 -用户要求: -` + codeFence + ` -{{用户要求}} -` + codeFence + ` +description 只写可见画面与动作;把“意识到、回忆起、感到”等不可见信息转译成眼神、停顿、手部动作、走位、道具反应或环境变化。visualPrompt 明确人物左右位置、视线、前中后景、遮挡、视觉焦点、可信光源、材质和色彩;videoPrompt 只补充主体运动、环境运动和结尾状态。 -当前画布资产:{{画布资产}} - - 格式: -{"title":"项目标题","logline":"一句话故事","styleGuide":"整体摄影、光线、色彩、质感和一致性规则","characters":["张三:男主角,28岁,短黑发,深灰夹克,清瘦挺拔,随身旧怀表,克制果断;所有镜头保持五官、发型、服装和怀表一致"],"locations":["场景与空间描述"],"shots":[{"title":"镜头标题","description":"可拍摄的主体动作、空间关系和可见结果","durationSeconds":8,"dialogue":"台词或旁白","shotSize":"中近景","emotion":"克制紧张","lightingAndAtmosphere":"侧逆光与薄雾","audioEffects":"环境风声","visualPrompt":"包含前中后景、构图、光线、材质和真实摄影质感的图片提示词","videoPrompt":"补充主体运动、环境运动、连续性、时长和转场逻辑","camera":"平视略低机位,50mm,中近景,前景遮挡,三分法构图","motion":"0-1秒固定,1-5秒缓慢推近,5-8秒轻微横移后停住","timeBeats":"0-1秒:建立画面;1-5秒:主体完成关键动作;5-8秒:反应和结尾停点","negativePrompt":"禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变、动作僵硬","assetTags":["角色:张三"]}]} - -特别注意: -- 只返回 JSON,不要 markdown,不要解释。 -- shots 输出 3 到 8 个;如果后续强制电影化质量契约指定了镜头数量,以契约为准。` +保持角色五官、年龄、发型、服装、道具、损伤位置、空间关系和项目媒介一致。视觉媒介、光线和材质严格服从项目画风,不得默认改写为真人摄影,也不得把 3D、动画或卡通项目强制改成写实。负面要求必须针对当前镜头的换脸、服装变化、手部错误、乱码、闪烁、穿模、风格突变和动作僵硬风险。` +} + +// 保留为纯函数,供分镜校验测试验证受保护契约不会被模板或用户定制覆盖。 +func storyboardCinematicQualityContract(shotDuration int, shotCount int) string { + values := storyboardPromptValues("", "", nil, storyboardProjectStyle{}, nil, shotDuration, shotCount) + return defaultStoryboardPromptTemplate() + "\n\n" + storyboardExecutionContract(values["单镜头时长规则"], values["镜头数量规则"]) } diff --git a/backend/internal/service/storyboard_prompt_test.go b/backend/internal/service/storyboard_prompt_test.go index 050b23193..9637d71fd 100644 --- a/backend/internal/service/storyboard_prompt_test.go +++ b/backend/internal/service/storyboard_prompt_test.go @@ -17,7 +17,7 @@ func TestStoryboardCinematicQualityContractIncludesRequestedCountAndDuration(t * func TestStoryboardCinematicQualityContractIncludesCameraLanguageGuide(t *testing.T) { contract := storyboardCinematicQualityContract(0, 0) - for _, term := range []string{"S01 大远景 ELS", "A05 倾斜角 Dutch Angle", "M15 希区柯克变焦", "C06 前景叠层", "N02 反应镜头", "禁止每 3-5 秒更换一次运镜"} { + for _, term := range []string{"先确定观众此刻跟随谁", "大远景只承担空间", "一个镜头只保留一个主运镜", "明确人物左右位置", "避免每镜都推进"} { if !strings.Contains(contract, term) { t.Fatalf("camera language guide is missing %q: %s", term, contract) } @@ -32,9 +32,22 @@ func TestStoryboardPromptsLeaveAspectRatioToVideoNode(t *testing.T) { if strings.Contains(defaultStoryboardPromptTemplate(), "2.39:1") { t.Fatal("default storyboard prompt still hard-codes 2.39:1") } - plan := fallbackAgentStoryboardPlan("测试故事") - if strings.Contains(plan.StyleGuide+plan.Shots[0].VideoPrompt, "2.39:1") || strings.Contains(plan.StyleGuide+plan.Shots[0].VideoPrompt, "画幅") { - t.Fatal("fallback storyboard still mentions output aspect ratio") + prompt := buildStoryboardVideoPrompt("【风格组合】仙侠·宏大叙事·3D 动漫", "高清、明亮、空间层次清楚", agentStoryboardShot{ + Duration: 8, Description: "剑仙踏云进入画面", VisualPrompt: "剑仙位于画面左侧", VideoPrompt: "向前踏出一步后停住", Camera: "平视中景", Motion: "缓慢推近后停住", TimeBeats: "0-4秒:进入;4-8秒:停住", + }) + if strings.Contains(prompt, "2.39:1") || strings.Contains(prompt, "画幅") { + t.Fatal("compiled storyboard video prompt still mentions output aspect ratio") + } +} + +func TestRemoveFixedMediaRestrictionsKeepsCurrentProjectMedium(t *testing.T) { + template := "保留这一行\n- 不要在 visualPrompt 或 videoPrompt 中使用 3D动漫、动画、二次元、游戏CG、卡通渲染。\n继续保留" + filtered := removeFixedMediaRestrictions(template) + if strings.Contains(filtered, "不要在 visualPrompt") { + t.Fatal("fixed 3D/cartoon restriction was not removed") + } + if !strings.Contains(filtered, "保留这一行") || !strings.Contains(filtered, "继续保留") { + t.Fatal("unrelated template content was removed") } } @@ -50,3 +63,32 @@ func TestValidateStoryboardShotCount(t *testing.T) { t.Fatalf("expected automatic shot count to pass: %v", err) } } + +func TestValidateStoryboardComplexityRejectsOverloadedShot(t *testing.T) { + plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ + Duration: 5, CharacterIDs: []string{"a", "b", "c"}, MustHave: []string{"一", "二", "三", "四"}, TimeBeats: "0-1秒:起,1-2秒:承,2-4秒:转,4-5秒:合", Motion: "航拍推进并升降", + }}} + if err := validateStoryboardComplexity(plan); err == nil { + t.Fatal("expected overloaded shot to fail complexity validation") + } +} + +func TestValidateStoryboardComplexityAcceptsSingleDirectedMove(t *testing.T) { + plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ + Duration: 8, CharacterIDs: []string{"a", "b"}, MustHave: []string{"身份稳定", "动作落点", "结尾状态"}, TimeBeats: "0-2秒:建立;2-6秒:动作;6-8秒:反应", Motion: "缓慢推近后停住", Dialogue: "你终于来了。", + }}} + if err := validateStoryboardComplexity(plan); err != nil { + t.Fatalf("expected focused shot to pass complexity validation: %v", err) + } +} + +func TestMatchStoryboardAssetsForShotPrioritizesCurrentCharacterCards(t *testing.T) { + assets := []storyboardAsset{ + {ID: "environment", Title: "天宫", Tags: []string{"场景:天宫"}}, + {ID: "character-node", Title: "青莲剑仙", CharacterAssetID: "character-1", CharacterVersionID: "version-3"}, + } + matched := matchStoryboardAssetsForShot(assets, agentStoryboardShot{CharacterIDs: []string{"character-1"}, AssetTags: []string{"场景:天宫"}}) + if len(matched) != 2 || matched[0].ID != "character-node" { + t.Fatalf("expected current character card before optional tagged assets: %#v", matched) + } +} diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 62db18943..2addd0115 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -24,6 +24,7 @@ import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendin import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache"; +import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context"; export const CANVAS_AGENT_PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; @@ -316,6 +317,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project const runCinematicSession = async (sessionId: string, text: string, current: CanvasAgentSnapshot, config: AiConfig, onCreated?: (backendSessionId: string) => void) => { const requestConfig = resolveModelRequestConfig(config, config.textModel || config.model); + const storyboardContext = resolveStoryboardGenerationContext(current.nodes); const controller = new AbortController(); const requestKey = `creating:${nanoid()}`; let backendSessionId = ""; @@ -326,6 +328,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project projectId, prompt: text, canvasSnapshot: compactSnapshot(current) as unknown as Record, + projectStyle: storyboardContext.projectStyle, + characters: storyboardContext.characters, config: backendAgentProviderConfig(requestConfig), }, { @@ -1549,6 +1553,9 @@ function compactMetadata(metadata: CanvasNodeData["metadata"]) { workflowKind: metadata?.workflowKind, workflowTitle: metadata?.workflowTitle, workflowDescription: metadata?.workflowDescription, + characterName: metadata?.characterName, + characterAssetId: metadata?.characterAssetId, + characterVersionId: metadata?.characterVersionId, chapterId: metadata?.chapterId, chapterTitle: metadata?.chapterTitle, shotIndex: metadata?.shotIndex, diff --git a/web/src/components/canvas/canvas-project-sidebar.tsx b/web/src/components/canvas/canvas-project-sidebar.tsx index 7651ee85b..01c7106ad 100644 --- a/web/src/components/canvas/canvas-project-sidebar.tsx +++ b/web/src/components/canvas/canvas-project-sidebar.tsx @@ -20,7 +20,7 @@ import { } from "lucide-react"; import { Link } from "react-router"; -import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal"; +import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects"; export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter"; @@ -46,7 +46,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN")); const projectQuery = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId) && !detail }); const projectDetail = detail || projectQuery.data; - const style = canvasStylePresets.find((preset) => preset.id === projectDetail?.project.stylePresetId); + const style = resolveCanvasStylePreset(projectDetail?.project.stylePresetId); const mediaAssetCount = projectDetail?.assets.filter((asset) => asset.mediaType === "image" || asset.mediaType === "video").length || 0; const orderedUnits = useMemo(() => projectDetail?.units.slice().sort((left, right) => left.position - right.position) || [], [projectDetail?.units]); const visibleUnits = useMemo(() => { diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx index b34c94a7c..5b71d6b3a 100644 --- a/web/src/components/canvas/canvas-script-node.tsx +++ b/web/src/components/canvas/canvas-script-node.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { Button, Checkbox, Input, InputNumber, Modal, Popover, Select, Table, Tooltip } from "antd"; +import { Button, Checkbox, Input, InputNumber, Modal, Popover, Segmented, Select, Table, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react"; @@ -13,7 +13,7 @@ import { navigateToSettings } from "@/lib/settings-navigation"; import { canvasThemes } from "@/lib/canvas-theme"; import { useEffectiveConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; -import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration } from "@/types/canvas"; +import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration, StoryboardVideoInputMode } from "@/types/canvas"; export const STORYBOARD_ROW_HEIGHT = 48; export const STORYBOARD_HEADER_HEIGHT = 124; @@ -43,6 +43,9 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [ { label: "时长", value: "durationSeconds" }, { label: "画面描述", value: "plotDescription" }, { label: "台词/旁白", value: "dialogue" }, + { label: "镜头意图", value: "narrativeIntent" }, + { label: "观众视点", value: "viewerPOV" }, + { label: "表演调度", value: "performanceBlocking" }, { label: "景别", value: "shotSize" }, { label: "情绪", value: "emotion" }, { label: "光影氛围", value: "lightingAndAtmosphere" }, @@ -52,10 +55,11 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [ { label: "时间节拍", value: "timeBeats" }, { label: "图片提示词", value: "imageGenerationPrompt" }, { label: "视频提示词", value: "videoMotionPrompt" }, + { label: "连续性出口", value: "continuityOut" }, { label: "负面要求", value: "negativePrompt" }, ]; -export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: { +export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: { node: CanvasNodeData; batch?: CanvasGenerationBatch; pipeline: CanvasStoryboardPipelineProgress; @@ -66,6 +70,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR onCreateVideoNodes: () => void; onGenerateImages: () => void; onGenerateVideos: () => void; + onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void; onMergeVideos: () => void; onCreateActionBoards: () => void; onRetryBatch: (batchId: string) => void; @@ -99,6 +104,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR const totalDuration = rows.reduce((sum, row) => sum + (Number(row.durationSeconds) || 0), 0); const shotDuration = node.metadata?.storyboardShotDuration || "auto"; const shotCount = node.metadata?.storyboardShotCount || "auto"; + const videoInputMode = node.metadata?.storyboardVideoInputMode || "direct"; const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); const batchSummary = batch ? generationBatchSummary(batch) : null; const hasFailedBatchItems = Boolean(batch?.items.some((item) => item.status === "failed")); @@ -146,6 +152,8 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR onCreateVideoNodes={onCreateVideoNodes} onGenerateImages={onGenerateImages} onGenerateVideos={onGenerateVideos} + videoInputMode={videoInputMode} + onVideoInputModeChange={onVideoInputModeChange} onMergeVideos={onMergeVideos} />
@@ -275,15 +283,17 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR ); } -function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos }: { +function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, videoInputMode, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos }: { pipeline: CanvasStoryboardPipelineProgress; simpleMode: boolean; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + videoInputMode: StoryboardVideoInputMode; onCreateImageNodes: () => void; onCreateVideoNodes: () => void; onGenerateImages: () => void; onGenerateVideos: () => void; + onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void; onMergeVideos: () => void; }) { const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created); @@ -291,10 +301,10 @@ function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreate const canMerge = pipeline.successfulVideoNodeIds.length >= 2 && pipeline.final.success === 0; return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - + {simpleMode ? ( ) : ( <> @@ -304,14 +314,20 @@ function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreate )} + + size="small" + value={videoInputMode} + options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]} + onChange={onVideoInputModeChange} + /> {simpleMode ? ( ) : ( <> - + )} @@ -381,7 +397,7 @@ function batchItemTone(item?: CanvasGenerationBatchItem): CanvasNodeStatus | und return "loading"; } -export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos }: { +export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos, onVideoInputModeChange }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; @@ -389,11 +405,13 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl onVisibleColumnsChange: (columns: StoryboardColumn[]) => void; onGenerateImages: (rowIds: string[]) => void; onGenerateVideos: (rowIds: string[]) => void; + onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void; }) { const [query, setQuery] = useState(""); const [selectedIds, setSelectedIds] = useState([]); const rows = node?.metadata?.storyboard?.rows || EMPTY_STORYBOARD_ROWS; const visibleColumns = node?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]; + const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct"; const filteredRows = useMemo(() => { const keyword = query.trim().toLowerCase(); return keyword ? rows.filter((row) => [row.plotDescription, row.dialogue, row.camera, row.motion, row.timeBeats, row.imageGenerationPrompt, row.videoMotionPrompt, row.negativePrompt].some((value) => String(value || "").toLowerCase().includes(keyword))) : rows; @@ -441,8 +459,9 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl onVisibleColumnsChange(values as StoryboardColumn[])} /> - - + + value={videoInputMode} options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]} onChange={onVideoInputModeChange} /> +
rowKey="id" size="small" bordered sticky pagination={false} scroll={{ x: Math.max(900, columns.length * 180), y: "calc(78vh - 170px)" }} dataSource={filteredRows} columns={columns} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) }} /> @@ -462,7 +481,7 @@ function SmallButton({ title, children, onClick }: { title: string; children: Re } function editorRow(shotNumber: number): StoryboardRow { - return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", negativePrompt: "", referenceNodeIds: [], status: "idle" }; + return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" }; } function RowHandle({ side, top, scale, tone, theme, title, onPointerDown }: { side: "left" | "right"; top: number; scale: number; tone?: StoryboardRow["status"]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; title?: string; onPointerDown: (event: ReactPointerEvent) => void }) { diff --git a/web/src/components/canvas/canvas-style-picker-modal.tsx b/web/src/components/canvas/canvas-style-picker-modal.tsx index d8229e93f..2c446cd44 100644 --- a/web/src/components/canvas/canvas-style-picker-modal.tsx +++ b/web/src/components/canvas/canvas-style-picker-modal.tsx @@ -1,24 +1,30 @@ -import { useState, type CSSProperties } from "react"; -import { Check, Clapperboard, Eye, Palette, Sparkles } from "lucide-react"; -import { Button, Modal } from "antd"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { Check, Eye, Palette } from "lucide-react"; +import { Button, Modal, Segmented, Select } from "antd"; import { canvasThemes } from "@/lib/canvas-theme"; +import { + compatibleProjectStyleCharacters, + compileCanvasStylePreset, + customCanvasStylePreset, + defaultProjectStyleSelection, + parseCanvasStyleSelection, + projectStyleMedia, + projectStyleTones, + projectStyleWorlds, + recommendedCanvasStylePresets, + type CanvasStylePreset, + type ProjectStyleMediumId, + type ProjectStyleSelection, +} from "@/lib/canvas/canvas-style-system"; import { useThemeStore } from "@/stores/use-theme-store"; -export type CanvasStylePreset = { - id: string; - title: string; - category: string; - description: string; - tags: string[]; - prompt: string; - imageUrl: string; -}; +export type { CanvasStylePreset } from "@/lib/canvas/canvas-style-system"; // 分类按短剧制作语境组织:先看媒介,再看题材与视觉气质,避免用品牌名称代替画风。 const PROJECT_STYLE_SCOPE = "【使用边界】本规范是全项目美术与影像风格基线,用于统一角色资产、服装材质、建筑世界观、色彩语言和成片质感;它不是某张图片或某个镜头的提示词。具体场景内容、构图、景别、机位、运镜、动作、光源位置、天气和单场情绪由剧情与分镜节点决定,不得从本规范机械复制。"; -export const canvasStylePresets: CanvasStylePreset[] = [ +const legacyCanvasStylePresets: CanvasStylePreset[] = [ { id: "urban-live-action", title: "都市真人短剧", @@ -363,43 +369,80 @@ export const canvasStylePresets: CanvasStylePreset[] = [ }, ]; +const legacyStyleUpgrades: Partial> = { + "urban-live-action": { world: "urban", tone: "romantic", medium: "live-action", character: "realistic" }, + "period-live-action": { world: "historical", tone: "epic", medium: "live-action", character: "realistic" }, + "suspense-noir": { world: "suspense", tone: "dark", medium: "live-action", character: "realistic" }, + "chinese-2d": { world: "xianxia", tone: "epic", medium: "2d-guoman", character: "semi-real" }, + "ink-narrative": { world: "xianxia", tone: "healing", medium: "ink", character: "semi-real" }, + "three-d-cartoon": { world: "urban", tone: "light-comedy", medium: "3d-cartoon", character: "stylized" }, + "fantasy-3d": { world: "xianxia", tone: "epic", medium: "3d-anime", character: "semi-real" }, + "future-tech": { world: "science-fiction", tone: "epic", medium: "live-action", character: "realistic" }, + "cyberpunk-neon": { world: "science-fiction", tone: "dark", medium: "live-action", character: "realistic" }, + "space-opera": { world: "science-fiction", tone: "epic", medium: "3d-anime", character: "semi-real" }, + "nature-healing": { world: "pastoral", tone: "healing", medium: "live-action", character: "realistic" }, + "real-life-documentary": { world: "urban", tone: "healing", medium: "live-action", character: "realistic" }, +}; + +const resolvedLegacyCanvasStylePresets = legacyCanvasStylePresets.map((preset) => { + const upgrade = legacyStyleUpgrades[preset.id]; + return upgrade ? { ...compileCanvasStylePreset(upgrade), id: preset.id, imageUrl: preset.imageUrl } : preset; +}); + +export const canvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets]; + +export function resolveCanvasStylePreset(id?: string) { + return canvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id); +} + export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { open: boolean; value?: string; onClose: () => void; onSelect: (preset: CanvasStylePreset) => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [detailPreset, setDetailPreset] = useState(null); + const [mode, setMode] = useState<"recommended" | "custom">("recommended"); + const [selection, setSelection] = useState(defaultProjectStyleSelection); + useEffect(() => { + if (!open) return; + const parsed = parseCanvasStyleSelection(value); + if (parsed) { + setSelection(parsed); + setMode(recommendedCanvasStylePresets.some((preset) => preset.id === value) ? "recommended" : "custom"); + } else { + setSelection(defaultProjectStyleSelection); + setMode("recommended"); + } + }, [open, value]); + const activePreset = resolveCanvasStylePreset(value); + const visiblePresets = activePreset && !recommendedCanvasStylePresets.some((preset) => preset.id === activePreset.id) + ? [activePreset, ...recommendedCanvasStylePresets.filter((preset) => preset.prompt !== activePreset.prompt)] + : recommendedCanvasStylePresets; return ( <> - -
-
- -
-
选择项目画风{canvasStylePresets.length} 套视觉预设
-
画风会统一角色三视图、项目资产、分镜和视频提示词。
-
- 先看规范,再做选择 + +
+
+

选择项目画风

+ setMode(next as typeof mode)} />
-
- {canvasStylePresets.map((preset) => { + {mode === "recommended" ?
+ {visiblePresets.map((preset) => { const active = preset.id === value; return (
- - +
); })} -
-
图片是风格示意,实际效果取决于模型、角色参考图与分镜提示词。
+
: }
setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); onSelect(preset); }} /> @@ -407,27 +450,83 @@ export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { ope ); } +function CanvasStyleComposer({ selection, value, theme, onChange, onSelect, onDetail }: { + selection: ProjectStyleSelection; + value?: string; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + onChange: (selection: ProjectStyleSelection) => void; + onSelect: (preset: CanvasStylePreset) => void; + onDetail: (preset: CanvasStylePreset) => void; +}) { + const preset = useMemo(() => compileCanvasStylePreset(selection), [selection]); + const characterOptions = compatibleProjectStyleCharacters(selection.medium); + const updateMedium = (medium: ProjectStyleMediumId) => { + const compatible = compatibleProjectStyleCharacters(medium); + const character = compatible.some((item) => item.id === selection.character) ? selection.character : compatible[0].id; + onChange({ ...selection, medium, character }); + }; + return ( +
+
+ onChange({ ...selection, world })} /> + onChange({ ...selection, tone })} /> + + onChange({ ...selection, character })} /> +
+
+
+ {`${preset.title}画风示意`} +
+
+
{preset.title}
+

{preset.description}

+
+ item.id === selection.world)?.label} muted={theme.node.muted} /> + item.id === selection.tone)?.label} muted={theme.node.muted} /> + item.id === selection.medium)?.label} muted={theme.node.muted} /> + item.id === selection.character)?.label} muted={theme.node.muted} /> +
+
+ + +
+
+
+
+ ); +} + +function StyleSummary({ label, value, muted }: { label: string; value?: string; muted: string }) { + return
{label}{value}
; +} + +function StyleSelect({ label, value, options, onChange }: { label: string; value: T; options: Array<{ id: T; label: string; description: string }>; onChange: (value: T) => void }) { + return ( + + ); +} + export function CanvasStyleDetailModal({ open, preset, selected = false, onClose, onSelect }: { open: boolean; preset: CanvasStylePreset | null; selected?: boolean; onClose: () => void; onSelect?: (preset: CanvasStylePreset) => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const sections = preset ? parseStyleSections(preset.prompt) : []; return ( - - {preset ?
- -
-
-
{preset.category}
-

{preset.title}

-

{preset.description}

-
{preset.tags.map((tag) => {tag})}
-
-
- {sections.map((section) =>

{section.title}

{section.content}

)} -
-
角色三视图会自动提取其中的色彩、造型和材质规范
{onSelect ? : null}
-
+ + {preset ?
+
+ {`${preset.title}画风示意`} +
+
+
{preset.category}
+

{preset.title}

+

{preset.description}

+
+
+ {sections.map((section) =>

{section.title}

{section.content}

)} +
+
{onSelect ? : null}
: null}
); diff --git a/web/src/components/prompt/prompt-code-editor.tsx b/web/src/components/prompt/prompt-code-editor.tsx new file mode 100644 index 000000000..5fce75c13 --- /dev/null +++ b/web/src/components/prompt/prompt-code-editor.tsx @@ -0,0 +1,56 @@ +import { EditorView } from "@codemirror/view"; +import CodeMirror from "@uiw/react-codemirror"; +import { forwardRef, useImperativeHandle, useRef } from "react"; + +import { cn } from "@/lib/utils"; +import { useThemeStore } from "@/stores/use-theme-store"; + +export type PromptCodeEditorHandle = { + insertText: (text: string) => void; + focus: () => void; +}; + +type PromptCodeEditorProps = { + value: string; + onChange?: (value: string) => void; + readOnly?: boolean; + ariaLabel: string; + className?: string; +}; + +export const PromptCodeEditor = forwardRef(function PromptCodeEditor({ value, onChange, readOnly = false, ariaLabel, className }, ref) { + const theme = useThemeStore((state) => state.theme); + const viewRef = useRef(null); + + useImperativeHandle(ref, () => ({ + insertText(text) { + const view = viewRef.current; + if (!view || readOnly) return; + const selection = view.state.selection.main; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: text }, + selection: { anchor: selection.from + text.length }, + }); + view.focus(); + }, + focus() { + viewRef.current?.focus(); + }, + }), [readOnly]); + + return ( + { viewRef.current = view; }} + onChange={onChange} + /> + ); +}); diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index f728d5ee9..e4acd96d5 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -58,6 +58,9 @@ export function createStoryboardRow(shotNumber: number, patch: Partial { + const variables = kind === "image" ? row.imagePromptTemplateVariables : row.videoPromptTemplateVariables; + return variables + ? { promptTemplateOperation: kind === "image" ? "storyboard_first_frame" : "storyboard_video", promptTemplateVariables: variables } + : { promptTemplateOperation: undefined, promptTemplateVariables: undefined }; +} + export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] { return Array.from(new Set([ ...(columns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]), "shotSize", + "narrativeIntent", + "viewerPOV", + "performanceBlocking", "camera", "motion", "timeBeats", "lightingAndAtmosphere", + "continuityOut", "negativePrompt", ])) as StoryboardColumn[]; } @@ -91,7 +109,19 @@ export function storyboardRowsFromTask(task: GenerationTask) { if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行"); return { title: result.title?.trim(), - rows: result.rows.map((row, index) => createStoryboardRow(index + 1, { ...row, id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`, shotNumber: index + 1, status: "idle", referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [] })), + rows: result.rows.map((row, index) => { + const next = createStoryboardRow(index + 1, { + ...row, + id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`, + shotNumber: index + 1, + status: "idle", + referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [], + }); + next.characters = Array.isArray(row.characters) ? row.characters : []; + next.mustHave = Array.isArray(row.mustHave) ? row.mustHave : []; + next.optionalDetails = Array.isArray(row.optionalDetails) ? row.optionalDetails : []; + return next; + }), }; } @@ -167,7 +197,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P return nodes.map((node) => { if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) { - return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoPrompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } }; + return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } }; } if (node.id !== scriptNodeId || node.type !== CanvasNodeType.Script) return node; const storyboard = node.metadata?.storyboard; diff --git a/web/src/lib/canvas/canvas-storyboard-context.ts b/web/src/lib/canvas/canvas-storyboard-context.ts new file mode 100644 index 000000000..88e50952b --- /dev/null +++ b/web/src/lib/canvas/canvas-storyboard-context.ts @@ -0,0 +1,41 @@ +import type { CanvasNodeData } from "@/types/canvas"; + +export type StoryboardGenerationContext = { + projectStyle: { + presetId: string; + title: string; + prompt: string; + }; + characters: Array<{ + assetId: string; + versionId: string; + name: string; + definition: Record; + }>; +}; + +// 分镜的两条入口共用同一强校验,避免画风或角色版本在某条旁路里被遗漏。 +export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): StoryboardGenerationContext { + const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard"); + const stylePrompt = String(styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim(); + const stylePresetId = String(styleNode?.metadata?.stylePresetId || "").trim(); + if (!styleNode || !stylePrompt || !stylePresetId) throw new Error("请先设置项目画风,再生成分镜"); + + const characterNodes = nodes.filter((node) => node.metadata?.workflowKind === "character"); + const invalidCharacter = characterNodes.find((node) => !node.metadata?.characterAssetId?.trim() || !node.metadata?.characterVersionId?.trim() || !(node.metadata?.characterName || node.title).trim()); + if (invalidCharacter) throw new Error(`角色卡“${invalidCharacter.metadata?.characterName || invalidCharacter.title || "未命名角色"}”版本未同步,请刷新角色资产后再生成分镜`); + + return { + projectStyle: { + presetId: stylePresetId, + title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title, + prompt: stylePrompt, + }, + characters: characterNodes.map((node) => ({ + assetId: node.metadata!.characterAssetId!.trim(), + versionId: node.metadata!.characterVersionId!.trim(), + name: (node.metadata?.characterName || node.title).trim(), + definition: node.metadata?.characterDefinition || { prompt: node.metadata?.characterPrompt || "" }, + })), + }; +} diff --git a/web/src/lib/canvas/canvas-style-system.ts b/web/src/lib/canvas/canvas-style-system.ts new file mode 100644 index 000000000..c722d532d --- /dev/null +++ b/web/src/lib/canvas/canvas-style-system.ts @@ -0,0 +1,332 @@ +export type ProjectStyleWorldId = "xianxia" | "urban" | "historical" | "suspense" | "science-fiction" | "pastoral"; +export type ProjectStyleToneId = "epic" | "dark" | "light-comedy" | "romantic" | "healing"; +export type ProjectStyleMediumId = "live-action" | "3d-anime" | "3d-cartoon" | "2d-guoman" | "ink"; +export type ProjectStyleCharacterId = "realistic" | "semi-real" | "anime" | "stylized"; + +export type ProjectStyleSelection = { + world: ProjectStyleWorldId; + tone: ProjectStyleToneId; + medium: ProjectStyleMediumId; + character: ProjectStyleCharacterId; +}; + +export type CanvasStylePreset = { + id: string; + title: string; + category: string; + description: string; + tags: string[]; + prompt: string; + imageUrl: string; + selection?: ProjectStyleSelection; +}; + +type StyleOption = { + id: T; + label: string; + description: string; + prompt: string; + palette?: string; + wardrobe?: string; + environment?: string; + motion?: string; + forbidden: string; +}; + +export const projectStyleWorlds: Array> = [ + { + id: "xianxia", + label: "仙侠", + description: "宗门、仙城、洞府、云海与法术规则共同构成东方修行世界。", + prompt: "东方仙侠世界以宗门、境界、法器、灵兽和因果秩序为基础;奇观从中式山水、木构建筑、道家意象和修行体系演化,所有地点共享明确的时代、地理与力量规则。", + palette: "云雾白、黛青、青玉、朱砂、古铜与阵营能量色", + wardrobe: "袍服、劲装、甲胄、宗门纹样、发冠、腰封和法器按身份与阵营建立稳定资产层级", + environment: "山门、殿宇、楼阁、洞府、仙城、云海、古战场和凡间城镇采用中式结构与东方山水空间", + motion: "御剑、身法、法术和衣袍运动必须有起势、蓄力、受力与落点,力量等级通过规模和环境反馈表达", + forbidden: "欧洲城堡、哥特教堂、日式鸟居、西式魔法阵、随机游戏 UI、宗门与法术规则漂移", + }, + { + id: "urban", + label: "当代都市", + description: "以可信中国城市、职业身份与生活关系组织现代短剧。", + prompt: "当代中国都市世界以职业、家庭、收入、社区和城市公共生活为基础;人物行动、空间功能、中文标识与生活设施必须符合明确城市和社会语境。", + palette: "中性白、雾灰、浅木、水泥色与克制的角色识别色", + wardrobe: "服装按职业、收入、季节和性格建立衣橱,固定配饰承担角色识别", + environment: "办公室、公寓、学校、医院、商场、街区、社区和公共交通保持本地尺度与使用痕迹", + motion: "表演贴近日常重心、停顿和微表情,镜头运动服务人物关系与信息揭示", + forbidden: "欧美城市替代、空洞样板间、错误中文、无职业逻辑服装、网红滤镜和广告摆拍", + }, + { + id: "historical", + label: "古装历史", + description: "先锁定单一时代,再统一礼制、服化道、器物与建筑。", + prompt: "历史世界必须先依据故事锁定唯一朝代或年代子类型,人物制度、礼仪、服饰、兵器、器物、交通与建筑全部服从同一考据基线。", + palette: "米白、黛灰、木褐、土黄、靛青、竹青与有限朱砂金色", + wardrobe: "襟形、袖型、腰带、冠帽、鞋履和纹样按时代、阶层与身份建立制度", + environment: "木构、院落、回廊、城镇、宫署、乡野和道路遵循所选时代的结构与材料", + motion: "人物动作考虑礼制、服装重量、兵器惯性和具体生活劳动", + forbidden: "朝代混搭、现代拉链家具、日式或欧式建筑、塑料饰品和现代偶像妆", + }, + { + id: "suspense", + label: "悬疑犯罪", + description: "真实城市纹理、线索系统和心理压力构成可读的悬疑世界。", + prompt: "现代悬疑世界以案件、秘密、身份关系和可追踪线索构成,地点布局、关键道具、伤痕与时间线必须稳定,信息隐藏不能牺牲画面可读性。", + palette: "炭灰、冷水泥、旧木、脏黄实景灯与少量危险强调色", + wardrobe: "职业服饰、制服、便装和关键随身物按身份与时间线建立版本", + environment: "旧居民楼、办公空间、街巷、仓储、地下空间和城郊设施保持同一城市地域", + motion: "观察、跟随、遮挡和反应镜头服务线索揭示,避免把晃动当作悬疑", + forbidden: "黑成不可读、霓虹灯海、过量烟雾、血浆猎奇、反派脸谱化和线索资产变形", + }, + { + id: "science-fiction", + label: "未来科幻", + description: "技术规则、社会结构与功能性空间共同支撑未来叙事。", + prompt: "未来世界必须先确定技术水平、能源、交通、通讯和社会组织规则;科技资产具有可理解的功能、接口与使用限制,奇观必须反映文明尺度而非随机堆砌发光物。", + palette: "深空黑、冷灰、银白、舱体白与单一阵营或能源强调色", + wardrobe: "工作服、制服、航天或防护装备按功能与阵营模块化设计", + environment: "舰船、空间站、未来城市、实验设施和居住空间遵循结构、维护与照明逻辑", + motion: "运动体现设备重量、惯性、空间限制和技术反馈", + forbidden: "无功能 HUD、随机霓虹、现实品牌、技术规则漂移、装备接口变化和无尺度参照", + }, + { + id: "pastoral", + label: "自然乡野", + description: "地域、季节、生活劳动和自然环境共同参与叙事。", + prompt: "自然乡野世界以明确地域、季节、家庭关系和生活劳动为基础;植物、天气、住宅、工具与集市不是装饰,而是持续参与人物行动和关系变化的真实环境。", + palette: "叶绿、苔藓绿、土色、旧木、天空色、米白与季节性角色点色", + wardrobe: "棉麻、针织、旧牛仔、工作服、雨具和手作物保留褶皱与使用痕迹", + environment: "村镇住宅、林间小屋、农场、河岸、菜园、集市和小城边缘具有地域证据", + motion: "风、雨、植物、动物和细小生活动作形成自然节奏", + forbidden: "商业民宿广告感、欧美乡村替代、随机换季、无生活痕迹和过度航拍空镜", + }, +]; + +export const projectStyleTones: Array> = [ + { + id: "epic", + label: "宏大叙事", + description: "用尺度、秩序与人物命运建立史诗感,不靠全程阴暗和特效堆积。", + prompt: "叙事气质庄重、开阔、有历史与命运重量;宏大场面负责建立空间和力量关系,人物镜头负责承接选择与代价,奇观和表演之间保持清晰主次。", + palette: "高明度空间层次、稳定阵营色与少量高亮奇观色,暗部保留细节", + motion: "建立镜头克制而有尺度,高潮运动有明确起止,人物反应获得停顿", + forbidden: "全片低照度、无休止慢动作、每镜航拍、特效盖住人物、只有规模没有情绪落点", + }, + { + id: "dark", + label: "暗黑诡谲", + description: "以未知、禁忌和心理压力形成黑暗气质,同时保持信息可读。", + prompt: "叙事气质危险、克制、带未知感;恐惧来自空间、规则和人物反应,不依赖血浆、全黑画面或持续尖叫。", + palette: "低调但可读的冷暖分区,危险色只用于禁忌、敌意或线索", + motion: "更多观察、遮挡、缓慢揭示和反应停顿,爆发运动只用于关键破局", + forbidden: "黑成不可读、血腥猎奇、廉价红黑滤镜、全屏烟雾、无来源闪光和持续镜头抖动", + }, + { + id: "light-comedy", + label: "轻喜剧", + description: "宏观处境与人物一本正经的反应形成反差,画面明快但不幼稚。", + prompt: "叙事气质轻松、机敏、有人情味;笑点依靠人物关系、反应、停顿和处境反差,允许宏大世界承载小人物式幽默,不把喜剧等同于夸张鬼脸。", + palette: "清晰明快的中高明度色彩,角色与背景保持稳定分离,危险场面也避免恐怖化压黑", + motion: "动作有清楚预备、落点和喜剧停顿,反应镜头与正经表演优先于炫技运镜", + forbidden: "阴森恐怖基调、儿童节目式糖果色、所有角色做鬼脸、连续快切和笑点被特效淹没", + }, + { + id: "romantic", + label: "唯美浪漫", + description: "以人物距离、光线和环境细节表达关系变化,避免滤镜化糖水感。", + prompt: "叙事气质细腻、含蓄、具有关系张力;浪漫来自视线、距离、触碰、环境变化和未说出口的情绪,不依赖随机花瓣与持续柔焦。", + palette: "自然肤色、柔和冷暖关系与克制的情绪点色", + motion: "镜头靠近人物和细节,运动缓慢而有情绪动机,保留呼吸与停顿", + forbidden: "全程柔焦、过曝肤色、随机花瓣、MV 式空镜、无表演的慢动作和滤镜漂移", + }, + { + id: "healing", + label: "温暖治愈", + description: "通过生活行动、环境触感和关系修复形成温暖,而不是泛化柔光。", + prompt: "叙事气质温暖、松弛、具体;治愈来自可见的照料、劳动、陪伴和关系变化,环境材质与自然声音参与情绪表达。", + palette: "自然中高明度、温暖但不过黄,保留木、布、植物和肤色的真实层次", + motion: "观察细小动作、呼吸、眼神和环境响应,节奏舒展但不空洞", + forbidden: "全片奶油滤镜、过度暖黄、无剧情自然空镜、广告式精致和人物永远微笑", + }, +]; + +export const projectStyleMedia: Array & { characters: ProjectStyleCharacterId[] }> = [ + { + id: "live-action", + label: "真人实拍", + description: "真实演员、可信光学、服化道和物理环境。", + prompt: "采用真人影视拍摄媒介:真实人物比例、自然皮肤与发丝、可信镜头光学和现场光源,服装、建筑、道具与特效保持真实材质响应。", + motion: "动作遵循真实重心、惯性、衣料重量和摄影机物理运动", + forbidden: "动画角色、游戏 CG、塑料皮肤、过度磨皮、虚假棚拍和无物理依据的材质", + characters: ["realistic", "semi-real"], + }, + { + id: "3d-anime", + label: "3D 动漫", + description: "高品质三维动画、东方动漫造型与电影化场景。", + prompt: "采用高品质 3D 动漫媒介:清晰三维体积、统一 PBR 或风格化 PBR 材质、受控次表面散射、稳定拓扑和电影级灯光;保持动画角色的设计感,不滑向真人恐怖谷或廉价游戏过场。", + motion: "动画具备明确关键姿势、预备动作、跟随、受力和停顿,特效与角色动作共享同一空间和光照", + forbidden: "廉价手游 CG、塑料手办反光、僵硬待机动作、真人毛孔皮肤、角色与背景渲染体系不一致", + characters: ["semi-real", "anime"], + }, + { + id: "3d-cartoon", + label: "3D 卡通", + description: "风格化比例、清晰剪影、柔和材质和可读表情。", + prompt: "采用风格化 3D 卡通动画媒介:简练几何、清晰剪影、半哑光材质、柔和次表面散射和可读表情;场景与角色使用同一圆角、粗糙度和细节密度体系。", + motion: "角色运动使用适度挤压拉伸、跟随、缓入缓出和清晰停顿,夸张建立在稳定骨架与关节结构上", + forbidden: "塑料公仔、所有角色同脸、过大玻璃眼、僵硬 T Pose、关节穿模和写实资产混入", + characters: ["stylized"], + }, + { + id: "2d-guoman", + label: "国漫 2D", + description: "稳定线稿、赛璐璐角色与东方绘景体系。", + prompt: "采用国漫 2D 动画媒介:有粗细变化的稳定线稿、两至三阶赛璐璐明暗、清晰角色剪影和东方绘景层次;全项目保持二维绘画语言。", + motion: "使用明确关键姿势、有限但准确的二维运动,口型、发丝、衣摆与特效遵循统一帧间节奏", + forbidden: "三维塑料质感、真人照片、线宽漂移、角色转面失真、背景与角色画法不一致", + characters: ["semi-real", "anime", "stylized"], + }, + { + id: "ink", + label: "水墨动画", + description: "宣纸、墨阶、笔势与留白构成可识别的东方叙事。", + prompt: "采用中国水墨动画媒介:宣纸纤维、干湿笔触、飞白、积墨、破墨与有限工笔勾线;留白、墨阶和固定点色共同塑造空间与人物身份。", + motion: "轮廓先行、笔势跟随、墨迹生长与消散遵循统一规则,人物结构在变化中保持可识别", + forbidden: "随机泼墨、角色五官消失、西式水彩、全屏脏灰、廉价纸纹滤镜和随机文字印章", + characters: ["semi-real", "stylized"], + }, +]; + +export const projectStyleCharacters: Array> = [ + { + id: "realistic", + label: "写实人物", + description: "真实骨相、年龄、皮肤和身体比例。", + prompt: "角色采用真实东亚骨相、自然年龄和真实身体比例,通过脸型、五官、发型、体型、姿态与生活痕迹建立差异;保留自然皮肤和微表情。", + forbidden: "网红同脸、过度磨皮、幼态大眼、年龄漂移、欧美默认脸和夸张动漫比例", + }, + { + id: "semi-real", + label: "半写实仿真人", + description: "东方骨相与理想化造型平衡,保留可信结构。", + prompt: "角色采用半写实东方造型:可信骨骼、肌肉和关节结构,五官适度理想化但保留年龄与身份差异;发丝、皮肤和服装细节服从所选媒介,不进入恐怖谷。", + forbidden: "真人扫描僵硬感、统一偶像脸、毛孔级过度写实、假睫毛模板和身体比例漂移", + }, + { + id: "anime", + label: "动漫角色", + description: "修长比例、清晰五官设计和稳定角色剪影。", + prompt: "角色采用东方动漫造型:约 7 至 8 头身,五官、发束、眼形和服装结构高度可识别;夸张集中在设计语言和表情,不破坏身体结构与身份稳定性。", + forbidden: "低幼大头、过大玻璃眼、随机发色、所有角色同一脸型、五官在转面中漂移", + }, + { + id: "stylized", + label: "风格化卡通", + description: "夸张比例与可读表情服务喜剧和轻松叙事。", + prompt: "角色采用统一的风格化卡通比例:成人约 5 至 6 头身,儿童约 3.5 至 4.5 头身,头手可适度夸张;通过眼鼻嘴比例、发型、体型和剪影建立显著差异。", + forbidden: "所有角色同脸、头身随机变化、过大玻璃眼、关节结构错误、塑料玩具感和僵硬表情", + }, +]; + +export const defaultProjectStyleSelection: ProjectStyleSelection = { + world: "xianxia", + tone: "epic", + medium: "3d-anime", + character: "semi-real", +}; + +const STYLE_SCOPE = "【使用边界】本规范锁定项目级题材世界、叙事气质、视觉媒介、角色造型、资产材质与影像基线。单镜头的剧情动作、构图、景别、机位、运镜、光源位置和天气由分镜按本规范创作,不得机械复制项目级描述。"; + +export function compatibleProjectStyleCharacters(mediumId: ProjectStyleMediumId) { + const allowed = projectStyleMedia.find((item) => item.id === mediumId)?.characters || []; + return projectStyleCharacters.filter((item) => allowed.includes(item.id)); +} + +export function compileCanvasStylePreset(selection: ProjectStyleSelection): CanvasStylePreset { + const world = requiredOption(projectStyleWorlds, selection.world); + const tone = requiredOption(projectStyleTones, selection.tone); + const medium = requiredOption(projectStyleMedia, selection.medium); + const compatibleCharacters = compatibleProjectStyleCharacters(selection.medium); + const character = compatibleCharacters.find((item) => item.id === selection.character) || compatibleCharacters[0]; + const resolvedSelection = { ...selection, character: character.id }; + const title = `${world.label} · ${tone.label} · ${medium.label}`; + return { + id: styleSelectionId(resolvedSelection), + title, + category: `${world.label} / ${medium.label}`, + description: `${world.description}${tone.description}${medium.description}`, + tags: [world.label, tone.label, medium.label, character.label], + imageUrl: stylePreviewImage(resolvedSelection), + selection: resolvedSelection, + prompt: [ + `【风格组合】题材世界:${world.label};叙事气质:${tone.label};视觉媒介:${medium.label};角色造型:${character.label}。以下四项必须同时成立,后续角色、场景、分镜和视频不得擅自替换其中任一维度。`, + `【项目定位】面向“${world.label}”题材的${tone.label}${medium.label}项目,以统一世界规则、角色资产和视觉媒介支撑连续叙事;项目规范只定义长期稳定的美术基线,不代替单镜头导演设计。`, + `【题材世界观】${world.prompt}`, + `【叙事气质】${tone.prompt}`, + `【视觉媒介】${medium.prompt} 成片保持高清可读的主体轮廓、稳定材质和受控高光;高清不等于过度锐化、磨皮或无来源泛光。`, + `【角色设计系统】${character.prompt}`, + `【项目色彩与光影】基础色彩来自${world.palette};本项目采用${tone.palette}。色彩按角色、阵营、地点和叙事功能分配,保证人物与环境具有稳定的明度和色相分离。`, + `【服饰、材质与场景】${world.wardrobe};${world.environment}。所有材质和细节密度遵循“${medium.label}”媒介,不混入其他渲染体系。`, + `【影像与动态基线】${tone.motion};${medium.motion};${world.motion}。分镜必须先确定镜头叙事意图,再选择景别与运镜。`, + "【资产一致性】角色固定脸型、五官比例、发型、头身、服装、道具和阵营色;主要场景固定空间布局、建筑模块、材质与照明基线;换装、受损、升级和时间变化必须形成明确资产版本。", + `【全局禁用】${world.forbidden};${tone.forbidden};${medium.forbidden};${character.forbidden}。`, + STYLE_SCOPE, + ].join("\n"), + }; +} + +export function parseCanvasStyleSelection(id?: string): ProjectStyleSelection | null { + if (!id?.startsWith("v2-")) return null; + const parts = id.slice(3).split("--"); + if (parts.length !== 4) return null; + const selection = { world: parts[0], tone: parts[1], medium: parts[2], character: parts[3] } as ProjectStyleSelection; + if (!projectStyleWorlds.some((item) => item.id === selection.world) + || !projectStyleTones.some((item) => item.id === selection.tone) + || !projectStyleMedia.some((item) => item.id === selection.medium) + || !projectStyleCharacters.some((item) => item.id === selection.character)) return null; + return selection; +} + +export function customCanvasStylePreset(id?: string) { + const selection = parseCanvasStyleSelection(id); + return selection ? compileCanvasStylePreset(selection) : undefined; +} + +const recommendedSelections: ProjectStyleSelection[] = [ + { world: "xianxia", tone: "epic", medium: "3d-anime", character: "semi-real" }, + { world: "xianxia", tone: "dark", medium: "3d-anime", character: "semi-real" }, + { world: "xianxia", tone: "light-comedy", medium: "3d-cartoon", character: "stylized" }, + { world: "xianxia", tone: "epic", medium: "live-action", character: "realistic" }, + { world: "urban", tone: "light-comedy", medium: "live-action", character: "realistic" }, + { world: "suspense", tone: "dark", medium: "live-action", character: "realistic" }, + { world: "pastoral", tone: "healing", medium: "3d-cartoon", character: "stylized" }, + { world: "historical", tone: "romantic", medium: "2d-guoman", character: "semi-real" }, +]; + +export const recommendedCanvasStylePresets = recommendedSelections.map(compileCanvasStylePreset); + +function styleSelectionId(selection: ProjectStyleSelection) { + return `v2-${selection.world}--${selection.tone}--${selection.medium}--${selection.character}`; +} + +function stylePreviewImage(selection: ProjectStyleSelection) { + if (selection.world === "xianxia") { + if (selection.medium === "live-action") return "/short-drama-styles/period-live-action.jpg"; + if (selection.medium === "3d-cartoon") return "/short-drama-styles/three-d-cartoon.jpg"; + if (selection.medium === "2d-guoman") return "/short-drama-styles/chinese-2d.jpg"; + if (selection.medium === "ink") return "/short-drama-styles/ink-narrative.jpg"; + return "/short-drama-styles/fantasy-3d.jpg"; + } + if (selection.world === "suspense") return "/short-drama-styles/suspense-noir.jpg"; + if (selection.world === "science-fiction") return "/short-drama-styles/future-tech.jpg"; + if (selection.world === "pastoral") return "/short-drama-styles/nature-healing.jpg"; + if (selection.world === "historical") return selection.medium === "2d-guoman" ? "/short-drama-styles/chinese-2d.jpg" : "/short-drama-styles/period-live-action.jpg"; + return selection.medium === "3d-cartoon" ? "/short-drama-styles/three-d-cartoon.jpg" : "/short-drama-styles/urban-live-action.jpg"; +} + +function requiredOption(options: Array>, id: T) { + const option = options.find((item) => item.id === id); + if (!option) throw new Error(`未知项目画风选项:${id}`); + return option; +} diff --git a/web/src/lib/settings-navigation.ts b/web/src/lib/settings-navigation.ts index 405826c49..a07b9e862 100644 --- a/web/src/lib/settings-navigation.ts +++ b/web/src/lib/settings-navigation.ts @@ -1,4 +1,4 @@ -export type SettingsSection = "channels" | "models" | "preferences" | "storage"; +export type SettingsSection = "channels" | "models" | "preferences" | "prompts" | "storage"; export function settingsPath(section: SettingsSection = "channels", continueCreation = false) { const params = new URLSearchParams({ section }); diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 5a1407dfb..292d70ab2 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -25,7 +25,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = items: [ { path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: }, { path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: }, - { path: "/admin/storyboard-prompts", label: "分镜提示词", description: "Agent 提示词版本", icon: }, + { path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: }, ], }, { diff --git a/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx b/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx index ed19a4422..2fe9e397d 100644 --- a/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx +++ b/web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx @@ -1,45 +1,68 @@ -import { App, Button, Drawer, Form, Input, Select, Table, Tag } from "antd"; +import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Table, Tabs, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Copy, Info, Pencil, Plus, Power, Search, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Braces, Copy, FileJson, FileText, Info, Plus, Power, Search, ShieldCheck, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { ListToolbar, TableSurface } from "@/components/layout/workspace-page"; -import { createAdminStoryboardPromptTemplate, deleteAdminStoryboardPromptTemplate, listAdminStoryboardPromptTemplates, updateAdminStoryboardPromptTemplate, type StoryboardPromptTemplate, type StoryboardPromptVariable } from "@/services/api/auth"; +import { PromptCodeEditor, type PromptCodeEditorHandle } from "@/components/prompt/prompt-code-editor"; +import { + createAdminPromptTemplate, + deleteAdminPromptTemplate, + listAdminPromptTemplates, + updateAdminPromptTemplate, + type PromptOperationDefinition, + type PromptTemplate, +} from "@/services/api/auth"; import { AdminPageFrame } from "../components/admin-shell"; import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton } from "../components/admin-ui"; -type PromptFormValues = { name: string; content: string; enabled?: boolean }; +type PromptFormValues = { name: string; enabled?: boolean }; +type DraftBaseline = { operation: string; name: string; enabled: boolean; content: string }; export default function StoryboardPromptsPage() { - const { message, modal } = App.useApp(); + const { message } = App.useApp(); const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get("filter") || ""; + const operationFilter = searchParams.get("operation") || "all"; const status = searchParams.get("status") === "enabled" || searchParams.get("status") === "disabled" ? searchParams.get("status") as "enabled" | "disabled" : "all"; - const [templates, setTemplates] = useState([]); - const [variables, setVariables] = useState([]); + const [templates, setTemplates] = useState([]); + const [definitions, setDefinitions] = useState([]); const [loading, setLoading] = useState(true); const [drawerOpen, setDrawerOpen] = useState(false); - const [editing, setEditing] = useState(null); + const [baseTemplate, setBaseTemplate] = useState(null); + const [draftOperation, setDraftOperation] = useState(""); + const [pendingOperation, setPendingOperation] = useState(""); + const [draftBaseline, setDraftBaseline] = useState(null); + const [editorContent, setEditorContent] = useState(""); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); - const hasFilters = Boolean(keyword || status !== "all"); + const editorRef = useRef(null); + const draftName = Form.useWatch("name", form) || ""; + const draftEnabled = Form.useWatch("enabled", form) === true; + const selectedDefinition = definitions.find((item) => item.operation === draftOperation); + const hasFilters = Boolean(keyword || operationFilter !== "all" || status !== "all"); + const dirty = Boolean(draftBaseline) && ( + draftOperation !== draftBaseline?.operation + || draftName !== draftBaseline?.name + || draftEnabled !== draftBaseline?.enabled + || editorContent !== draftBaseline?.content + ); - const updateUrl = (patch: { filter?: string; status?: string }) => { + const updateUrl = (patch: { filter?: string; operation?: string; status?: string }) => { const next = new URLSearchParams(searchParams); - if (patch.filter !== undefined) patch.filter ? next.set("filter", patch.filter) : next.delete("filter"); - if (patch.status !== undefined) patch.status !== "all" ? next.set("status", patch.status) : next.delete("status"); + Object.entries(patch).forEach(([key, value]) => value && value !== "all" ? next.set(key, value) : next.delete(key)); setSearchParams(next); }; const reload = async () => { setLoading(true); try { - const result = await listAdminStoryboardPromptTemplates(); + const result = await listAdminPromptTemplates(); setTemplates(result.templates); - setVariables(result.variables); + setDefinitions(result.definitions); } catch (error) { - message.error(error instanceof Error ? error.message : "读取分镜提示词失败"); + message.error(error instanceof Error ? error.message : "读取提示词模板失败"); } finally { setLoading(false); } @@ -47,91 +70,189 @@ export default function StoryboardPromptsPage() { useEffect(() => { void reload(); }, []); + const definitionByOperation = useMemo(() => new Map(definitions.map((item) => [item.operation, item])), [definitions]); const filtered = useMemo(() => templates.filter((template) => { + const definition = definitionByOperation.get(template.operation); const normalizedKeyword = keyword.trim().toLowerCase(); - if (normalizedKeyword && !`${template.name} ${template.content}`.toLowerCase().includes(normalizedKeyword)) return false; + if (normalizedKeyword && !`${template.name} ${template.content} ${definition?.label || ""}`.toLowerCase().includes(normalizedKeyword)) return false; + if (operationFilter !== "all" && template.operation !== operationFilter) return false; if (status === "enabled" && !template.enabled) return false; if (status === "disabled" && template.enabled) return false; return true; - }), [keyword, status, templates]); + }), [definitionByOperation, keyword, operationFilter, status, templates]); - const openDrawer = (template?: StoryboardPromptTemplate) => { - setEditing(template || null); + const activeTemplateFor = (operation: string) => templates.find((template) => template.operation === operation && template.enabled); + + const loadDraftBaseline = (operation: string, template?: PromptTemplate) => { + const source = template || activeTemplateFor(operation); + const baseline = { + operation, + name: source ? `${source.name} · 新版本` : "", + enabled: false, + content: source?.content || "", + }; + setDraftOperation(operation); + setPendingOperation(""); + setEditorContent(baseline.content); + setDraftBaseline(baseline); + form.setFieldsValue({ name: baseline.name, enabled: baseline.enabled }); + }; + + const openDrawer = (template?: PromptTemplate) => { + const operation = template?.operation || definitions[0]?.operation || ""; + setBaseTemplate(template || null); form.resetFields(); - form.setFieldsValue(template ? { name: template.name, content: template.content, enabled: template.enabled } : { name: "", content: "", enabled: false }); + loadDraftBaseline(operation, template); setDrawerOpen(true); }; + const switchOperation = (operation: string) => { + if (!dirty) { + loadDraftBaseline(operation); + return; + } + setPendingOperation(operation); + }; + const closeDrawer = () => { if (saving) return; - if (!form.isFieldsTouched()) return setDrawerOpen(false); - modal.confirm({ title: "放弃提示词修改?", content: "尚未保存的版本内容将丢失。", okText: "放弃修改", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => setDrawerOpen(false) }); + setDrawerOpen(false); + setPendingOperation(""); }; const save = async () => { const values = await form.validateFields(); + if (!editorContent.trim()) { + message.warning("请填写提示词模板内容"); + return; + } setSaving(true); try { - const payload = { name: values.name.trim(), content: values.content, enabled: values.enabled === true }; - await (editing ? updateAdminStoryboardPromptTemplate(editing.id, payload) : createAdminStoryboardPromptTemplate(payload)); + await createAdminPromptTemplate({ operation: draftOperation, name: values.name.trim(), content: editorContent, enabled: values.enabled === true }); + setDraftBaseline(null); setDrawerOpen(false); - form.resetFields(); await reload(); - message.success(editing ? "分镜提示词已更新" : "分镜提示词已创建"); + message.success("提示词新版本已创建"); } catch (error) { - message.error(error instanceof Error ? error.message : "保存分镜提示词失败"); + message.error(error instanceof Error ? error.message : "保存提示词模板失败"); } finally { setSaving(false); } }; - const activate = async (template: StoryboardPromptTemplate) => { + const activate = async (template: PromptTemplate) => { try { - await updateAdminStoryboardPromptTemplate(template.id, { name: template.name, content: template.content, enabled: true }); + await updateAdminPromptTemplate(template.id, { operation: template.operation, name: template.name, content: template.content, enabled: true }); await reload(); - message.success("分镜提示词已启用"); + message.success("提示词版本已启用"); } catch (error) { - message.error(error instanceof Error ? error.message : "启用分镜提示词失败"); + message.error(error instanceof Error ? error.message : "启用提示词版本失败"); } }; - const remove = async (template: StoryboardPromptTemplate) => { + const remove = async (template: PromptTemplate) => { try { - await deleteAdminStoryboardPromptTemplate(template.id); + await deleteAdminPromptTemplate(template.id); await reload(); - message.success("分镜提示词已删除"); + message.success("提示词版本已删除"); } catch (error) { - message.error(error instanceof Error ? error.message : "删除分镜提示词失败"); + message.error(error instanceof Error ? error.message : "删除提示词版本失败"); } }; - const columns: ColumnsType = [ - { title: "模板", dataIndex: "name", render: (_, template) =>
{template.name}
{template.content.length} 字符
}, - { title: "内容摘要", dataIndex: "content", width: 420, render: (content: string) =>
{content}
}, - { title: "状态", dataIndex: "enabled", width: 110, render: (enabled) => {enabled ? "启用中" : "未启用"} }, + const columns: ColumnsType = [ + { title: "模板类型", dataIndex: "operation", width: 180, render: (operation: string) => { const definition = definitionByOperation.get(operation); return
{definition?.label || operation}
{definition?.category || "--"}
; } }, + { title: "版本", dataIndex: "name", render: (_, template) =>
{template.name}
v{template.version} · {template.content.length} 字符
}, + { title: "输出", dataIndex: "outputType", width: 120, render: (outputType: string, template) => {outputType === "json" ? : }{outputType === "json" ? definitionByOperation.get(template.operation)?.schemaKey || "JSON" : "文本"} }, + { title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => {enabled ? "启用中" : "历史版"} }, { title: "更新时间", dataIndex: "updatedAt", width: 180, render: formatTime }, - { title: "操作", width: 140, fixed: "right", align: "right", render: (_, template) => , onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: , disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "启用后会立即替换当前 Agent 分镜生成所使用的版本。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: , danger: true, disabled: template.enabled, confirm: { title: "删除这个提示词版本?", description: "删除后不可恢复;启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> }, + { title: "操作", width: 170, fixed: "right", align: "right", render: (_, template) => , onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: , disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "只会替换同类型的当前版本,其他模板类型不受影响。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: , danger: true, disabled: template.enabled, confirm: { title: "删除这个历史版本?", description: "删除后不可恢复,启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> }, ]; - const insertVariable = (placeholder: string) => { - const current = form.getFieldValue("content") || ""; - form.setFieldValue("content", `${current}${current && !current.endsWith("\n") ? "\n" : ""}${placeholder}`); - }; - return ( - } onClick={() => openDrawer()}>新增版本}> -
真实用于 Agent 分镜生成

当前启用版本会与镜头时长、数量和电影化质量约束一起发送给文本模型。展开行可查看完整提示词。

- updateUrl({ filter: "", status: "all" })}> - } value={keyword} placeholder="搜索版本名称或提示词" onChange={(event) => updateUrl({ filter: event.target.value })} /> - } value={keyword} placeholder="搜索模板或内容" onChange={(event) => updateUrl({ filter: event.target.value })} /> + updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "历史版本", value: "disabled" }]} /> - {loading && templates.length === 0 ? : }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1080 }} expandable={{ expandedRowRender: (template) =>
{template.content}
}} />} - void save()}>保存}> -
- -
{variables.map((variable) => )}
- -
}} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1120 }} expandable={{ expandedRowRender: (template) =>
{template.content}
}} />} + + } + > + +
+ + + + + ({ + value: item.definition.operation, + label: `${item.definition.category} · ${item.definition.label}${item.customization && item.customization.mode !== "inherit" ? " · 已定制" : ""}`, + }))} + /> +
+
+ + + +
+ + +
+
+
+

{selected.definition.label}

+ 平台 v{selected.template?.version || "--"} + {outputLabel} + {dirty ? 未保存 : null} +
+

{selected.definition.description}

+
+ setMode(value as CustomizationMode)} /> +
+ + + {selected.outdated ? : null} + +
+
+
+

{mode === "inherit" ? "当前平台模板" : mode === "append" ? "追加个人要求" : "改写创作策略"}

+

+ {mode === "inherit" ? "平台升级后自动使用新版本。" : mode === "append" ? "内容追加在平台策略之后,仍会自动继承平台升级。" : "只替换创作策略;动态项目数据和输出契约仍由服务端强制注入。"} +

+
+ {mode === "append" ? ( + setAppendContent(event.target.value)} + /> + ) : ( +
+ +
+ )} +
+ + +
+ + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 1e01df786..ed6a41b65 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -69,7 +69,8 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: "users", element: }, { path: "channels", element: }, - { path: "storyboard-prompts", element: }, + { path: "prompt-templates", element: }, + { path: "storyboard-prompts", element: }, { path: "announcements", element: }, { path: "credit-operations", element: }, { path: "redemption-codes", element: }, diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index d58a258ec..4f0e68e30 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -201,21 +201,51 @@ export type ModelPricing = { updatedAt: string; }; -export type StoryboardPromptTemplate = { +export type PromptTemplate = { id: string; + operation: string; name: string; + version: number; content: string; + outputType: "json" | "text"; enabled: boolean; createdBy?: string; createdAt: string; updatedAt: string; }; -export type StoryboardPromptVariable = { +export type PromptTemplateVariable = { label: string; placeholder: string; }; +export type PromptOperationDefinition = { + operation: string; + label: string; + category: string; + description: string; + outputType: "json" | "text"; + schemaKey?: string; + variables: PromptTemplateVariable[]; + outputContract: string; +}; + +export type UserPromptCustomization = { + id: string; + operation: string; + mode: "inherit" | "append" | "rewrite"; + content: string; + baseTemplateId: string; + updatedAt: string; +}; + +export type UserPromptPreference = { + definition: PromptOperationDefinition; + template: PromptTemplate | null; + customization?: UserPromptCustomization; + outdated: boolean; +}; + export type AdminOSSSetting = { enabled: boolean; provider: "aliyun"; @@ -397,20 +427,32 @@ export function deleteAdminChannel(id: string) { return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(id)}`)); } -export function listAdminStoryboardPromptTemplates() { - return request<{ templates: StoryboardPromptTemplate[]; variables: StoryboardPromptVariable[] }>(api.get("/admin/storyboard-prompts")); +export function listAdminPromptTemplates() { + return request<{ templates: PromptTemplate[]; definitions: PromptOperationDefinition[] }>(api.get("/admin/prompt-templates")); } -export function createAdminStoryboardPromptTemplate(input: Partial>) { - return request<{ template: StoryboardPromptTemplate }>(api.post("/admin/storyboard-prompts", input)); +export function createAdminPromptTemplate(input: Pick & { enabled?: boolean }) { + return request<{ template: PromptTemplate }>(api.post("/admin/prompt-templates", input)); } -export function updateAdminStoryboardPromptTemplate(id: string, input: Partial>) { - return request<{ template: StoryboardPromptTemplate }>(api.patch(`/admin/storyboard-prompts/${encodeURIComponent(id)}`, input)); +export function updateAdminPromptTemplate(id: string, input: Pick & { enabled?: boolean }) { + return request<{ template: PromptTemplate }>(api.patch(`/admin/prompt-templates/${encodeURIComponent(id)}`, input)); } -export function deleteAdminStoryboardPromptTemplate(id: string) { - return request<{ ok: boolean }>(api.delete(`/admin/storyboard-prompts/${encodeURIComponent(id)}`)); +export function deleteAdminPromptTemplate(id: string) { + return request<{ ok: boolean }>(api.delete(`/admin/prompt-templates/${encodeURIComponent(id)}`)); +} + +export function listUserPromptPreferences() { + return request<{ preferences: UserPromptPreference[] }>(api.get("/settings/prompt-templates")); +} + +export function updateUserPromptCustomization(operation: string, input: Pick) { + return request<{ customization: UserPromptCustomization }>(api.patch(`/settings/prompt-templates/${encodeURIComponent(operation)}`, input)); +} + +export function resetUserPromptCustomization(operation: string) { + return request<{ ok: boolean }>(api.delete(`/settings/prompt-templates/${encodeURIComponent(operation)}`)); } export function getAdminOSSSetting() { diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index b46d95d46..4fac12dcb 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -183,7 +183,7 @@ export function backendProviderConfig(config: AiConfig) { audioFormat: config.audioFormat, audioSpeed: config.audioSpeed, audioInstructions: config.audioInstructions, - systemPrompt: config.systemPrompt, + systemPrompt: "", }; } diff --git a/web/src/services/api/task-center.ts b/web/src/services/api/task-center.ts index 8747b2dfd..b2be34082 100644 --- a/web/src/services/api/task-center.ts +++ b/web/src/services/api/task-center.ts @@ -117,6 +117,8 @@ export type CreateSessionInput = { prompt: string; canvasSnapshot?: Record; references?: string[]; + projectStyle?: { presetId: string; title: string; prompt: string }; + characters?: Array<{ assetId: string; versionId: string; name: string; definition: Record }>; config?: Record; }; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 30f1d2f52..20ae0b589 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -303,7 +303,9 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) { audioVoice: config.audioVoice || defaultConfig.audioVoice, audioFormat: config.audioFormat || defaultConfig.audioFormat, audioSpeed: config.audioSpeed || defaultConfig.audioSpeed, - audioInstructions: config.audioInstructions || "", + audioInstructions: config.audioInstructions || "", + // 旧版全局 systemPrompt 会跨任务污染请求;提示词定制现已按 operation 由服务端编译。 + systemPrompt: "", videoSeconds: normalizeVideoDuration(config.videoSeconds), vquality: normalizeVideoResolution(config.vquality), videoGenerateAudio: config.videoGenerateAudio || "true", diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index e3faf765d..4fe99b5b0 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -554,6 +554,59 @@ } } +/* CodeMirror 使用运行时注入样式,需在全局覆盖中绑定项目主题 token。 */ +.prompt-code-editor, +.prompt-code-editor .cm-editor { + width: 100%; + height: 100%; + min-height: 0; +} + +.prompt-code-editor .cm-editor { + background: var(--workspace-surface-strong) !important; + color: var(--foreground); + font-size: var(--fs-caption); +} + +.prompt-code-editor .cm-editor.cm-focused { + outline: 2px solid color-mix(in srgb, var(--workspace-accent) 42%, transparent); + outline-offset: -2px; +} + +.prompt-code-editor .cm-scroller { + overflow: auto; + overflow-x: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + line-height: 1.65; +} + +.prompt-code-editor .cm-content { + padding-block: var(--space-3); + color: var(--foreground) !important; + caret-color: var(--foreground); +} + +.prompt-code-editor .cm-line { + padding-inline: var(--space-3); + overflow-wrap: anywhere; +} + +.prompt-code-editor .cm-gutters { + border-right: 1px solid var(--workspace-border) !important; + background: color-mix(in srgb, var(--workspace-surface-strong) 92%, var(--foreground) 8%) !important; + color: color-mix(in srgb, var(--foreground) 42%, transparent) !important; +} + +.prompt-code-editor .cm-activeLine, +.prompt-code-editor .cm-activeLineGutter { + background: color-mix(in srgb, var(--workspace-accent) 7%, transparent) !important; +} + +.prompt-code-editor .cm-selectionBackground, +.prompt-code-editor .cm-content ::selection { + background: color-mix(in srgb, var(--workspace-accent) 24%, transparent) !important; +} + @layer utilities { .app-spatial-workspace { --workspace-canvas: #f5f5f5; @@ -3275,6 +3328,30 @@ backdrop-filter: blur(5px); } + /* 画风选择器自身就是完整工作区表面,移除 AntD 外壳避免形成双层厚边。 */ + .canvas-style-picker-modal .ant-modal-container, + .canvas-style-detail-modal .ant-modal-container { + overflow: hidden; + border: var(--stroke-1) solid var(--border) !important; + border-radius: var(--r-2xl) !important; + padding: 0 !important; + background: transparent !important; + } + + .canvas-style-picker-modal .ant-modal-body, + .canvas-style-detail-modal .ant-modal-body { + padding: 0 !important; + } + + .canvas-style-picker-modal .ant-modal-close, + .canvas-style-detail-modal .ant-modal-close { + top: var(--space-2); + right: var(--space-2); + color: var(--cn-text); + background: color-mix(in srgb, var(--cn-spatial-elevated) 84%, transparent); + backdrop-filter: blur(var(--space-2)); + } + :where(.ant-select-dropdown, .ant-dropdown-menu, .ant-popover-inner) { border: 1px solid var(--border); box-shadow: 0 20px 56px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index d0103d5e0..707af086d 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -29,6 +29,7 @@ export type CanvasWorkspaceMode = "simple" | "professional"; export type CanvasToolMode = "move" | "box-select"; export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30"; export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10"; +export type StoryboardVideoInputMode = "direct" | "keyframe"; export type CanvasGenerationMode = "text" | "image" | "video" | "audio"; export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board"; export type CanvasGenerationBatchStatus = "queued" | "running" | "partial_failed" | "completed" | "cancelled"; @@ -38,10 +39,12 @@ export type CanvasWorkflowKind = "free" | "script" | "story_input" | "character" export type CanvasVideoEditOperation = "text_to_video" | "image_to_video" | "extend" | "inpaint" | "replace_element" | "camera_motion" | "style_transfer" | "audio_to_video" | "compare_versions" | "concat"; export type CanvasSkillCategory = "writing" | "storyboard" | "image" | "video" | "utility"; export type CanvasSkillOutputMode = "text" | "json" | "image_prompt" | "workflow"; -export type StoryboardColumn = "shotNumber" | "durationSeconds" | "plotDescription" | "dialogue" | "shotSize" | "emotion" | "lightingAndAtmosphere" | "audioEffects" | "camera" | "motion" | "timeBeats" | "imageGenerationPrompt" | "videoMotionPrompt" | "negativePrompt"; +export type StoryboardColumn = "shotNumber" | "durationSeconds" | "plotDescription" | "dialogue" | "narrativeIntent" | "viewerPOV" | "performanceBlocking" | "shotSize" | "emotion" | "lightingAndAtmosphere" | "audioEffects" | "camera" | "motion" | "timeBeats" | "imageGenerationPrompt" | "videoMotionPrompt" | "continuityOut" | "negativePrompt"; export type StoryboardCharacterReference = { characterName: string; + characterAssetId?: string; + characterVersionId?: string; characterDescription?: string; characterImageNodeId?: string; }; @@ -53,6 +56,9 @@ export type StoryboardRow = { plotDescription: string; dialogue: string; characters: StoryboardCharacterReference[]; + narrativeIntent: string; + viewerPOV: string; + performanceBlocking: string; shotSize: string; emotion: string; lightingAndAtmosphere: string; @@ -62,6 +68,11 @@ export type StoryboardRow = { timeBeats: string; imageGenerationPrompt: string; videoMotionPrompt: string; + imagePromptTemplateVariables?: Record; + videoPromptTemplateVariables?: Record; + mustHave: string[]; + optionalDetails: string[]; + continuityOut: string; negativePrompt: string; referenceNodeIds: string[]; imageNodeId?: string; @@ -115,6 +126,8 @@ export type CanvasNodeMetadata = { richText?: Record; composerContent?: string; prompt?: string; + promptTemplateOperation?: string; + promptTemplateVariables?: Record; status?: CanvasNodeStatus; locked?: boolean; errorDetails?: string; @@ -217,6 +230,7 @@ export type CanvasNodeMetadata = { storyboard?: StoryboardData; storyboardShotDuration?: StoryboardShotDuration; storyboardShotCount?: StoryboardShotCount; + storyboardVideoInputMode?: StoryboardVideoInputMode; storyboardComposerHeight?: number; generationBatches?: CanvasGenerationBatch[]; frame?: {