diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 349e10053..c6eabb442 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -244,6 +244,7 @@ func migrations() []tableMigration { migrateTable[model.VoiceProfile]("voice_profiles"), migrateTable[model.CharacterVoiceBinding]("character_voice_bindings"), migrateTable[model.Project]("projects"), + migrateTable[model.StyleProfile]("style_profiles"), migrateTable[model.ProjectUnit]("project_units"), migrateTable[model.CanvasUnitLink]("canvas_unit_links"), migrateTable[model.Shot]("shots"), diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index b2f81598e..a81de6ea3 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -39,6 +39,7 @@ func Models() []any { &model.VoiceProfile{}, &model.CharacterVoiceBinding{}, &model.Project{}, + &model.StyleProfile{}, &model.ProjectUnit{}, &model.CanvasUnitLink{}, &model.Shot{}, diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 91e54cf97..3ad0502dc 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -9,6 +9,7 @@ import ( ) func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { + RegisterStyleProfileRoutes(r, svc) r.GET("/voice-profiles", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/handler/style_profile.go b/backend/internal/handler/style_profile.go new file mode 100644 index 000000000..1bcc91809 --- /dev/null +++ b/backend/internal/handler/style_profile.go @@ -0,0 +1,117 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterStyleProfileRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/style-profiles", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + profiles, err := svc.ListStyleProfiles(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"profiles": profiles}) + }) + r.POST("/style-profiles", 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, 320<<10) + var req service.StyleProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + profile, err := svc.CreateStyleProfile(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"profile": profile}) + }) + r.PATCH("/style-profiles/:id", 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, 320<<10) + var req service.StyleProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + profile, err := svc.UpdateStyleProfile(user.ID, c.Param("id"), req) + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"profile": profile}) + }) + r.PATCH("/style-profiles/:id/favorite", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.StyleProfileFavoriteRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.SetStyleProfileFavorite(user.ID, c.Param("id"), req.Favorite); service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } else if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id"), "favorite": req.Favorite}) + }) + r.POST("/style-profiles/:id/use", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.TouchStyleProfile(user.ID, c.Param("id")); service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } else if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id")}) + }) + r.DELETE("/style-profiles/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteStyleProfile(user.ID, c.Param("id")); service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } else if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id")}) + }) +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 7a5818edd..d2c2bce90 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -109,18 +109,35 @@ type CharacterVoiceBinding struct { // Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 type Project struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` - Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` - Type string `json:"type" gorm:"size:32;index"` - AspectRatio string `json:"aspectRatio" gorm:"size:16"` - SourceType string `json:"sourceType" gorm:"size:32"` - Description string `json:"description" gorm:"type:text"` - StylePresetID string `json:"stylePresetId" gorm:"size:64"` - Status ProjectStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` + Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` + Type string `json:"type" gorm:"size:32;index"` + AspectRatio string `json:"aspectRatio" gorm:"size:16"` + SourceType string `json:"sourceType" gorm:"size:32"` + Description string `json:"description" gorm:"type:text"` + StylePresetID string `json:"stylePresetId" gorm:"size:64"` + StyleProfileJSON string `json:"styleProfileJson" gorm:"type:text"` + Status ProjectStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +// StyleProfile 是用户可持续编辑的风格源;项目只保存应用当时的 JSON 快照,避免源对象更新污染历史生成。 +type StyleProfile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_style_profiles_user_updated,priority:1"` + Name string `json:"name" gorm:"size:160"` + Description string `json:"description" gorm:"size:500"` + CoverURL string `json:"coverUrl" gorm:"type:text"` + TagsJSON string `json:"tagsJson" gorm:"type:text"` + ProfileJSON string `json:"profileJson" gorm:"type:text"` + Favorite bool `json:"favorite" gorm:"index"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"index"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_style_profiles_user_updated,priority:2"` } type ProjectUnit struct { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index d3ee39dea..77760ed47 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -934,7 +934,7 @@ func (r *Repository) CreateProject(project *model.Project) error { func (r *Repository) UpdateProject(project *model.Project) error { return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{ "name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType, - "description": project.Description, "style_preset_id": project.StylePresetID, + "description": project.Description, "style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON, "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt, }).Error } diff --git a/backend/internal/repository/style_profile.go b/backend/internal/repository/style_profile.go new file mode 100644 index 000000000..109de1a3f --- /dev/null +++ b/backend/internal/repository/style_profile.go @@ -0,0 +1,73 @@ +package repository + +import ( + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func (r *Repository) StyleProfiles(userID string) ([]model.StyleProfile, error) { + var profiles []model.StyleProfile + err := r.db.Where("user_id = ?", userID).Order("favorite desc, updated_at desc").Find(&profiles).Error + return profiles, err +} + +func (r *Repository) StyleProfileCount(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.StyleProfile{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +func (r *Repository) StyleProfileForUser(userID string, id string) (*model.StyleProfile, error) { + var profile model.StyleProfile + if err := r.db.First(&profile, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &profile, nil +} + +func (r *Repository) CreateStyleProfile(profile *model.StyleProfile) error { + return r.db.Create(profile).Error +} + +func (r *Repository) UpdateStyleProfile(profile *model.StyleProfile) error { + return r.db.Model(&model.StyleProfile{}).Where("id = ? AND user_id = ?", profile.ID, profile.UserID).Updates(map[string]any{ + "name": profile.Name, "description": profile.Description, "cover_url": profile.CoverURL, + "tags_json": profile.TagsJSON, "profile_json": profile.ProfileJSON, "revision": profile.Revision, "updated_at": profile.UpdatedAt, + }).Error +} + +func (r *Repository) SetStyleProfileFavorite(userID string, id string, favorite bool) error { + result := r.db.Model(&model.StyleProfile{}).Where("id = ? AND user_id = ?", id, userID).Update("favorite", favorite) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) TouchStyleProfile(userID string, id string, usedAt time.Time) error { + result := r.db.Model(&model.StyleProfile{}).Where("id = ? AND user_id = ?", id, userID).Update("last_used_at", usedAt) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) DeleteStyleProfile(userID string, id string) error { + result := r.db.Delete(&model.StyleProfile{}, "id = ? AND user_id = ?", id, userID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 0d99fc58d..fc8bc68c9 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -137,7 +137,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err != nil { return nil, err } - if capability == "video" { + if capability == "image" || capability == "video" { if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { return nil, err } @@ -193,7 +193,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits item.PriceConfigured = req.PriceConfigured - if capability == "video" { + if capability == "image" || capability == "video" { capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) if normalizeErr != nil { return nil, normalizeErr @@ -234,7 +234,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, if err != nil { return nil, err } - if capability == "video" { + if capability == "image" || capability == "video" { if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { return nil, err } @@ -284,6 +284,13 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, }, Metadata: map[string]interface{}{}, } + if capability == "image" { + profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) + if normalizeErr != nil { + return nil, normalizeErr + } + input.ImageCapability = profile.Image + } // 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。 testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index c429b19f3..ac85d57af 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -14,9 +14,44 @@ import ( // ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。 type ModelCapabilityConfig struct { Version int `json:"version"` + Image *ImageCapabilityConfig `json:"image,omitempty"` Video *VideoCapabilityConfig `json:"video,omitempty"` } +type ImageCapabilityConfig struct { + References ImageReferenceConfig `json:"references"` + Size ImageSizeConfig `json:"size"` + Quality ImageQualityConfig `json:"quality"` + TransparentBackground VideoBooleanConfig `json:"transparentBackground"` + ResponseFormat ParameterSupport `json:"responseFormat"` + OutputFormat ParameterSupport `json:"outputFormat"` + MaxOutputs int `json:"maxOutputs"` +} + +type ImageReferenceConfig struct { + PromptMaxChars int `json:"promptMaxChars"` + MaxImages int `json:"maxImages"` + MaxImageBytes int64 `json:"maxImageBytes"` + MaskSupported bool `json:"maskSupported"` +} + +type ImageSizeConfig struct { + Parameter string `json:"parameter"` + Values []string `json:"values"` + Default string `json:"default"` + AllowCustom bool `json:"allowCustom"` +} + +type ImageQualityConfig struct { + Supported bool `json:"supported"` + Values []string `json:"values"` + Default string `json:"default"` +} + +type ParameterSupport struct { + Supported bool `json:"supported"` +} + type VideoCapabilityConfig struct { References VideoReferenceConfig `json:"references"` Duration VideoDurationConfig `json:"duration"` @@ -57,6 +92,48 @@ type VideoBooleanConfig struct { } func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { + return DefaultModelCapabilityConfigForModel(protocol, "") +} + +func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig { + image := &ImageCapabilityConfig{ + References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true}, + Size: ImageSizeConfig{Parameter: "size", Values: []string{"1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840"}, Default: "1:1", AllowCustom: true}, + Quality: ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "auto"}, + TransparentBackground: VideoBooleanConfig{Supported: true, Default: false}, + ResponseFormat: ParameterSupport{Supported: true}, + OutputFormat: ParameterSupport{Supported: true}, + MaxOutputs: 15, + } + switch model.ChannelInterfaceType(protocol) { + case model.ChannelInterfaceVolcengineArkImage: + image.References.MaskSupported = false + image.Quality.Supported = false + image.TransparentBackground.Supported = false + image.ResponseFormat.Supported = false + image.OutputFormat.Supported = false + case model.ChannelInterfaceVolcengineJiMengImage: + image.References.MaxImages = 14 + image.References.MaskSupported = false + image.Quality.Supported = false + image.TransparentBackground.Supported = false + image.ResponseFormat.Supported = false + image.OutputFormat.Supported = false + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") { + image.References.MaxImages = 0 + image.References.MaskSupported = false + image.Size = ImageSizeConfig{Parameter: "none", Values: []string{}, Default: "auto", AllowCustom: false} + image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"} + image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} + image.ResponseFormat = ParameterSupport{Supported: true} + image.OutputFormat = ParameterSupport{Supported: false} + image.MaxOutputs = 1 + } + return image +} + +func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { video := &VideoCapabilityConfig{ References: VideoReferenceConfig{PromptMaxChars: 1000, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, @@ -90,7 +167,7 @@ func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} } - return &ModelCapabilityConfig{Version: 1, Video: video} + return &ModelCapabilityConfig{Version: 1, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} } func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { @@ -104,10 +181,20 @@ func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { return &value, nil } -func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { - if capability != "video" { +func NormalizeModelCapabilityConfig(capability string, _ string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { + if capability != "image" && capability != "video" { return nil, nil } + if capability == "image" { + if input == nil || input.Image == nil { + return nil, BadAuthRequest("请配置图片模型能力参数") + } + value := &ModelCapabilityConfig{Version: 1, Image: input.Image} + if err := validateImageCapabilityConfig(value.Image); err != nil { + return nil, err + } + return value, nil + } if input == nil || input.Video == nil { return nil, BadAuthRequest("请配置视频模型能力参数") } @@ -118,6 +205,45 @@ func NormalizeModelCapabilityConfig(capability string, protocol string, input *M return value, nil } +func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { + if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { + return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") + } + if value.References.MaxImages < 0 || value.References.MaxImages > 100 || value.References.MaxImageBytes < 0 { + return BadAuthRequest("图片引用限制无效") + } + if value.MaxOutputs < 1 || value.MaxOutputs > 100 { + return BadAuthRequest("单次图片数量必须在 1-100 之间") + } + switch value.Size.Parameter { + case "none": + value.Size.Values = []string{} + value.Size.Default = "auto" + value.Size.AllowCustom = false + case "size", "aspect_ratio": + if strings.TrimSpace(value.Size.Default) == "" { + return BadAuthRequest("请配置默认图片尺寸或比例") + } + if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) { + return BadAuthRequest("默认图片尺寸必须属于支持值") + } + default: + return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio") + } + if value.Quality.Supported { + if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { + return BadAuthRequest("请配置图片质量支持值和默认值") + } + } else { + value.Quality.Values = []string{} + value.Quality.Default = "auto" + } + if !value.TransparentBackground.Supported { + value.TransparentBackground.Default = false + } + return nil +} + func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") @@ -177,7 +303,7 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { return BadAuthRequest("任务输入格式无效") } var taskInput canvasGenerationInput - if err := json.Unmarshal(encoded, &taskInput); err != nil || taskInput.Mode != "video" { + if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video") { return nil } channelID := strings.TrimSpace(taskInput.Config.ChannelID) @@ -185,6 +311,13 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL) } if channelID == "" { + if taskInput.Mode == "image" { + profile := DefaultImageCapabilityConfig(taskInput.Config.InterfaceType, taskInput.Config.Model) + if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil { + profile = taskInput.Config.CapabilityConfig.Image + } + return validateImageTask(profile, taskInput) + } if taskInput.Config.CapabilityConfig == nil || taskInput.Config.CapabilityConfig.Video == nil { return nil } @@ -195,6 +328,16 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { return BadAuthRequest("当前系统渠道模型未配置或已停用") } profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if taskInput.Mode == "image" { + if err != nil { + return BadAuthRequest("当前图片模型能力参数无效") + } + imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), item.ModelKey) + if profile != nil && profile.Image != nil { + imageProfile = profile.Image + } + return validateImageTask(imageProfile, taskInput) + } if err != nil || profile == nil || profile.Video == nil { return BadAuthRequest("当前视频模型尚未配置能力参数") } @@ -253,6 +396,37 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp return nil } +func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error { + if profile == nil { + return nil + } + if utf8.RuneCountInString(input.Prompt) > profile.References.PromptMaxChars { + return BadAuthRequest(fmt.Sprintf("提示词超过当前模型限制(最多 %d 字)", profile.References.PromptMaxChars)) + } + if len(input.ReferenceImages) > profile.References.MaxImages { + return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)) + } + for _, media := range input.ReferenceImages { + if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { + return BadAuthRequest("参考图片文件超过当前模型大小限制") + } + } + if input.Mask != nil && !profile.References.MaskSupported { + return BadAuthRequest("当前图片模型不支持蒙版编辑") + } + if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + return BadAuthRequest("图片尺寸不在当前模型支持范围内") + } + if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { + return BadAuthRequest("图片质量不在当前模型支持范围内") + } + count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) + if err == nil && count > profile.MaxOutputs { + return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) + } + return nil +} + func videoDurationAllowed(value VideoDurationConfig, seconds int) bool { if value.Selection == "enum" { return containsInt(value.Values, seconds) diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index f0412f3e4..d6df1b6da 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -12,22 +12,24 @@ import ( ) type CreateProjectRequest struct { - Name string `json:"name"` - Type string `json:"type"` - AspectRatio string `json:"aspectRatio"` - SourceType string `json:"sourceType"` - Description string `json:"description"` - StylePresetID string `json:"stylePresetId"` + Name string `json:"name"` + Type string `json:"type"` + AspectRatio string `json:"aspectRatio"` + SourceType string `json:"sourceType"` + Description string `json:"description"` + StylePresetID string `json:"stylePresetId"` + StyleProfileJSON string `json:"styleProfileJson"` } type UpdateProjectRequest struct { - Name string `json:"name"` - Type string `json:"type"` - AspectRatio string `json:"aspectRatio"` - SourceType string `json:"sourceType"` - Description *string `json:"description"` - StylePresetID *string `json:"stylePresetId"` - Status string `json:"status"` + Name string `json:"name"` + Type string `json:"type"` + AspectRatio string `json:"aspectRatio"` + SourceType string `json:"sourceType"` + Description *string `json:"description"` + StylePresetID *string `json:"stylePresetId"` + StyleProfileJSON *string `json:"styleProfileJson"` + Status string `json:"status"` } type CreateProjectUnitRequest struct { @@ -175,8 +177,16 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model. if sourceType == "" { sourceType = "blank" } + styleProfileJSON, err := validateStyleProfileJSON(req.StyleProfileJSON) + if err != nil { + return model.Project{}, BadAuthRequest(err.Error()) + } + stylePresetID := strings.TrimSpace(req.StylePresetID) + if err := validateStyleProfilePreset(stylePresetID, styleProfileJSON); err != nil { + return model.Project{}, BadAuthRequest(err.Error()) + } now := time.Now() - project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: strings.TrimSpace(req.StylePresetID), Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} if err := s.repo.CreateProject(&project); err != nil { return model.Project{}, err } @@ -212,6 +222,16 @@ func (s *Service) UpdateProject(userID string, id string, req UpdateProjectReque if req.StylePresetID != nil { project.StylePresetID = strings.TrimSpace(*req.StylePresetID) } + if req.StyleProfileJSON != nil { + styleProfileJSON, profileErr := validateStyleProfileJSON(*req.StyleProfileJSON) + if profileErr != nil { + return model.Project{}, BadAuthRequest(profileErr.Error()) + } + project.StyleProfileJSON = styleProfileJSON + } + if err := validateStyleProfilePreset(project.StylePresetID, project.StyleProfileJSON); err != nil { + return model.Project{}, BadAuthRequest(err.Error()) + } if status := model.ProjectStatus(strings.TrimSpace(req.Status)); status != "" { if status != model.ProjectStatusActive && status != model.ProjectStatusArchived { return model.Project{}, BadAuthRequest("不支持的项目状态") diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index b308e63f6..ad68418b6 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -20,6 +20,8 @@ import ( "time" "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" ) type canvasGenerationInput struct { @@ -31,6 +33,7 @@ type canvasGenerationInput struct { ReferenceAudios []providerMedia `json:"referenceAudios"` Mask *providerMedia `json:"mask"` Metadata map[string]interface{} `json:"metadata"` + ImageCapability *ImageCapabilityConfig `json:"-"` VideoCapability *VideoCapabilityConfig `json:"-"` } @@ -149,7 +152,7 @@ func (e providerHTTPError) Error() string { return fmt.Sprintf("接口请求失败:%s %s", e.Status, e.Body) } -func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) { +func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) { var input canvasGenerationInput if err := json.Unmarshal([]byte(rawInput), &input); err != nil { return nil, fmt.Errorf("任务输入解析失败:%w", err) @@ -177,6 +180,11 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string return nil, err } input.Config = config + if (input.Mode == "image" || input.Mode == "video") && input.Metadata != nil { + if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil { + return nil, err + } + } if input.Config.APIFormat == "gemini" && input.Config.InterfaceType != string(model.ChannelInterfaceGeminiVeo) { return nil, errors.New("后端任务队列暂不支持 Gemini 调用格式,请使用 OpenAI 兼容渠道") } @@ -189,6 +197,11 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string if isVolcengineJiMengProtocol(input.Config.InterfaceType) && strings.TrimSpace(input.Config.SecretKey) == "" { return nil, errors.New("即梦官方 API 缺少 Secret Key") } + if input.Mode == "image" { + if err := s.validateResolvedImageCapability(&input); err != nil { + return nil, err + } + } if input.Mode == "video" { if err := s.validateResolvedVideoCapability(&input); err != nil { return nil, err @@ -223,6 +236,180 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +type styleExecutionPlanDocument struct { + SchemaVersion int `json:"schemaVersion"` + ProfilePresetID string `json:"profilePresetId"` + ProfileRevision int `json:"profileRevision"` + Mode string `json:"mode"` + Model string `json:"model"` + InterfaceType string `json:"interfaceType"` + Status string `json:"status"` + Prompt string `json:"prompt"` +} + +func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID string, input *canvasGenerationInput) error { + styleProfileJSON := metadataString(input.Metadata, "styleProfileJson") + if strings.TrimSpace(styleProfileJSON) == "" { + return nil + } + if _, err := validateStyleProfileJSON(styleProfileJSON); err != nil { + return fmt.Errorf("项目画风执行配置无效:%w", err) + } + var profile styleProfileDocument + if err := json.Unmarshal([]byte(styleProfileJSON), &profile); err != nil { + return fmt.Errorf("项目画风执行配置解析失败:%w", err) + } + storedProfileJSON, storedPresetID, belongsToProject, err := s.taskProjectStyleProfile(userID, taskProjectID) + if err != nil { + return fmt.Errorf("读取项目画风失败:%w", err) + } + if belongsToProject { + if strings.TrimSpace(storedProfileJSON) == "" { + // 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。 + if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) { + return errors.New("任务画风预设与项目当前设置不一致,请刷新画布后重试") + } + } else { + matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON) + if compareErr != nil || !matches { + return errors.New("任务画风快照与项目当前设置不一致,请刷新画布后重试") + } + } + } + plan, err := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"]) + if err != nil { + return err + } + stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat)) + if plan.SchemaVersion != 1 || plan.ProfilePresetID != profile.PresetID || plan.ProfileRevision != profile.Revision || plan.Mode != input.Mode || !strings.EqualFold(plan.Model, input.Config.Model) || plan.InterfaceType != firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat) { + return errors.New("项目画风执行计划与当前模型或快照不一致,请刷新配置后重试") + } + if plan.Status != expectedStatus || strings.TrimSpace(plan.Prompt) != stylePrompt { + return errors.New("项目画风执行计划已失效,请重新生成执行计划") + } + if expectedStatus == "blocked" { + return fmt.Errorf("项目画风与当前生成模型不兼容:%s", strings.Join(warnings, ";")) + } + if stylePrompt != "" && !strings.Contains(input.Prompt, stylePrompt) { + input.Prompt = strings.TrimSpace(input.Prompt) + "\n\n【项目画风执行规范】\n" + stylePrompt + } + return nil +} + +func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) { + id := strings.TrimSpace(canvasOrProjectID) + if id == "" { + return "", "", false, nil + } + if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil { + if strings.TrimSpace(canvas.ProjectID) == "" { + return "", "", false, nil + } + project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID) + if projectErr != nil { + return "", "", true, projectErr + } + return project.StyleProfileJSON, project.StylePresetID, true, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", false, err + } + project, err := s.repo.ProjectForUser(userID, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", false, nil + } + return "", "", false, err + } + return project.StyleProfileJSON, project.StylePresetID, true, nil +} + +func equivalentStyleProfileJSON(left string, right string) (bool, error) { + var leftValue interface{} + if err := json.Unmarshal([]byte(left), &leftValue); err != nil { + return false, err + } + var rightValue interface{} + if err := json.Unmarshal([]byte(right), &rightValue); err != nil { + return false, err + } + leftCanonical, err := json.Marshal(leftValue) + if err != nil { + return false, err + } + rightCanonical, err := json.Marshal(rightValue) + if err != nil { + return false, err + } + return bytes.Equal(leftCanonical, rightCanonical), nil +} + +func decodeStyleExecutionPlan(value interface{}) (styleExecutionPlanDocument, error) { + if value == nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划缺失") + } + raw, err := json.Marshal(value) + if err != nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") + } + var plan styleExecutionPlanDocument + if err := json.Unmarshal(raw, &plan); err != nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") + } + return plan, nil +} + +func resolveGenerationStyleExecution(profile styleProfileDocument, generationModel string, interfaceType string) (string, string, []string) { + fragments := []string{strings.TrimSpace(profile.Prompt)} + if negative := strings.TrimSpace(profile.NegativePrompt); negative != "" { + fragments = append(fragments, "【全局负面 Prompt】\n"+negative) + } + warnings := make([]string, 0) + for _, asset := range profile.Assets { + if asset.Enabled != nil && !*asset.Enabled { + continue + } + if asset.Status != "validated" { + reason := "资产尚未验证" + if asset.Status == "unavailable" { + reason = "资产当前不可用" + } + warnings = append(warnings, asset.Title+":"+reason) + continue + } + if len(asset.BaseModels) > 0 && !styleAssetSupportsModel(asset.BaseModels, generationModel) { + warnings = append(warnings, asset.Title+":仅兼容 "+strings.Join(asset.BaseModels, "、")) + continue + } + switch asset.Kind { + case "prompt", "template": + fragments = append(fragments, strings.TrimSpace(asset.PromptFragment)) + fragments = append(fragments, nonEmptyStyleProfileStrings(asset.TriggerWords)...) + case "reference": + warnings = append(warnings, asset.Title+":项目参考图自动注入适配器尚未启用") + case "lora": + warnings = append(warnings, asset.Title+":当前 "+firstNonEmpty(interfaceType, "图片")+" 协议未启用 LoRA 适配器") + } + } + normalizedFragments := nonEmptyStyleProfileStrings(fragments) + status := "ready" + if len(warnings) > 0 { + status = "degraded" + if profile.ExecutionPolicy == "strict-assets" { + status = "blocked" + } + } + return strings.Join(normalizedFragments, "\n"), status, warnings +} + +func styleAssetSupportsModel(baseModels []string, generationModel string) bool { + for _, baseModel := range baseModels { + if strings.EqualFold(strings.TrimSpace(baseModel), strings.TrimSpace(generationModel)) { + return true + } + } + return false +} + func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) error { channelID := strings.TrimSpace(input.Config.ChannelID) if channelID == "" { @@ -244,6 +431,33 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) return validateVideoTask(profile.Video, *input) } +func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error { + fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model) + channelID := strings.TrimSpace(input.Config.ChannelID) + if channelID == "" { + if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil { + input.ImageCapability = input.Config.CapabilityConfig.Image + } else { + input.ImageCapability = fallback + } + return validateImageTask(input.ImageCapability, *input) + } + item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/")) + if err != nil { + return errors.New("当前系统渠道模型未配置或已停用") + } + profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if err != nil { + return errors.New("当前图片模型能力参数无效") + } + if profile != nil && profile.Image != nil { + input.ImageCapability = profile.Image + } else { + input.ImageCapability = fallback + } + return validateImageTask(input.ImageCapability, *input) +} + func metadataStringValues(value any) map[string]string { values := map[string]string{} raw, ok := value.(map[string]interface{}) @@ -443,16 +657,20 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] writeField(writer, "model", input.Config.Model) writeField(writer, "prompt", withSystemPrompt(input.Config, input.Prompt)) writeField(writer, "n", "1") - writeField(writer, "response_format", "b64_json") - writeField(writer, "output_format", "png") - if input.Config.TransparentBackground == "true" { + if imageParameterSupported(input.ImageCapability, "response_format") { + writeField(writer, "response_format", "b64_json") + } + if imageParameterSupported(input.ImageCapability, "output_format") { + writeField(writer, "output_format", "png") + } + if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" { writeField(writer, "background", "transparent") } - if input.Config.Quality != "" { + if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" { writeField(writer, "quality", normalizeImageQuality(input.Config.Quality)) } - if size := normalizePixelSize(input.Config.Size); size != "" { - writeField(writer, "size", size) + if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { + writeField(writer, key, value) } for _, image := range input.ReferenceImages { if err := writeMediaPart(writer, "image", image); err != nil { @@ -472,20 +690,24 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] } } else { body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": withSystemPrompt(input.Config, input.Prompt), - "n": 1, - "response_format": "b64_json", - "output_format": "png", + "model": input.Config.Model, + "prompt": withSystemPrompt(input.Config, input.Prompt), + "n": 1, } - if input.Config.TransparentBackground == "true" { + if imageParameterSupported(input.ImageCapability, "response_format") { + body["response_format"] = "b64_json" + } + if imageParameterSupported(input.ImageCapability, "output_format") { + body["output_format"] = "png" + } + if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" { body["background"] = "transparent" } - if input.Config.Quality != "" { + if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" { body["quality"] = normalizeImageQuality(input.Config.Quality) } - if size := normalizePixelSize(input.Config.Size); size != "" { - body["size"] = size + if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { + body[key] = value } if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil { return nil, err @@ -525,8 +747,11 @@ func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{} "prompt": withSystemPrompt(input.Config, input.Prompt), "n": 1, } - if size := normalizeVolcengineArkImageSize(input.Config.Size); size != "" { - body["size"] = size + if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { + if key == "size" { + value = normalizeVolcengineArkImageSize(value) + } + body[key] = value } if len(input.ReferenceImages) == 0 { return body, nil diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 019162627..44914c02d 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -999,8 +999,61 @@ func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) { ReferenceImages: []providerMedia{{StorageKey: "resource:missing"}}, } raw, _ := json.Marshal(input) - _, err := (&Service{}).processCanvasGenerationTask(context.Background(), "user-1", "video_generate", "", string(raw)) + _, err := (&Service{}).processCanvasGenerationTask(context.Background(), "user-1", "", "video_generate", "", string(raw)) if err == nil || !strings.Contains(err.Error(), "不支持video生成") { t.Fatalf("processCanvasGenerationTask() error = %v", err) } } + +func TestResolveGenerationStyleExecutionUsesValidatedPromptAssets(t *testing.T) { + enabled := true + profile := styleProfileDocument{ + Prompt: "base style", + ExecutionPolicy: "compatible-fallback", + Assets: []styleProfileAsset{ + {ID: "prompt-1", Kind: "prompt", Title: "色彩约束", Provider: "builtin", Enabled: &enabled, Status: "validated", PromptFragment: "muted palette", TriggerWords: []string{"soft light"}}, + {ID: "lora-1", Kind: "lora", Title: "东方角色 LoRA", Provider: "liblib", Enabled: &enabled, Status: "validated", SourceID: "model-1"}, + }, + } + prompt, status, warnings := resolveGenerationStyleExecution(profile, "image-model", "openai-image") + if prompt != "base style\nmuted palette\nsoft light" { + t.Fatalf("resolveGenerationStyleExecution() prompt = %q", prompt) + } + if status != "degraded" || len(warnings) != 1 || !strings.Contains(warnings[0], "LoRA") { + t.Fatalf("resolveGenerationStyleExecution() status = %q, warnings = %#v", status, warnings) + } +} + +func TestResolveGenerationStyleExecutionStrictPolicyBlocksUnsupportedAsset(t *testing.T) { + profile := styleProfileDocument{ + Prompt: "base style", + ExecutionPolicy: "strict-assets", + Assets: []styleProfileAsset{{ID: "reference-1", Kind: "reference", Title: "项目参考图", Provider: "project", Status: "validated", ReferenceResourceIDs: []string{"resource-1"}}}, + } + _, status, warnings := resolveGenerationStyleExecution(profile, "image-model", "openai-image") + if status != "blocked" || len(warnings) != 1 { + t.Fatalf("resolveGenerationStyleExecution() status = %q, warnings = %#v", status, warnings) + } +} + +func TestResolveGenerationStyleExecutionSkipsPromptAssetForOtherModel(t *testing.T) { + profile := styleProfileDocument{ + Prompt: "base style", + ExecutionPolicy: "compatible-fallback", + Assets: []styleProfileAsset{{ + ID: "template-1", Kind: "template", Title: "专用模板", Provider: "workflow", Status: "validated", + BaseModels: []string{"supported-model"}, PromptFragment: "must not be injected", + }}, + } + prompt, status, warnings := resolveGenerationStyleExecution(profile, "other-model", "openai-image") + if prompt != "base style" || status != "degraded" || len(warnings) != 1 { + t.Fatalf("resolveGenerationStyleExecution() prompt = %q, status = %q, warnings = %#v", prompt, status, warnings) + } +} + +func TestEquivalentStyleProfileJSONIgnoresObjectKeyOrder(t *testing.T) { + equal, err := equivalentStyleProfileJSON(`{"schemaVersion":1,"presetId":"style-1","assets":[]}`, `{"assets":[],"presetId":"style-1","schemaVersion":1}`) + if err != nil || !equal { + t.Fatalf("equivalentStyleProfileJSON() equal = %v, err = %v", equal, err) + } +} diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index 7b5c27588..c4928e3b4 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -38,6 +38,70 @@ func normalizeImageQuality(value string) string { } } +func imageParameterSupported(profile *ImageCapabilityConfig, parameter string) bool { + if profile == nil { + return true + } + if parameter == "response_format" { + return profile.ResponseFormat.Supported + } + return profile.OutputFormat.Supported +} + +func imageQualitySupported(profile *ImageCapabilityConfig) bool { + return profile == nil || profile.Quality.Supported +} + +func imageTransparentBackgroundSupported(profile *ImageCapabilityConfig) bool { + return profile == nil || profile.TransparentBackground.Supported +} + +func imageSizeParameter(profile *ImageCapabilityConfig, value string) (string, string) { + if profile == nil { + return "size", normalizePixelSize(value) + } + value = strings.TrimSpace(value) + if value == "" || value == "auto" { + value = strings.TrimSpace(profile.Size.Default) + } + switch profile.Size.Parameter { + case "size": + return "size", normalizePixelSize(value) + case "aspect_ratio": + return "aspect_ratio", normalizeImageAspectRatio(value) + default: + return "", "" + } +} + +func normalizeImageAspectRatio(value string) string { + value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x"))) + if strings.Contains(value, ":") { + return value + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return "" + } + width, widthErr := strconv.Atoi(parts[0]) + height, heightErr := strconv.Atoi(parts[1]) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "" + } + divisor := imageDimensionGCD(width, height) + return strconv.Itoa(width/divisor) + ":" + strconv.Itoa(height/divisor) +} + +func imageDimensionGCD(left int, right int) int { + for right != 0 { + left, right = right, left%right + } + if left < 1 { + return 1 + } + return left +} + func normalizePixelSize(value string) string { value = strings.TrimSpace(value) if value == "" || value == "auto" { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 0c89b64c6..2d459579d 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -131,9 +131,10 @@ type agentStoryboardInput struct { } type storyboardProjectStyle struct { - PresetID string `json:"presetId"` - Title string `json:"title"` - Prompt string `json:"prompt"` + PresetID string `json:"presetId"` + Title string `json:"title"` + Prompt string `json:"prompt"` + ProfileJSON string `json:"profileJson,omitempty"` } type storyboardCharacterCard struct { @@ -1079,7 +1080,7 @@ func (s *Service) processTask(ctx context.Context, task model.Task) (map[string] return s.processStoryboardRowsTask(ctx, task) } if strings.HasPrefix(task.Type, "canvas_") || canRunProviderTask(task) { - result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.Type, task.Prompt, task.InputJSON) + result, err := s.processCanvasGenerationTask(ctx, task.UserID, task.ProjectID, task.Type, task.Prompt, task.InputJSON) return result, nil, err } if task.Type == "agent_storyboard" { @@ -1341,6 +1342,11 @@ func validateStoryboardContext(projectStyle storyboardProjectStyle, characters [ if strings.TrimSpace(projectStyle.PresetID) == "" || strings.TrimSpace(projectStyle.Title) == "" || strings.TrimSpace(projectStyle.Prompt) == "" { return errors.New("请先设置项目画风,再生成分镜") } + if strings.TrimSpace(projectStyle.ProfileJSON) != "" { + if _, err := validateStyleProfileJSON(projectStyle.ProfileJSON); err != nil { + return err + } + } for _, character := range characters { if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") @@ -1505,7 +1511,7 @@ func (s *Service) buildAgentStoryboardResult(task model.Task, plan agentStoryboa 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", "项目画风 · "+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(styleID, "text", "项目画风 · "+shortTitle(projectStyle.Title, 24), styleX, 0, map[string]any{"workflowKind": "styleboard", "workflowTitle": "项目画风", "workflowDescription": plan.StyleGuide, "stylePresetId": projectStyle.PresetID, "styleProfileJson": projectStyle.ProfileJSON, "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), diff --git a/backend/internal/service/style_profile.go b/backend/internal/service/style_profile.go new file mode 100644 index 000000000..5f6a325cf --- /dev/null +++ b/backend/internal/service/style_profile.go @@ -0,0 +1,304 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +const maxUserStyleProfiles = 200 + +type StyleProfileRequest struct { + ProfileJSON string `json:"profileJson"` +} + +type StyleProfileFavoriteRequest struct { + Favorite bool `json:"favorite"` +} + +type styleProfileDocument struct { + SchemaVersion int `json:"schemaVersion"` + PresetID string `json:"presetId"` + Title string `json:"title"` + Description string `json:"description"` + Tags []string `json:"tags"` + Prompt string `json:"prompt"` + NegativePrompt string `json:"negativePrompt"` + CoverURL string `json:"coverUrl"` + SourceProfileID string `json:"sourceProfileId"` + Selection map[string]string `json:"selection,omitempty"` + Assets []styleProfileAsset `json:"assets"` + ExecutionPolicy string `json:"executionPolicy"` + Source string `json:"source"` + Revision int `json:"revision"` +} + +type styleProfileAsset struct { + ID string `json:"id"` + Kind string `json:"kind"` + Provider string `json:"provider"` + Title string `json:"title"` + Enabled *bool `json:"enabled"` + SourceID string `json:"sourceId"` + SourceURL string `json:"sourceUrl"` + Model string `json:"model"` + Version string `json:"version"` + BaseModels []string `json:"baseModels"` + TriggerWords []string `json:"triggerWords"` + PromptFragment string `json:"promptFragment"` + Weight *float64 `json:"weight"` + Parameters *styleProfileAssetParameters `json:"parameters"` + ReferenceURLs []string `json:"referenceUrls"` + ReferenceResourceIDs []string `json:"referenceResourceIds"` + Status string `json:"status"` + License *styleProfileAssetLicense `json:"license,omitempty"` +} + +type styleProfileAssetParameters struct { + Sampler string `json:"sampler"` + Steps *int `json:"steps"` + CFG *float64 `json:"cfg"` + Size string `json:"size"` +} + +type styleProfileAssetLicense struct { + Commercial *bool `json:"commercial,omitempty"` + Note string `json:"note,omitempty"` + Source string `json:"source,omitempty"` + CapturedAt string `json:"capturedAt,omitempty"` +} + +func (s *Service) ListStyleProfiles(userID string) ([]model.StyleProfile, error) { + return s.repo.StyleProfiles(userID) +} + +func (s *Service) CreateStyleProfile(userID string, req StyleProfileRequest) (model.StyleProfile, error) { + count, err := s.repo.StyleProfileCount(userID) + if err != nil { + return model.StyleProfile{}, err + } + if count >= maxUserStyleProfiles { + return model.StyleProfile{}, BadAuthRequest("我的风格最多保存 200 个") + } + normalized, document, err := normalizeUserStyleProfileJSON(req.ProfileJSON, "", 1) + if err != nil { + return model.StyleProfile{}, BadAuthRequest(err.Error()) + } + now := time.Now() + profile := model.StyleProfile{ + ID: newID(), UserID: userID, Name: strings.TrimSpace(document.Title), Description: strings.TrimSpace(document.Description), + CoverURL: strings.TrimSpace(document.CoverURL), TagsJSON: mustStyleProfileJSON(document.Tags), Favorite: false, + Revision: 1, CreatedAt: now, UpdatedAt: now, + } + normalized, document, err = normalizeUserStyleProfileJSON(normalized, profile.ID, profile.Revision) + if err != nil { + return model.StyleProfile{}, BadAuthRequest(err.Error()) + } + profile.ProfileJSON = normalized + profile.Name = document.Title + if err := s.repo.CreateStyleProfile(&profile); err != nil { + return model.StyleProfile{}, err + } + return profile, nil +} + +func (s *Service) UpdateStyleProfile(userID string, id string, req StyleProfileRequest) (model.StyleProfile, error) { + profile, err := s.repo.StyleProfileForUser(userID, id) + if err != nil { + return model.StyleProfile{}, err + } + normalized, document, err := normalizeUserStyleProfileJSON(req.ProfileJSON, profile.ID, profile.Revision+1) + if err != nil { + return model.StyleProfile{}, BadAuthRequest(err.Error()) + } + profile.Name = document.Title + profile.Description = document.Description + profile.CoverURL = document.CoverURL + profile.TagsJSON = mustStyleProfileJSON(document.Tags) + profile.ProfileJSON = normalized + profile.Revision++ + profile.UpdatedAt = time.Now() + if err := s.repo.UpdateStyleProfile(profile); err != nil { + return model.StyleProfile{}, err + } + return *profile, nil +} + +func (s *Service) SetStyleProfileFavorite(userID string, id string, favorite bool) error { + return s.repo.SetStyleProfileFavorite(userID, id, favorite) +} + +func (s *Service) TouchStyleProfile(userID string, id string) error { + return s.repo.TouchStyleProfile(userID, id, time.Now()) +} + +func (s *Service) DeleteStyleProfile(userID string, id string) error { + return s.repo.DeleteStyleProfile(userID, id) +} + +func validateStyleProfileJSON(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + if len([]byte(trimmed)) > 256*1024 { + return "", errors.New("项目画风资产配置过大") + } + var profile styleProfileDocument + if err := json.Unmarshal([]byte(trimmed), &profile); err != nil { + return "", errors.New("项目画风资产配置不是合法 JSON") + } + if profile.SchemaVersion != 1 || strings.TrimSpace(profile.PresetID) == "" || strings.TrimSpace(profile.Title) == "" || strings.TrimSpace(profile.Prompt) == "" || profile.Revision < 1 { + return "", errors.New("项目画风资产配置缺少必要字段") + } + if profile.Assets == nil { + return "", errors.New("项目画风资产配置缺少 assets 数组") + } + if len(profile.Assets) > 20 { + return "", errors.New("项目画风执行资产最多绑定 20 个") + } + if profile.ExecutionPolicy != "" && profile.ExecutionPolicy != "compatible-fallback" && profile.ExecutionPolicy != "strict-assets" { + return "", errors.New("项目画风执行策略不支持") + } + if profile.Source != "builtin" && profile.Source != "user" && profile.Source != "external" { + return "", errors.New("项目画风来源不支持") + } + if len(profile.Selection) > 20 { + return "", errors.New("项目画风辅助标注最多 20 项") + } + for key, value := range profile.Selection { + if strings.TrimSpace(key) == "" || len([]rune(key)) > 64 || len([]rune(value)) > 200 { + return "", errors.New("项目画风辅助标注格式无效") + } + } + assetIDs := make(map[string]struct{}, len(profile.Assets)) + for _, asset := range profile.Assets { + if strings.TrimSpace(asset.ID) == "" || strings.TrimSpace(asset.Title) == "" || strings.TrimSpace(asset.Provider) == "" || !styleProfileValueAllowed(asset.Kind, "lora", "template", "reference", "prompt") || !styleProfileValueAllowed(asset.Status, "draft", "validated", "unavailable") { + return "", errors.New("项目画风执行资产字段不完整") + } + if _, exists := assetIDs[asset.ID]; exists { + return "", errors.New("项目画风执行资产 ID 不能重复") + } + assetIDs[asset.ID] = struct{}{} + if asset.Weight != nil && (*asset.Weight < 0 || *asset.Weight > 2) { + return "", errors.New("LoRA 权重必须在 0 到 2 之间") + } + if len(asset.BaseModels) > 20 { + return "", errors.New("单个画风资产最多声明 20 个兼容模型") + } + if len(asset.TriggerWords) > 50 || len(asset.ReferenceURLs) > 20 || len(asset.ReferenceResourceIDs) > 20 { + return "", errors.New("项目画风执行资产的触发词或参考图数量过多") + } + if asset.Parameters != nil { + if asset.Parameters.Steps != nil && (*asset.Parameters.Steps < 1 || *asset.Parameters.Steps > 200) { + return "", errors.New("画风资产 Steps 必须在 1 到 200 之间") + } + if asset.Parameters.CFG != nil && (*asset.Parameters.CFG < 0 || *asset.Parameters.CFG > 50) { + return "", errors.New("画风资产 CFG 必须在 0 到 50 之间") + } + } + if asset.Status == "validated" { + switch asset.Kind { + case "lora": + if strings.TrimSpace(asset.SourceID) == "" && strings.TrimSpace(asset.SourceURL) == "" { + return "", errors.New("已验证的 LoRA 必须填写来源资产 ID 或 URL") + } + case "template", "prompt": + if strings.TrimSpace(asset.PromptFragment) == "" && len(nonEmptyStyleProfileStrings(asset.TriggerWords)) == 0 { + return "", errors.New("已验证的模板或提示词模块必须填写提示词内容") + } + case "reference": + if len(nonEmptyStyleProfileStrings(asset.ReferenceURLs)) == 0 && len(nonEmptyStyleProfileStrings(asset.ReferenceResourceIDs)) == 0 { + return "", errors.New("已验证的参考图组必须绑定参考图") + } + } + } + } + return trimmed, nil +} + +func normalizeUserStyleProfileJSON(value string, presetID string, revision int64) (string, styleProfileDocument, error) { + validated, err := validateStyleProfileJSON(value) + if err != nil { + return "", styleProfileDocument{}, err + } + var document styleProfileDocument + if err := json.Unmarshal([]byte(validated), &document); err != nil { + return "", styleProfileDocument{}, errors.New("用户风格配置解析失败") + } + if len([]rune(strings.TrimSpace(document.Title))) > 80 { + return "", styleProfileDocument{}, errors.New("风格名称最多 80 个字") + } + if len([]rune(strings.TrimSpace(document.Description))) > 500 { + return "", styleProfileDocument{}, errors.New("风格简介最多 500 个字") + } + if len(document.Tags) > 20 { + return "", styleProfileDocument{}, errors.New("风格标签最多 20 个") + } + if len([]byte(document.CoverURL)) > 4096 || len([]byte(document.NegativePrompt)) > 64*1024 { + return "", styleProfileDocument{}, errors.New("风格封面或负面 Prompt 过大") + } + if coverURL := strings.ToLower(strings.TrimSpace(document.CoverURL)); coverURL != "" && !strings.HasPrefix(coverURL, "http://") && !strings.HasPrefix(coverURL, "https://") && !strings.HasPrefix(coverURL, "/") && !strings.HasPrefix(coverURL, "data:image/") { + return "", styleProfileDocument{}, errors.New("风格封面只支持 http(s)、站内路径或图片 Data URL") + } + if strings.TrimSpace(presetID) != "" { + document.PresetID = strings.TrimSpace(presetID) + } + document.SourceProfileID = document.PresetID + document.Source = "user" + document.Revision = int(revision) + document.Title = strings.TrimSpace(document.Title) + document.Description = strings.TrimSpace(document.Description) + document.CoverURL = strings.TrimSpace(document.CoverURL) + document.NegativePrompt = strings.TrimSpace(document.NegativePrompt) + document.Tags = nonEmptyStyleProfileStrings(document.Tags) + encoded, err := json.Marshal(document) + if err != nil { + return "", styleProfileDocument{}, errors.New("用户风格配置序列化失败") + } + if len(encoded) > 256*1024 { + return "", styleProfileDocument{}, errors.New("用户风格配置过大") + } + return string(encoded), document, nil +} + +func validateStyleProfilePreset(presetID string, profileJSON string) error { + if strings.TrimSpace(profileJSON) == "" { + return nil + } + var profile styleProfileDocument + if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil { + return errors.New("项目画风资产配置解析失败") + } + if strings.TrimSpace(presetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(presetID) { + return errors.New("项目画风预设与结构化快照不一致") + } + return nil +} + +func mustStyleProfileJSON(value any) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func nonEmptyStyleProfileStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func styleProfileValueAllowed(value string, allowed ...string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} diff --git a/backend/internal/service/style_profile_test.go b/backend/internal/service/style_profile_test.go new file mode 100644 index 000000000..876fd76a9 --- /dev/null +++ b/backend/internal/service/style_profile_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestStyleProfileCreateAndUpdateOwnsIdentityAndRevision(t *testing.T) { + svc := newStyleProfileTestService(t) + created, err := svc.CreateStyleProfile("user-1", styleProfileTestRequest(t, "志怪暗彩", 88)) + if err != nil { + t.Fatal(err) + } + var createdDocument styleProfileDocument + if err := json.Unmarshal([]byte(created.ProfileJSON), &createdDocument); err != nil { + t.Fatal(err) + } + if created.Revision != 1 || createdDocument.PresetID != created.ID || createdDocument.SourceProfileID != created.ID || createdDocument.Source != "user" || createdDocument.Revision != 1 { + t.Fatalf("created profile identity was not normalized: model=%+v document=%+v", created, createdDocument) + } + if createdDocument.Selection["medium"] != "工笔暗彩" || len(createdDocument.Assets) != 1 || createdDocument.Assets[0].License == nil || createdDocument.Assets[0].License.Note != "需保留来源记录" { + t.Fatalf("created profile metadata was not preserved: document=%+v", createdDocument) + } + + updated, err := svc.UpdateStyleProfile("user-1", created.ID, styleProfileTestRequest(t, "志怪暗彩二版", 300)) + if err != nil { + t.Fatal(err) + } + var updatedDocument styleProfileDocument + if err := json.Unmarshal([]byte(updated.ProfileJSON), &updatedDocument); err != nil { + t.Fatal(err) + } + if updated.Revision != 2 || updatedDocument.Revision != 2 || updatedDocument.PresetID != created.ID || updatedDocument.Title != "志怪暗彩二版" { + t.Fatalf("updated profile revision was not server controlled: model=%+v document=%+v", updated, updatedDocument) + } +} + +func TestStyleProfileMutationsRejectOtherUsers(t *testing.T) { + svc := newStyleProfileTestService(t) + created, err := svc.CreateStyleProfile("user-1", styleProfileTestRequest(t, "用户一风格", 1)) + if err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateStyleProfile("user-2", created.ID, styleProfileTestRequest(t, "越权修改", 1)); err == nil { + t.Fatal("expected cross-user update to fail") + } + if err := svc.SetStyleProfileFavorite("user-2", created.ID, true); err == nil { + t.Fatal("expected cross-user favorite to fail") + } + if err := svc.TouchStyleProfile("user-2", created.ID); err == nil { + t.Fatal("expected cross-user use tracking to fail") + } + if err := svc.DeleteStyleProfile("user-2", created.ID); err == nil { + t.Fatal("expected cross-user delete to fail") + } + profiles, err := svc.ListStyleProfiles("user-1") + if err != nil { + t.Fatal(err) + } + if len(profiles) != 1 || profiles[0].ID != created.ID { + t.Fatalf("owner profile was changed by rejected mutations: %+v", profiles) + } +} + +func newStyleProfileTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.StyleProfile{}); err != nil { + t.Fatal(err) + } + return New(repository.New(db), t.TempDir()) +} + +func styleProfileTestRequest(t *testing.T, title string, revision int) StyleProfileRequest { + t.Helper() + document := styleProfileDocument{ + SchemaVersion: 1, PresetID: "client-controlled-id", Title: title, Description: "可复用风格", + Tags: []string{"志怪", "暗彩"}, Prompt: "统一使用工笔线条、暗彩矿物色和克制光影。", NegativePrompt: "禁止媒介漂移和随机文字。", + Selection: map[string]string{"world": "东方志怪", "medium": "工笔暗彩"}, + Assets: []styleProfileAsset{{ + ID: "license-snapshot", Kind: "prompt", Provider: "manual", Title: "材质约束", PromptFragment: "矿物色与宣纸肌理", Status: "validated", + License: &styleProfileAssetLicense{Note: "需保留来源记录", Source: "内部规范"}, + }}, + ExecutionPolicy: "compatible-fallback", Source: "external", Revision: revision, + } + encoded, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + return StyleProfileRequest{ProfileJSON: string(encoded)} +} diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 6f33d1d89..8fe9cb25e 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -7,7 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities"; +import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -46,6 +46,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const mode = node.metadata?.generationMode || "image"; const simpleMode = workspaceMode === "simple"; const config = buildNodeConfig(globalConfig, node, mode); + const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, config.model).image! : undefined; const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions; const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); @@ -55,7 +56,11 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount); const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); - const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : ""; + const capabilityError = imageProfile + ? imageCapabilityError(imageProfile, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary) + : videoProfile + ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) + : ""; const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return ( @@ -218,14 +223,16 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = node.metadata?.model; const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel; + const imageProfile = mode === "image" ? modelCapabilityConfigFor(globalConfig, model).image! : undefined; + const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined; const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined; const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined; return { ...globalConfig, model, - quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, - size: normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size, - transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false", + quality: normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality, + size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size, + transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"), videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds), vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality), videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false", @@ -234,7 +241,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions, - count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), + count: normalizedImage?.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), }; } @@ -254,6 +261,12 @@ function videoCapabilityError(profile: NonNullable profile.references.promptMaxChars) return `提示词超过模型限制(最多 ${profile.references.promptMaxChars} 字)`; + if (input.imageCount > profile.references.maxImages) return `当前图片模型最多支持 ${profile.references.maxImages} 张参考图`; + return ""; +} + function audioConfigPatch(key: CanvasAudioSettingKey, value: string) { if (key === "audioVoice") return { audioVoice: value }; if (key === "audioFormat") return { audioFormat: value }; diff --git a/web/src/components/canvas/canvas-image-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index 31e04b551..5ab180efb 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -5,6 +5,7 @@ import { Button } from "antd"; import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel"; import { canvasThemes } from "@/lib/canvas-theme"; +import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; import { useThemeStore } from "@/stores/use-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; @@ -26,11 +27,16 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(null); - const quality = config.quality || "auto"; - const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); - const activeSize = config.size || "auto"; - const transparentLabel = config.transparentBackground === "true" ? " · 透明" : ""; - const summary = showCount ? `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)} · ${count} 张${transparentLabel}` : `${imageQualityLabel(quality)} · ${imageSizeLabel(activeSize)}${transparentLabel}`; + const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!; + const normalized = normalizeImageValue(profile, config); + const summaryParts = [ + ...(profile.size.parameter !== "none" ? [imageSizeLabel(normalized.size)] : []), + ...(profile.quality.supported ? [imageQualityLabel(normalized.quality)] : []), + ...(showCount && profile.maxOutputs > 1 ? [`${normalized.count} 张`] : []), + ...(profile.transparentBackground.supported && normalized.transparentBackground === "true" ? ["透明"] : []), + ]; + const summary = summaryParts.join(" · "); + const hasSettings = profile.size.parameter !== "none" || profile.quality.supported || profile.transparentBackground.supported || (showCount && profile.maxOutputs > 1); const updateOpen = (nextOpen: boolean) => { setOpen(nextOpen); onOpenChange?.(nextOpen); @@ -61,6 +67,8 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang const panel = open && buttonRect ? : null; + if (!hasSettings) return null; + return ( <> diff --git a/web/src/components/canvas/canvas-project-sidebar.tsx b/web/src/components/canvas/canvas-project-sidebar.tsx index 0d17a1ed7..b7d73f590 100644 --- a/web/src/components/canvas/canvas-project-sidebar.tsx +++ b/web/src/components/canvas/canvas-project-sidebar.tsx @@ -4,7 +4,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowUpRight, BookOpenText, ChevronLeft, ChevronRight, Crosshair, FolderKanban, GripVertical, Images, LocateFixed, LoaderCircle, Palette, Plus, Search, Settings2, X } from "lucide-react"; import { Link } from "react-router"; -import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; +import { resolveProjectCanvasStyle } 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"; @@ -30,7 +30,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 = resolveCanvasStylePreset(projectDetail?.project.stylePresetId); + const style = resolveProjectCanvasStyle(projectDetail?.project.stylePresetId, projectDetail?.project.styleProfileJson); 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-style-picker-modal.tsx b/web/src/components/canvas/canvas-style-picker-modal.tsx index 2c446cd44..379117dbc 100644 --- a/web/src/components/canvas/canvas-style-picker-modal.tsx +++ b/web/src/components/canvas/canvas-style-picker-modal.tsx @@ -1,22 +1,20 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; -import { Check, Eye, Palette } from "lucide-react"; -import { Button, Modal, Segmented, Select } from "antd"; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Clock3, Copy, Eye, Palette, Pencil, Plus, Search, SlidersHorizontal, Star, Trash2, UserRound } from "lucide-react"; +import { App, Button, Input, Modal } from "antd"; +import { nanoid } from "nanoid"; +import { StyleProfileEditorModal } from "@/components/canvas/style-profile-editor-modal"; import { canvasThemes } from "@/lib/canvas-theme"; +import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile"; import { - compatibleProjectStyleCharacters, compileCanvasStylePreset, customCanvasStylePreset, - defaultProjectStyleSelection, - parseCanvasStyleSelection, - projectStyleMedia, - projectStyleTones, - projectStyleWorlds, recommendedCanvasStylePresets, type CanvasStylePreset, - type ProjectStyleMediumId, type ProjectStyleSelection, } from "@/lib/canvas/canvas-style-system"; +import { createStyleProfile, deleteStyleProfile, listStyleProfiles, setStyleProfileFavorite, touchStyleProfile, updateStyleProfile, type UserStyleProfile } from "@/services/api/style-profiles"; import { useThemeStore } from "@/stores/use-theme-store"; export type { CanvasStylePreset } from "@/lib/canvas/canvas-style-system"; @@ -386,127 +384,288 @@ const legacyStyleUpgrades: Partial> = { const resolvedLegacyCanvasStylePresets = legacyCanvasStylePresets.map((preset) => { const upgrade = legacyStyleUpgrades[preset.id]; - return upgrade ? { ...compileCanvasStylePreset(upgrade), id: preset.id, imageUrl: preset.imageUrl } : preset; + if (!upgrade) return preset; + const compiled = compileCanvasStylePreset(upgrade); + const upgraded = { ...compiled, id: preset.id, imageUrl: preset.imageUrl }; + return { ...upgraded, profile: createStyleProfileSnapshot({ ...compiled.profile!, presetId: preset.id }) }; }); -export const canvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets]; +const resolvableCanvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets]; + +// 风格中心不靠重复卡片虚增数量;旧 ID 仍留在解析集合中,保证历史项目可以恢复。 +export const canvasStylePresets: CanvasStylePreset[] = resolvableCanvasStylePresets.filter((preset, index, presets) => presets.findIndex((candidate) => candidate.prompt === preset.prompt) === index); export function resolveCanvasStylePreset(id?: string) { - return canvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id); + return resolvableCanvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id); } -export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { open: boolean; value?: string; onClose: () => void; onSelect: (preset: CanvasStylePreset) => void }) { +export function resolveProjectCanvasStyle(presetId?: string, profileJson?: string | null): CanvasStylePreset | undefined { + const preset = resolveCanvasStylePreset(presetId); + const profile = parseStyleProfile(profileJson); + if (!profile) return preset; + return { + id: profile.presetId, + title: profile.title, + category: preset?.category || "项目风格包", + description: profile.description, + tags: [...profile.tags], + prompt: profile.prompt, + imageUrl: profile.coverUrl || preset?.imageUrl || "/short-drama-styles/real-life.jpg", + selection: preset?.selection, + profile, + }; +} + +const STYLE_FAVORITES_KEY = "canvas:style-library:favorites"; +const STYLE_RECENT_KEY = "canvas:style-library:recent"; + +type StyleCenterTab = "system" | "mine" | "favorites" | "recent"; +type SystemStyleLibraryItem = { kind: "system"; preset: CanvasStylePreset }; +type UserStyleLibraryItem = { kind: "user"; preset: CanvasStylePreset; entity: UserStyleProfile }; +type StyleLibraryItem = SystemStyleLibraryItem | UserStyleLibraryItem; +type StyleEditorState = { profile: StyleProfileSnapshot; entityId?: string }; + +export function CanvasStylePickerModal({ open, value, currentProfile, startInEditor = false, applying = false, onClose, onSelect }: { + open: boolean; + value?: string; + currentProfile?: StyleProfileSnapshot | null; + startInEditor?: boolean; + applying?: boolean; + onClose: () => void; + onSelect: (preset: CanvasStylePreset) => void; +}) { + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [detailPreset, setDetailPreset] = useState(null); - const [mode, setMode] = useState<"recommended" | "custom">("recommended"); - const [selection, setSelection] = useState(defaultProjectStyleSelection); + const [tab, setTab] = useState("system"); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("全部"); + const [systemFavoriteIds, setSystemFavoriteIds] = useState([]); + const [recentIds, setRecentIds] = useState([]); + const [editor, setEditor] = useState(null); + const profilesQuery = useQuery({ queryKey: ["style-profiles"], queryFn: listStyleProfiles, enabled: open }); + 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"); + setTab("system"); + setQuery(""); + setCategory("全部"); + setSystemFavoriteIds(readStyleIds(STYLE_FAVORITES_KEY)); + setRecentIds(readStyleIds(STYLE_RECENT_KEY)); + setEditor(startInEditor && currentProfile?.source !== "user" && currentProfile ? { profile: editableCopy(currentProfile) } : null); + }, [currentProfile, open, startInEditor]); + + const userItems = useMemo(() => (profilesQuery.data?.profiles || []).flatMap((entity) => { + const preset = userStylePreset(entity); + return preset ? [{ kind: "user" as const, preset, entity }] : []; + }), [profilesQuery.data?.profiles]); + const systemItems = useMemo(() => canvasStylePresets.map((preset) => ({ kind: "system", preset })), []); + const categories = useMemo(() => ["全部", ...Array.from(new Set(canvasStylePresets.map((preset) => preset.category)))], []); + + useEffect(() => { + if (!open || !startInEditor || !currentProfile || currentProfile.source !== "user" || editor || profilesQuery.isFetching || profilesQuery.isError) return; + const sourceId = currentProfile.sourceProfileId || currentProfile.presetId; + const sourceEntity = (profilesQuery.data?.profiles || []).find((profile) => profile.id === sourceId); + const sourceProfile = sourceEntity ? parseStyleProfile(sourceEntity.profileJson) : null; + if (sourceEntity && sourceProfile) { + setEditor({ profile: editableCopy(sourceProfile, sourceEntity.id), entityId: sourceEntity.id }); + return; } - }, [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; + if (profilesQuery.isFetched) setEditor({ profile: editableCopy(currentProfile) }); + }, [currentProfile, editor, open, profilesQuery.data?.profiles, profilesQuery.isError, profilesQuery.isFetched, profilesQuery.isFetching, startInEditor]); + + const visibleItems = useMemo(() => { + const keyword = query.trim().toLocaleLowerCase("zh-CN"); + let source: StyleLibraryItem[] = tab === "system" ? systemItems : tab === "mine" ? userItems : tab === "favorites" + ? [...systemItems.filter((item) => systemFavoriteIds.includes(item.preset.id)), ...userItems.filter((item) => item.kind === "user" && item.entity.favorite)] + : [...userItems.filter((item) => item.kind === "user" && item.entity.lastUsedAt).sort((a, b) => String(b.entity.lastUsedAt).localeCompare(String(a.entity.lastUsedAt))), ...recentIds.flatMap((id) => systemItems.filter((item) => item.preset.id === id))]; + if (tab === "system" && category !== "全部") source = source.filter((item) => item.preset.category === category); + if (keyword) source = source.filter((item) => `${item.preset.title} ${item.preset.category} ${item.preset.description} ${item.preset.tags.join(" ")}`.toLocaleLowerCase("zh-CN").includes(keyword)); + return source; + }, [category, query, recentIds, systemFavoriteIds, systemItems, tab, userItems]); + + const saveMutation = useMutation({ + mutationFn: ({ profile, apply }: { profile: StyleProfileSnapshot; apply: boolean }) => editor?.entityId + ? updateStyleProfile(editor.entityId, serializeStyleProfile(profile)).then((result) => ({ ...result, apply })) + : createStyleProfile(serializeStyleProfile(profile)).then((result) => ({ ...result, apply })), + onSuccess: async ({ profile, apply }) => { + await queryClient.invalidateQueries({ queryKey: ["style-profiles"] }); + const preset = userStylePreset(profile); + setEditor(null); + setTab("mine"); + message.success("风格已保存到“我的风格”"); + if (apply && preset) selectItem({ kind: "user", preset, entity: profile }); + }, + onError: (error) => message.error(error instanceof Error ? error.message : "风格保存失败"), + }); + const favoriteMutation = useMutation({ + mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => setStyleProfileFavorite(id, favorite), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["style-profiles"] }), + onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"), + }); + const deleteMutation = useMutation({ + mutationFn: deleteStyleProfile, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["style-profiles"] }); message.success("风格已删除"); }, + onError: (error) => message.error(error instanceof Error ? error.message : "风格删除失败"), + }); + + function selectItem(item: StyleLibraryItem) { + if (applying) return; + const { preset } = item; + const next = [preset.id, ...recentIds.filter((id) => id !== preset.id)].slice(0, 12); + setRecentIds(next); + localStorage.setItem(STYLE_RECENT_KEY, JSON.stringify(next)); + if (item.kind === "user") void touchStyleProfile(item.entity.id).then(() => queryClient.invalidateQueries({ queryKey: ["style-profiles"] })).catch((error) => message.warning(error instanceof Error ? error.message : "最近使用记录更新失败")); + onSelect(preset); + } + function toggleSystemFavorite(presetId: string) { + const next = systemFavoriteIds.includes(presetId) ? systemFavoriteIds.filter((id) => id !== presetId) : [presetId, ...systemFavoriteIds]; + setSystemFavoriteIds(next); + localStorage.setItem(STYLE_FAVORITES_KEY, JSON.stringify(next)); + } + function createNewStyle() { + setEditor({ profile: blankUserStyle() }); + } + function copyPreset(preset: CanvasStylePreset) { + setEditor({ profile: editableCopy(preset.profile || createStyleProfileSnapshot(preset), undefined, `${preset.title} 副本`, preset.imageUrl) }); + } + function confirmDelete(entity: UserStyleProfile) { + modal.confirm({ title: `删除“${entity.name}”?`, content: "项目中已经保存的快照不会被删除,但该风格将从“我的风格”中移除。", okText: "删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: () => deleteMutation.mutateAsync(entity.id) }); + } + return ( <> - -
-
-

选择项目画风

- setMode(next as typeof mode)} /> + +
+
+
+

风格中心

+

系统规范 · 个人风格 · 项目快照

+
+
+ } value={query} placeholder="搜索风格、题材或媒介" onChange={(event) => setQuery(event.target.value)} /> + +
- {mode === "recommended" ?
- {visiblePresets.map((preset) => { - const active = preset.id === value; - return ( -
- - -
- ); - })} -
: } +
+ +
+
+
{styleCenterViewTitle(tab, category)}
+
{visibleItems.length} 个风格
+
+
+ {visibleItems.map((item) => ( + selectItem(item)} + onDetail={() => setDetailPreset(item.preset)} + onFavorite={() => item.kind === "user" ? favoriteMutation.mutate({ id: item.entity.id, favorite: !item.entity.favorite }) : toggleSystemFavorite(item.preset.id)} + onCopy={() => copyPreset(item.preset)} + onEdit={item.kind === "user" ? () => setEditor({ profile: editableCopy(item.preset.profile!, item.entity.id), entityId: item.entity.id }) : undefined} + onDelete={item.kind === "user" ? () => confirmDelete(item.entity) : undefined} + /> + ))} + {!visibleItems.length ? { setTab("system"); setQuery(""); }} /> : null} +
+
+
- setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); onSelect(preset); }} /> + setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); const item = [...systemItems, ...userItems].find((candidate) => candidate.preset.id === preset.id); if (item) selectItem(item); }} /> + setEditor(null)} onSave={(profile, apply) => saveMutation.mutate({ profile, apply })} /> ); } -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 }); - }; +function readStyleIds(key: string) { + try { + const parsed = JSON.parse(localStorage.getItem(key) || "[]"); + return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string").slice(0, 100) : []; + } catch { + return []; + } +} + +function StyleCenterCard({ item, active, applying, favorite, theme, onApply, onDetail, onFavorite, onCopy, onEdit, onDelete }: { item: StyleLibraryItem; active: boolean; applying: boolean; favorite: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onApply: () => void; onDetail: () => void; onFavorite: () => void; onCopy: () => void; onEdit?: () => void; onDelete?: () => void }) { + const { preset } = item; 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} /> -
-
- - -
-
-
-
+
+
+ + +
+
+
{item.kind === "user" ? "我的风格" : preset.category}{active ? 当前 : null}
+

{preset.title}

+

{preset.description}

+
{preset.tags.slice(0, 3).map((tag) => {tag})}
+
+
+ + + {onEdit ? : null} + {onDelete ? : null} + +
+
); } -function StyleSummary({ label, value, muted }: { label: string; value?: string; muted: string }) { - return
{label}{value}
; +function IconAction({ label, danger = false, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) { + return ; } -function StyleSelect({ label, value, options, onChange }: { label: string; value: T; options: Array<{ id: T; label: string; description: string }>; onChange: (value: T) => void }) { - return ( - - ); +function StyleCenterNavItem({ active, icon, label, count, onClick }: { active: boolean; icon: ReactNode; label: string; count?: number; onClick: () => void }) { + return ; +} + +function styleCenterViewTitle(tab: StyleCenterTab, category: string) { + if (tab === "mine") return "我的风格"; + if (tab === "favorites") return "收藏风格"; + if (tab === "recent") return "最近使用"; + return category === "全部" ? "全部系统风格" : category; +} + +function EmptyStyleCenter({ tab, loading, failed, color, onCreate, onBrowse }: { tab: StyleCenterTab; loading: boolean; failed: boolean; color: string; onCreate: () => void; onBrowse: () => void }) { + return

{loading ? "正在加载风格库" : failed && tab !== "system" ? "我的风格加载失败,请检查登录状态或后端服务" : tab === "mine" ? "还没有自己的风格" : tab === "favorites" ? "还没有收藏风格" : tab === "recent" ? "还没有使用记录" : "没有匹配的系统风格"}

{tab === "mine" && !failed ? : null}
; +} + +function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null { + const profile = parseStyleProfile(entity.profileJson); + if (!profile) return null; + return { id: profile.presetId, title: profile.title, category: "我的风格", description: profile.description, tags: [...profile.tags], prompt: profile.prompt, imageUrl: profile.coverUrl || entity.coverUrl || "/short-drama-styles/real-life.jpg", profile }; +} + +function blankUserStyle(): StyleProfileSnapshot { + return createStyleProfileSnapshot({ presetId: `draft-${nanoid()}`, title: "未命名风格", description: "", tags: [], prompt: "", assets: [], source: "user", revision: 1 }); +} + +function editableCopy(profile: StyleProfileSnapshot, entityId?: string, title?: string, coverUrl?: string): StyleProfileSnapshot { + return createStyleProfileSnapshot({ ...profile, presetId: entityId || `draft-${nanoid()}`, sourceProfileId: entityId, title: title || profile.title, coverUrl: profile.coverUrl || coverUrl, source: "user", revision: entityId ? profile.revision : 1 }); } export function CanvasStyleDetailModal({ open, preset, selected = false, onClose, onSelect }: { open: boolean; preset: CanvasStylePreset | null; selected?: boolean; onClose: () => void; onSelect?: (preset: CanvasStylePreset) => void }) { @@ -514,24 +673,39 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose const sections = preset ? parseStyleSections(preset.prompt) : []; return ( - {preset ?
+ {preset ?
{`${preset.title}画风示意`}
-
{preset.category}
+
{preset.category}{preset.profile?.assets.length ? `${preset.profile.assets.length} 个执行资产` : "Prompt 通用"}

{preset.title}

{preset.description}

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

{section.title}

{section.content}

)} +
+ + +
-
{onSelect ? : null}
+
+ {preset.profile?.assets.length ?

执行资产

{preset.profile.assets.map((asset) =>
{asset.title}{kindOptionsLabel(asset.kind)} · {asset.provider}{asset.status === "validated" ? "已验证" : asset.status === "unavailable" ? "不可用" : "待验证"}
)}
: null} + {sections.map((section) =>

{section.title}

{section.content}

)} + {preset.profile?.negativePrompt ?

全局负面 Prompt

{preset.profile.negativePrompt}

: null} +
+
{onSelect ? : null}
: null} ); } +function StyleDetailMetric({ label, value, muted }: { label: string; value: string; muted: string }) { + return
{label}{value}
; +} + +function kindOptionsLabel(kind: string) { + return kind === "lora" ? "LoRA" : kind === "template" ? "图片模板" : kind === "reference" ? "参考图组" : "提示词模块"; +} + function parseStyleSections(prompt: string) { return prompt.split("\n").map((line) => { const match = line.match(/^【([^】]+)】(.*)$/); diff --git a/web/src/components/canvas/style-asset-binding-modal.tsx b/web/src/components/canvas/style-asset-binding-modal.tsx new file mode 100644 index 000000000..c0df441fe --- /dev/null +++ b/web/src/components/canvas/style-asset-binding-modal.tsx @@ -0,0 +1,349 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Button, Input, InputNumber, Modal, Select, Switch } from "antd"; +import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react"; +import { nanoid } from "nanoid"; + +import { + createStyleProfileSnapshot, + MAX_STYLE_ASSETS, + styleAssetValidationMessage, + type StyleAssetBinding, + type StyleAssetKind, + type StyleAssetStatus, + type StyleProfileSnapshot, +} from "@/lib/canvas/style-profile"; + +const kindOptions: Array<{ value: StyleAssetKind; label: string }> = [ + { value: "lora", label: "LoRA" }, + { value: "template", label: "图片模板" }, + { value: "reference", label: "参考图组" }, + { value: "prompt", label: "提示词模块" }, +]; + +const statusOptions: Array<{ value: StyleAssetStatus; label: string }> = [ + { value: "draft", label: "待验证" }, + { value: "validated", label: "已验证" }, + { value: "unavailable", label: "不可用" }, +]; + +const policyOptions: Array<{ value: NonNullable; label: string }> = [ + { value: "compatible-fallback", label: "兼容降级" }, + { value: "strict-assets", label: "严格阻止" }, +]; + +const emptyAsset = (): StyleAssetBinding => ({ + id: nanoid(), + kind: "lora", + provider: "liblib", + title: "", + enabled: false, + weight: 0.8, + status: "draft", +}); + +type StyleAssetBindingModalProps = { + open: boolean; + profile: StyleProfileSnapshot | null; + onClose: () => void; + onApply: (profile: StyleProfileSnapshot) => void; +}; + +export function StyleAssetBindingModal({ open, profile, onClose, onApply }: StyleAssetBindingModalProps) { + const [draft, setDraft] = useState(profile); + const [editingId, setEditingId] = useState(""); + + useEffect(() => { + if (!open) return; + setDraft(profile ? createStyleProfileSnapshot(profile) : null); + setEditingId(profile?.assets[0]?.id || ""); + }, [open, profile]); + + const editing = useMemo(() => draft?.assets.find((asset) => asset.id === editingId), [draft, editingId]); + const issues = useMemo(() => draft?.assets.map((asset) => ({ id: asset.id, message: styleAssetValidationMessage(asset) })).filter((issue) => issue.message) || [], [draft]); + if (!draft) return null; + + const updateAsset = (patch: Partial) => { + setDraft((current) => current ? { ...current, assets: current.assets.map((asset) => asset.id === editingId ? { ...asset, ...patch } : asset) } : current); + }; + const addAsset = () => { + const asset = emptyAsset(); + setDraft((current) => current ? { ...current, assets: [...current.assets, asset] } : current); + setEditingId(asset.id); + }; + const deleteAsset = (id: string) => { + setDraft((current) => current ? { ...current, assets: current.assets.filter((asset) => asset.id !== id) } : current); + setEditingId((current) => current === id ? draft.assets.find((asset) => asset.id !== id)?.id || "" : current); + }; + + return ( + +
+
+
+

风格执行资产

+

绑定模型资产、兼容范围、执行参数和许可快照

+
+ +
+ +
+ + +
+ {editing ? ( + deleteAsset(editing.id)} /> + ) : ( +
+

添加或选择一个执行资产

+
+ )} +
+
+ +
+
+ {issues.length ? : } + {issues.length ? `${issues.length} 个资产需要处理:${issues[0].message}` : `${draft.assets.length} 个资产,${draft.assets.filter((asset) => asset.enabled).length} 个已启用`} +
+
+ + +
+
+
+
+ ); +} + +function AssetEditor({ asset, onChange, onDelete }: { asset: StyleAssetBinding; onChange: (patch: Partial) => void; onDelete: () => void }) { + const validationMessage = styleAssetValidationMessage(asset); + return ( +
+
+
+

资产配置

+

已验证且启用的资产才会进入执行计划

+
+ +
+ + {validationMessage ? ( +
+ + {validationMessage} +
+ ) : null} + +
+ + value={asset.kind} options={kindOptions} onChange={(kind) => onChange({ kind })} /> + + +
+ 参与生成执行计划 + onChange({ enabled })} /> +
+
+ + onChange({ title: event.target.value })} /> + + + value={asset.status} options={statusOptions} onChange={(status) => onChange({ status })} /> + + + onChange({ provider: event.target.value })} /> + + + onChange({ sourceId: event.target.value })} /> + + + onChange({ sourceUrl: event.target.value })} /> + + + onChange({ model: event.target.value })} /> + + + onChange({ version: event.target.value })} /> + + + mode="tags" value={asset.baseModels || []} tokenSeparators={[","]} placeholder="填写实际生成模型名,输入后回车" onChange={(baseModels) => onChange({ baseModels })} /> + +
+ + {asset.kind === "lora" ? : null} + {asset.kind === "reference" ? : null} + {asset.kind === "prompt" || asset.kind === "template" ? : null} + + + + + {asset.enabled !== false && (asset.kind === "lora" || asset.kind === "reference") ? ( +
+ + 当前通用生成协议尚未启用{asset.kind === "lora" ? " LoRA" : "参考图组自动注入"}适配器。兼容降级会继续执行项目 Prompt;严格策略会在生成前阻止任务。 +
+ ) : null} +
+ ); +} + +function LoraFields({ asset, onChange }: AssetFieldProps) { + return ( +
+ + onChange({ weight: weight ?? undefined })} /> + + + mode="tags" value={asset.triggerWords || []} tokenSeparators={[","]} placeholder="输入后回车" onChange={(triggerWords) => onChange({ triggerWords })} /> + +
+ ); +} + +function ReferenceFields({ asset, onChange }: AssetFieldProps) { + return ( +
+ + mode="tags" value={asset.referenceUrls || []} tokenSeparators={[","]} placeholder="输入可访问的参考图地址后回车" onChange={(referenceUrls) => onChange({ referenceUrls })} /> + + + mode="tags" value={asset.referenceResourceIds || []} tokenSeparators={[","]} placeholder="输入已上传的 resource ID 后回车" onChange={(referenceResourceIds) => onChange({ referenceResourceIds })} /> + +
+ ); +} + +function PromptFields({ asset, onChange }: AssetFieldProps) { + return ( +
+ + onChange({ promptFragment: event.target.value })} /> + +
+ ); +} + +function GenerationParameterFields({ asset, onChange }: AssetFieldProps) { + const parameters = asset.parameters || {}; + const updateParameters = (patch: NonNullable) => onChange({ parameters: { ...parameters, ...patch } }); + return ( +
+

建议生成参数

+
+ updateParameters({ sampler: event.target.value })} /> + updateParameters({ steps: steps ?? undefined })} /> + updateParameters({ cfg: cfg ?? undefined })} /> + updateParameters({ size: event.target.value })} /> +
+
+ ); +} + +function LicenseFields({ asset, onChange }: AssetFieldProps) { + return ( +
+

许可快照

+
+ + onChange({ license: { ...asset.license, source: event.target.value } })} /> + onChange({ license: { ...asset.license, note: event.target.value } })} /> +
+
+ ); +} + +type AssetFieldProps = { + asset: StyleAssetBinding; + onChange: (patch: Partial) => void; +}; + +function AssetKindIcon({ kind }: { kind: StyleAssetKind }) { + return ( + + {kind === "lora" ? : kind === "reference" ? : kind === "template" ? : } + + ); +} + +function EmptyAssetList() { + return ( +
+

尚未绑定执行资产

当前画风只通过项目 Prompt 执行

+
+ ); +} + +function assetKindLabel(kind: StyleAssetKind) { + return kindOptions.find((item) => item.value === kind)?.label || kind; +} + +function assetStatusLabel(status: StyleAssetStatus) { + return statusOptions.find((item) => item.value === status)?.label || status; +} + +function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) { + return ; +} diff --git a/web/src/components/canvas/style-profile-editor-modal.tsx b/web/src/components/canvas/style-profile-editor-modal.tsx new file mode 100644 index 000000000..290090415 --- /dev/null +++ b/web/src/components/canvas/style-profile-editor-modal.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { App, Button, Input, Modal, Segmented, Select } from "antd"; +import { Braces, Image, Layers3, Save, Sparkles } from "lucide-react"; + +import { StyleAssetBindingModal } from "@/components/canvas/style-asset-binding-modal"; +import { createStyleProfileSnapshot, styleProfileValidationMessage, type StyleProfileSnapshot } from "@/lib/canvas/style-profile"; + +type EditorSection = "identity" | "prompt" | "execution"; + +type StyleProfileEditorModalProps = { + open: boolean; + initialProfile: StyleProfileSnapshot | null; + saving?: boolean; + onClose: () => void; + onSave: (profile: StyleProfileSnapshot, applyToProject: boolean) => void; +}; + +export function StyleProfileEditorModal({ open, initialProfile, saving = false, onClose, onSave }: StyleProfileEditorModalProps) { + const { message, modal } = App.useApp(); + const [section, setSection] = useState("identity"); + const [draft, setDraft] = useState(initialProfile); + const [baseline, setBaseline] = useState(""); + const [assetsOpen, setAssetsOpen] = useState(false); + + useEffect(() => { + if (!open) return; + const next = initialProfile ? createStyleProfileSnapshot(initialProfile) : null; + setDraft(next); + setBaseline(next ? JSON.stringify(next) : ""); + setSection("identity"); + setAssetsOpen(false); + }, [initialProfile, open]); + + const validationMessage = useMemo(() => draft ? styleProfileValidationMessage(draft) : "风格草稿不存在", [draft]); + if (!draft) return null; + + const update = (patch: Partial) => setDraft((current) => current ? { ...current, ...patch } : current); + const requestClose = () => { + if (JSON.stringify(draft) === baseline) { + onClose(); + return; + } + modal.confirm({ title: "放弃未保存的风格修改?", content: "名称、Prompt、封面和执行配置中的改动都会丢失。", okText: "放弃修改", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: onClose }); + }; + const submit = (applyToProject: boolean) => { + if (validationMessage) { + message.error(validationMessage); + return; + } + onSave(createStyleProfileSnapshot({ ...draft, source: "user" }), applyToProject); + }; + + return ( + <> + +
+
+
+
+ +
+

风格编辑器

+

{draft.title || "未命名风格"} · 我的风格

+
+
+
+
+ + + +
+ +
+ {section === "identity" ? : null} + {section === "prompt" ? : null} + {section === "execution" ? setAssetsOpen(true)} /> : null} +
+
+ +
+

{validationMessage || "保存后进入“我的风格”;应用时会把当前版本复制为项目快照"}

+
+ + + +
+
+
+
+ setAssetsOpen(false)} + onApply={(profile) => { + setDraft(profile); + setAssetsOpen(false); + }} + /> + + ); +} + +function StylePreview({ profile }: { profile: StyleProfileSnapshot }) { + return ( + + ); +} + +function IdentityFields({ profile, onChange }: EditorFieldsProps) { + const selection = profile.selection || {}; + const setSelection = (key: string, value: string) => onChange({ selection: { ...selection, [key]: value } }); + return ( +
+ +
+ onChange({ title: event.target.value })} /> + onChange({ description: event.target.value })} /> + onChange({ coverUrl: event.target.value })} /> + setSelection("world", event.target.value)} /> + setSelection("medium", event.target.value)} /> + setSelection("tone", event.target.value)} /> + setSelection("character", event.target.value)} /> +
+
+ ); +} + +function PromptFields({ profile, onChange }: EditorFieldsProps) { + return ( +
+ + + onChange({ prompt: event.target.value })} /> + + + onChange({ negativePrompt: event.target.value })} /> + +
+ ); +} + +function ExecutionFields({ profile, onChange, onEditAssets }: EditorFieldsProps & { onEditAssets: () => void }) { + const enabled = profile.assets.filter((asset) => asset.enabled !== false); + return ( +
+ + + onChange({ executionPolicy: executionPolicy as StyleProfileSnapshot["executionPolicy"] })} + /> + +
+
+

执行资产

+

{profile.assets.length} 个已绑定,{enabled.length} 个已启用

+
+ +
+ {profile.assets.length ?
{profile.assets.map((asset) =>
{asset.title}{asset.kind.toUpperCase()} · {asset.status === "validated" ? "已验证" : asset.status === "unavailable" ? "不可用" : "待验证"}
)}
:
尚未绑定执行资产,当前风格将只通过 Prompt 执行
} +

LoRA 与参考图已支持绑定、验证和兼容判断;具体生成渠道没有原生适配器时,兼容策略会降级到 Prompt,严格策略会阻止生成。

+
+ ); +} + +type EditorFieldsProps = { profile: StyleProfileSnapshot; onChange: (patch: Partial) => void }; + +function SectionHeading({ title, description }: { title: string; description: string }) { + return

{title}

{description}

; +} + +function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) { + return ; +} + +function Metric({ label, value }: { label: string; value: string }) { + return
{label}
{value}
; +} + +function EditorTab({ id, panelId, active, icon, label, onClick }: { id: string; panelId: string; active: boolean; icon: ReactNode; label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 40ebac8b9..032cdb697 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useState } from "react"; import { ConfigProvider, Switch } from "antd"; import { type CanvasTheme } from "@/lib/canvas-theme"; +import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { type AiConfig } from "@/stores/use-config-store"; const qualityOptions = [ @@ -42,15 +43,20 @@ type ImageSettingsPanelProps = { export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) { const [snapDimensionToStep, setSnapDimensionToStep] = useState(true); - const quality = config.quality || "auto"; - const transparentBackground = config.transparentBackground === "true"; - const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1))); - const activeSize = config.size || "auto"; - const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize); + const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!; + const normalized = normalizeImageValue(profile, config); + const quality = normalized.quality; + const transparentBackground = normalized.transparentBackground === "true"; + const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs); + const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count))); + const activeSize = normalized.size; + const availableAspects = aspectOptions.filter((item) => imageOptionAllowed(profile, item)); + const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize); const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]); + const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value }); const selectAspect = (value: string) => { const option = aspectOptions.find((item) => item.value === value); - onConfigChange("size", option?.size || option?.value || "auto"); + onConfigChange("size", option ? imageOptionValue(profile, option) : "auto"); }; const updateDimension = (key: "width" | "height", value: number | null) => { const next = Math.max(1, Math.floor(value || dimensions[key] || 1024)); @@ -71,17 +77,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = }} > {showTitle ?
图像设置
: null} -
+ {profile.quality.supported ?
质量
- {qualityOptions.map((item) => ( + {activeQualityOptions.map((item) => ( onConfigChange("quality", item.value)}> {item.label} ))}
-
-
+
: null} + {profile.transparentBackground.supported ?
透明背景
@@ -95,29 +101,29 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = onChange={(checked) => onConfigChange("transparentBackground", checked ? "true" : "false")} /> -
-
+
: null} + {profile.size.parameter !== "none" ?
尺寸 -
+ {profile.size.allowCustom ?
16倍数对齐 event.stopPropagation()}> -
+
: null}
-
+ {profile.size.allowCustom ?
updateDimension("width", value)} /> ↔ updateDimension("height", value)} /> -
-
-
+
: null} +
: null} + {availableAspects.length ?
宽高比
- {aspectOptions.map((item) => ( + {availableAspects.map((item) => (
-
- {showCount ? ( +
: null} + {showCount && effectiveMaxCount > 1 ? (
生成张数
- {Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => ( + {Array.from({ length: Math.min(quickCount, effectiveMaxCount) }, (_, index) => index + 1).map((value) => ( onConfigChange("count", String(value))}> {value} ))} - onConfigChange("count", String(value || 1))} /> + onConfigChange("count", String(value || 1))} />
) : null} @@ -150,6 +156,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ); } +function imageOptionAllowed(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) { + if (profile.size.parameter === "none") return false; + if (profile.size.allowCustom && profile.size.values.length === 0) return true; + return [option.value, option.size, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).some((value) => profile.size.values.includes(String(value))); +} + +function imageOptionValue(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) { + const candidates = [option.size, option.value, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).map(String); + return candidates.find((value) => profile.size.values.includes(value)) || option.size || option.value || "auto"; +} + export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; children: ReactNode }) { return ( void; protocol?: ModelProtocol; + capability?: "image" | "video"; + model?: string; disabled?: boolean; }; -export function ModelCapabilityEditor({ value, onChange, protocol, disabled = false }: Props) { +export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", disabled = false }: Props) { + if (capability === "image") { + return ; + } const profile = value?.video || defaultModelCapabilityConfig(protocol).video!; const update = (patch: Partial) => onChange?.({ version: 1, video: { ...profile, ...patch } }); const updateReferences = (patch: Partial) => update({ references: { ...profile.references, ...patch } }); @@ -89,6 +94,66 @@ export function ModelCapabilityEditor({ value, onChange, protocol, disabled = fa ); } +function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required> & Pick) { + const profile = value?.image || defaultImageCapabilityConfig(protocol, model); + const update = (patch: Partial) => onChange?.({ version: 1, image: { ...profile, ...patch } }); + const updateReferences = (patch: Partial) => update({ references: { ...profile.references, ...patch } }); + const updateSize = (patch: Partial) => update({ size: { ...profile.size, ...patch } }); + const updateQuality = (patch: Partial) => update({ quality: { ...profile.quality, ...patch } }); + + return ( +
+
+
图片能力参数
生成界面和后端请求都会按此处裁剪参数
+ 当前模型独立生效 +
+ + +
+ updateReferences({ promptMaxChars: promptMaxChars || 1 })} /> + updateReferences({ maxImages: maxImages || 0 })} /> + updateReferences({ maxImageBytes: mbToBytes(maxImageBytes) })} /> + update({ maxOutputs: maxOutputs || 1 })} /> +
+ updateReferences({ maskSupported })} /> +
+ + + { + const parameter = value as ImageCapabilityConfig["size"]["parameter"]; + updateSize(parameter === "none" ? { parameter, values: [], default: "auto", allowCustom: false } : { parameter, values: profile.size.values.length ? profile.size.values : ["1:1"], default: profile.size.default === "auto" ? "1:1" : profile.size.default }); + }} + /> + {profile.size.parameter !== "none" ? <> + ({ label: item, value: item }))} onChange={(defaultValue) => updateSize({ default: defaultValue })} /> + updateSize({ allowCustom })} /> +
+ : null} + + + + updateQuality({ supported })} /> + {profile.quality.supported ?
+ ({ label: item, value: item }))} onChange={(defaultValue) => updateQuality({ default: defaultValue })} /> +
: null} + update({ transparentBackground })} /> +
+ update({ responseFormat: { supported } })} /> + update({ outputFormat: { supported } })} /> +
+
+
+ ); +} + function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) { return
{title}
{children}
; } @@ -105,6 +170,10 @@ function BooleanField({ label, value, disabled, onChange }: { label: string; val return
{label}
支持该参数
onChange({ ...value, supported })} /> onChange({ ...value, default: defaultValue })} />
; } +function ParameterField({ label, description, supported, disabled, onChange }: { label: string; description: string; supported: boolean; disabled: boolean; onChange: (supported: boolean) => void }) { + return
{label}
{description}
; +} + function bytesToMB(value: number) { return value ? Math.round((value / (1024 * 1024)) * 10) / 10 : 0; } diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index b179a4dd6..9000931a8 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -35,8 +35,6 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = } const seconds = normalizeVideoDuration(config.videoSeconds); - const secondOptions = videoDurationOptions(profile); - const durationColumns = secondOptions.length + (profile.duration.selection === "range" ? 1 : 0); const size = normalizeVideoSizeValue(config.size); const dimensions = readSizeDimensions(size); const resolution = normalizeVideoResolutionValue(config.vquality); @@ -84,14 +82,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
-
- {secondOptions.map((value) => ( - onConfigChange("videoSeconds", String(value))}> - {value}s - - ))} - {profile.duration.selection === "range" ? onConfigChange("videoSeconds", String(value))} /> : null} -
+ onConfigChange("videoSeconds", String(value))} />
{profile.generateAudio.supported || profile.watermark.supported ?
{profile.generateAudio.supported ? onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? onConfigChange("videoWatermark", String(checked))} /> : null}
: null}
@@ -111,9 +102,7 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
-
- {videoDurationOptions(profile).map((value) => onConfigChange("videoSeconds", String(value))}>{value}s)} -
+ onConfigChange("videoSeconds", String(value))} />
@@ -127,8 +116,6 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh const duration = normalizeSeedanceDuration(config.videoSeconds); const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default); const watermark = boolConfig(config.videoWatermark, profile.watermark.default); - const durationOptions = videoDurationOptions(profile); - const durationColumns = durationOptions.length + (profile.duration.selection === "range" ? 1 : 0); return ( @@ -171,14 +158,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh -
- {durationOptions.map((value) => ( - onConfigChange("videoSeconds", String(value))}> - {value}s - - ))} - {profile.duration.selection === "range" ? onConfigChange("videoSeconds", String(value))} /> : null} -
+ onConfigChange("videoSeconds", String(value))} />
@@ -255,7 +235,7 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi }; return ( -
); + + function applyStyle(preset: CanvasStylePreset) { + setStylePresetId(preset.id); + setStyleProfileJson(serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset))); + } +} + +function StyleMetric({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function styleExecutionStatusLabel(status?: "ready" | "degraded" | "blocked") { + return status === "blocked" ? "不可执行" : status === "degraded" ? "降级执行" : "完整执行"; } function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) { diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index 96a291d5d..c3c5d7a64 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from "react-router"; import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; +import { parseStyleProfile } from "@/lib/canvas/style-profile"; import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; import { createProject, listProjects, type ProjectSummary } from "@/services/api/projects"; @@ -43,7 +44,7 @@ export default function ProjectsPage() { const normalizedKeyword = keyword.trim().toLowerCase(); return [...(query.data?.projects || [])] .filter(({ project }) => status === "all" || project.status === status) - .filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword)) + .filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword)) .sort((left, right) => { if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN"); if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left); @@ -106,7 +107,7 @@ export default function ProjectsPage() { function ProjectRow({ row }: { row: ProjectSummary }) { const completion = projectSummaryCompletion(row); const stage = projectSummaryStage(row); - const styleTitle = resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风"); + const styleTitle = parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风"); return ( {row.project.aspectRatio}{stage.label} diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 4805444f7..3a5811629 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -24,7 +24,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan protocol: defaultProtocolForModel(channel, model), billingMode: "fixed_request" as const, unitPriceMicrocredits: 0, - capabilityConfig: defaultModelCapabilityConfig(defaultProtocolForModel(channel, model)), + capabilityConfig: defaultModelCapabilityConfig(defaultProtocolForModel(channel, model), model), }; const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model }]; onChange(next.filter((item) => channel.models.includes(item.model))); @@ -125,7 +125,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan protocol: nextProtocol, capability: nextCapability, billingMode: nextCapability === "video" ? activeBillingMode : "fixed_request", - capabilityConfig: nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined, + capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, }); }} /> @@ -135,7 +135,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan updateCost(activeModel, { protocol: nextProtocol, capabilityConfig: activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined })} + onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })} /> {activeCapability === "video" ? ( @@ -167,8 +167,8 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan ) : null} - {activeCapability === "video" ? ( - updateCost(activeModel, { capabilityConfig })} /> + {activeCapability === "image" || activeCapability === "video" ? ( + updateCost(activeModel, { capabilityConfig })} /> ) : null} ) : null} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index f2773d890..4e2cb3950 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -9,6 +9,7 @@ import { channelRequest } from "@/services/api/custom-channel-relay"; import { imageToDataUrl } from "@/services/image-storage"; import type { ReferenceImage } from "@/types/image"; import { withOpenAIPromptCacheKey } from "@/lib/openai-prompt-cache"; +import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; export type AiTextMessage = { role: "system" | "user" | "assistant"; @@ -193,6 +194,33 @@ function resolveRequestSize(quality: string | undefined, size: string) { throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024"); } +function resolveAspectRatio(value: string) { + const normalized = value.trim().toLowerCase().replace("×", "x"); + if (normalized.includes(":")) return normalized; + const dimensions = parseImageDimensions(normalized); + if (!dimensions) throw new Error("图像比例格式不支持,请使用 3:4 或 1024x1360"); + const divisor = dimensionGCD(dimensions.width, dimensions.height); + return `${dimensions.width / divisor}:${dimensions.height / divisor}`; +} + +function dimensionGCD(left: number, right: number) { + while (right) [left, right] = [right, left % right]; + return Math.max(1, left); +} + +function resolveImageRequestSize(profile: ImageCapabilityConfig, quality: string | undefined, size: string) { + const request = imageSizeRequest(profile, size); + if (!request) return undefined; + return { parameter: request.parameter, value: request.parameter === "size" ? resolveRequestSize(quality, request.value) : resolveAspectRatio(request.value) }; +} + +function validateImageCapability(profile: ImageCapabilityConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) { + if (Array.from(prompt).length > profile.references.promptMaxChars) throw new Error(`提示词超过当前模型限制(最多 ${profile.references.promptMaxChars} 字)`); + if (references.length > profile.references.maxImages) throw new Error(`当前图片模型最多支持 ${profile.references.maxImages} 张参考图`); + if (mask && !profile.references.maskSupported) throw new Error("当前图片模型不支持蒙版编辑"); + if (profile.references.maxImageBytes > 0 && references.some((image) => (image.bytes || 0) > profile.references.maxImageBytes)) throw new Error("参考图片文件超过当前模型大小限制"); +} + function normalizeVolcengineArkImageSize(size: string | undefined) { if (!size) return undefined; const dimensions = parseImageDimensions(size); @@ -788,7 +816,10 @@ function parseGeminiImagePayload(payload: GeminiPayload) { export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) { const selectedModel = config.model || config.imageModel; const requestConfig = resolveModelRequestConfig(config, selectedModel); - const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); + const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!; + validateImageCapability(imageProfile, prompt, []); + const normalizedImage = normalizeImageValue(imageProfile, config); + const n = Number(normalizedImage.count); if (requestConfig.apiFormat === "gemini") { try { return await requestGeminiImages(requestConfig, prompt, [], n, options); @@ -796,27 +827,27 @@ export async function requestGeneration(config: AiConfig, prompt: string, option throw new Error(readAxiosError(error, "请求失败")); } } - const quality = normalizeQuality(config.quality); - const requestSize = resolveRequestSize(quality, config.size); + const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined; + const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size); const isVolcengineArk = requestConfig.interfaceType === "volcengine-ark-image"; - const normalizedRequestSize = isVolcengineArk ? normalizeVolcengineArkImageSize(requestSize) : requestSize; + const normalizedRequestSize = requestSize?.parameter === "size" && isVolcengineArk ? { ...requestSize, value: normalizeVolcengineArkImageSize(requestSize.value)! } : requestSize; try { const payload = isVolcengineArk ? { model: requestConfig.model, prompt: withSystemPrompt(requestConfig, prompt), n, - ...(normalizedRequestSize ? { size: normalizedRequestSize } : {}), + ...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}), } : { model: requestConfig.model, prompt: withSystemPrompt(requestConfig, prompt), n, ...(quality ? { quality } : {}), - ...(requestSize ? { size: requestSize } : {}), - response_format: "b64_json", - output_format: IMAGE_OUTPUT_FORMAT, - ...(config.transparentBackground === "true" ? { background: "transparent" } : {}), + ...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}), + ...(imageProfile.responseFormat.supported ? { response_format: "b64_json" } : {}), + ...(imageProfile.outputFormat.supported ? { output_format: IMAGE_OUTPUT_FORMAT } : {}), + ...(imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true" ? { background: "transparent" } : {}), }; const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) @@ -842,7 +873,10 @@ async function postChannelJSON(config: ReturnType imageToDataUrl(image))); const response = await postVolcengineArkImage( @@ -864,7 +899,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: model: requestConfig.model, prompt: withSystemPrompt(requestConfig, requestPrompt), n, - ...(requestSize ? { size: requestSize } : {}), + ...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}), ...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}), }, options, @@ -874,22 +909,22 @@ export async function requestEdit(config: AiConfig, prompt: string, references: throw new Error(readAxiosError(error, "火山方舟图片生成失败")); } } - const quality = normalizeQuality(config.quality); - const requestSize = resolveRequestSize(quality, config.size); + const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined; + const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size); const formData = new FormData(); formData.set("model", requestConfig.model); formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt)); formData.set("n", String(n)); - formData.set("response_format", "b64_json"); - formData.set("output_format", IMAGE_OUTPUT_FORMAT); - if (config.transparentBackground === "true") { + if (imageProfile.responseFormat.supported) formData.set("response_format", "b64_json"); + if (imageProfile.outputFormat.supported) formData.set("output_format", IMAGE_OUTPUT_FORMAT); + if (imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true") { formData.set("background", "transparent"); } if (quality) { formData.set("quality", quality); } if (requestSize) { - formData.set("size", requestSize); + formData.set(requestSize.parameter, requestSize.value); } const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) }))); files.forEach((file) => formData.append("image", file)); diff --git a/web/src/services/api/projects.ts b/web/src/services/api/projects.ts index f179831e9..caf18550b 100644 --- a/web/src/services/api/projects.ts +++ b/web/src/services/api/projects.ts @@ -11,6 +11,7 @@ export type Project = { sourceType: string; description: string; stylePresetId: string; + styleProfileJson?: string; status: "active" | "archived" | string; revision: number; createdAt: string; @@ -174,11 +175,11 @@ export function getProject(id: string) { return request(api.get(`/projects/${encodeURIComponent(id)}`)); } -export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string }) { +export function createProject(input: { name: string; type: string; aspectRatio: string; sourceType: string; description?: string; stylePresetId?: string; styleProfileJson?: string }) { return request<{ project: Project }>(api.post("/projects", input)); } -export function updateProject(projectId: string, input: Partial>) { +export function updateProject(projectId: string, input: Partial>) { return request<{ project: Project }>(api.patch(`/projects/${encodeURIComponent(projectId)}`, input)); } diff --git a/web/src/services/api/style-profiles.ts b/web/src/services/api/style-profiles.ts new file mode 100644 index 000000000..dcb7b94f5 --- /dev/null +++ b/web/src/services/api/style-profiles.ts @@ -0,0 +1,40 @@ +import { apiClient, request } from "@/services/api/request"; + +export type UserStyleProfile = { + id: string; + userId: string; + name: string; + description: string; + coverUrl: string; + tagsJson: string; + profileJson: string; + favorite: boolean; + lastUsedAt?: string; + revision: number; + createdAt: string; + updatedAt: string; +}; + +export function listStyleProfiles() { + return request<{ profiles: UserStyleProfile[] }>(apiClient.get("/style-profiles")); +} + +export function createStyleProfile(profileJson: string) { + return request<{ profile: UserStyleProfile }>(apiClient.post("/style-profiles", { profileJson })); +} + +export function updateStyleProfile(id: string, profileJson: string) { + return request<{ profile: UserStyleProfile }>(apiClient.patch(`/style-profiles/${encodeURIComponent(id)}`, { profileJson })); +} + +export function setStyleProfileFavorite(id: string, favorite: boolean) { + return request<{ id: string; favorite: boolean }>(apiClient.patch(`/style-profiles/${encodeURIComponent(id)}/favorite`, { favorite })); +} + +export function touchStyleProfile(id: string) { + return request<{ id: string }>(apiClient.post(`/style-profiles/${encodeURIComponent(id)}/use`)); +} + +export function deleteStyleProfile(id: string) { + return request<{ id: string }>(apiClient.delete(`/style-profiles/${encodeURIComponent(id)}`)); +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 3587e2b0d..4506b940b 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -601,6 +601,12 @@ --modal-shadow: var(--shadow-2xl); --modal-padding: var(--space-5); /* 20px */ + /* Style Center / Editor */ + --style-center-height: min(780px, 84dvh); + --style-editor-mobile-preview-height: 220px; + --style-center-sidebar-width: 184px; + --style-center-search-width: 320px; + /* Panel 组件(侧边面板,对应 #98 五层体系 L2)*/ --panel-radius: var(--r-2xl); /* 16px */ --panel-padding: var(--space-4); /* 16px */ @@ -3834,7 +3840,9 @@ /* 画风选择器自身就是完整工作区表面,移除 AntD 外壳避免形成双层厚边。 */ .canvas-style-picker-modal .ant-modal-container, - .canvas-style-detail-modal .ant-modal-container { + .canvas-style-detail-modal .ant-modal-container, + .style-profile-editor-modal .ant-modal-container, + .style-asset-binding-modal .ant-modal-container { overflow: hidden; border: var(--stroke-1) solid var(--border) !important; border-radius: var(--r-2xl) !important; @@ -3843,19 +3851,230 @@ } .canvas-style-picker-modal .ant-modal-body, - .canvas-style-detail-modal .ant-modal-body { + .canvas-style-detail-modal .ant-modal-body, + .style-profile-editor-modal .ant-modal-body, + .style-asset-binding-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); + .canvas-style-detail-modal .ant-modal-close, + .style-profile-editor-modal .ant-modal-close, + .style-asset-binding-modal .ant-modal-close { + top: var(--space-4); + right: var(--space-4); + width: var(--space-8); + height: var(--space-8); + border-radius: var(--r-md); color: var(--cn-text); - background: color-mix(in srgb, var(--cn-spatial-elevated) 84%, transparent); + background: color-mix(in srgb, var(--cn-spatial-elevated) 72%, transparent); backdrop-filter: blur(var(--space-2)); } + .canvas-style-picker-modal .ant-modal-close:hover, + .canvas-style-detail-modal .ant-modal-close:hover, + .style-profile-editor-modal .ant-modal-close:hover, + .style-asset-binding-modal .ant-modal-close:hover { + background: color-mix(in srgb, var(--foreground) 10%, var(--cn-spatial-elevated)); + } + + .canvas-style-library-body { + max-height: 76dvh; + } + + .canvas-style-center-shell, + .style-profile-editor-modal .ant-modal-body > div { + height: var(--style-center-height); + } + + .style-center-search { + width: min(var(--style-center-search-width), 50vw); + } + + .style-center-workspace { + grid-template-rows: auto minmax(0, 1fr); + } + + .style-center-sidebar { + max-height: 132px; + overscroll-behavior: contain; + } + + .style-center-primary-nav, + .style-center-category-nav { + overflow-x: auto; + scrollbar-width: none; + } + + .style-center-primary-nav::-webkit-scrollbar, + .style-center-category-nav::-webkit-scrollbar { + display: none; + } + + .style-center-nav-item, + .style-center-category-item { + color: color-mix(in srgb, var(--foreground) 52%, transparent); + } + + .style-center-nav-item:hover, + .style-center-category-item:hover { + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: color-mix(in srgb, var(--foreground) 82%, transparent); + } + + .style-center-nav-item.is-active, + .style-center-category-item.is-active { + background: color-mix(in srgb, var(--foreground) 9%, transparent); + color: var(--foreground); + } + + .style-center-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--space-3); + overscroll-behavior: contain; + } + + .style-center-card { + transition: border-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out); + } + + .style-center-card:not(.is-active):hover { + border-color: color-mix(in srgb, var(--foreground) 24%, transparent) !important; + transform: translateY(-1px); + } + + .style-center-card-cover { + display: block; + width: 100%; + height: clamp(150px, 14vw, 220px); + flex: 0 0 auto; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + } + + .style-center-card-cover img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .style-center-card:hover .style-center-card-cover img { + transform: scale(1.015); + } + + .style-center-favorite-action { + border: var(--stroke-1) solid color-mix(in srgb, var(--color-neutral-0) 12%, transparent); + background: color-mix(in srgb, var(--color-neutral-950) 70%, transparent); + color: color-mix(in srgb, var(--color-neutral-0) 78%, transparent); + backdrop-filter: blur(var(--space-2)); + transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out); + } + + .style-center-favorite-action:hover, + .style-center-favorite-action.is-active { + background: color-mix(in srgb, var(--color-neutral-950) 84%, transparent); + color: var(--color-neutral-0); + } + + .style-center-icon-action { + color: color-mix(in srgb, var(--foreground) 48%, transparent); + transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out); + } + + .style-center-icon-action:hover { + background: color-mix(in srgb, var(--foreground) 7%, transparent); + color: var(--foreground); + } + + .style-center-icon-action.is-danger:hover { + background: color-mix(in srgb, var(--palette-status-error) 10%, transparent); + color: var(--palette-status-error); + } + + .style-center-apply-button.ant-btn { + border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border)); + background: color-mix(in srgb, var(--workspace-accent) 8%, transparent); + color: var(--workspace-accent); + box-shadow: none; + font-weight: 560; + } + + .style-center-apply-button.ant-btn:not(:disabled):hover { + border-color: var(--workspace-accent); + background: color-mix(in srgb, var(--workspace-accent) 14%, transparent); + color: var(--workspace-accent); + } + + .style-center-apply-button.ant-btn.is-current, + .style-center-apply-button.ant-btn:disabled { + border-color: color-mix(in srgb, var(--foreground) 12%, transparent); + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: color-mix(in srgb, var(--foreground) 48%, transparent); + } + + .style-center-nav-item:focus-visible, + .style-center-category-item:focus-visible, + .style-center-icon-action:focus-visible, + .style-center-favorite-action:focus-visible, + .style-profile-editor-tab:focus-visible { + box-shadow: inset 0 0 0 var(--stroke-2) var(--ring); + } + + .style-profile-editor-tabs { + min-height: var(--space-12); + } + + .style-profile-editor-tab::after { + position: absolute; + right: var(--space-3); + bottom: 0; + left: var(--space-3); + height: var(--stroke-2); + background: transparent; + content: ""; + } + + .style-profile-editor-tab.is-active::after { + background: var(--workspace-accent); + } + + .style-profile-editor-workspace { + grid-template-rows: auto minmax(0, 1fr); + } + + .style-profile-editor-preview { + max-height: var(--style-editor-mobile-preview-height); + } + + @media (min-width: 1024px) { + .style-center-workspace { + grid-template-columns: var(--style-center-sidebar-width) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + } + + .style-center-sidebar { + max-height: none; + } + + .style-center-primary-nav, + .style-center-category-nav { + overflow-x: visible; + } + + .style-profile-editor-workspace { + grid-template-rows: minmax(0, 1fr); + } + + .style-profile-editor-preview { + max-height: none; + } + } + + .canvas-style-detail-shell { + max-height: 84dvh; + } + .canvas-toolbar-settings-modal { max-width: calc(100vw - var(--space-8)); } diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 750f6b50d..e4f860cd0 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -1,4 +1,5 @@ import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture"; +import type { StyleExecutionPlan } from "@/lib/canvas/style-profile"; export type Position = { x: number; @@ -170,6 +171,8 @@ export type CanvasNodeMetadata = { workflowTitle?: string; workflowDescription?: string; stylePresetId?: string; + styleProfileJson?: string; + styleExecutionPlan?: StyleExecutionPlan; chapterId?: string; chapterTitle?: string; shotIndex?: number;