From 9ee71d465044b0295a88d3b88652ff442d379e92 Mon Sep 17 00:00:00 2001 From: WindC0X Date: Sat, 15 Aug 2026 15:48:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=B7=A5=E4=BD=9C=E6=B5=81=20-=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=BF=9E=E7=BA=BF=E3=80=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=B8=8E=E6=A8=A1=E5=9E=8B=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。 --- backend/go.mod | 2 +- backend/internal/service/model_capability.go | 55 +++- .../internal/service/model_capability_test.go | 64 +++++ backend/internal/service/outbound.go | 32 +++ backend/internal/service/outbound_test.go | 37 +++ backend/internal/service/provider.go | 14 +- backend/internal/service/provider_test.go | 78 +++++ .../service/provider_video_options.go | 5 +- backend/internal/service/service.go | 23 +- .../service/storyboard_prompt_test.go | 30 ++ web/package.json | 2 +- .../canvas/canvas-leafer-graphics-layer.tsx | 31 +- .../components/canvas/canvas-node-content.tsx | 2 +- web/src/components/canvas/canvas-node.tsx | 4 +- web/src/components/canvas/canvas-toolbar.tsx | 2 +- .../canvas/canvas-workspace-overlays.tsx | 3 +- web/src/components/image-settings-panel.tsx | 59 +++- web/src/lib/canvas/canvas-batch-connection.ts | 139 +++++++++ .../lib/canvas/canvas-connection-policy.ts | 10 +- web/src/lib/canvas/canvas-emotion.ts | 31 +- .../lib/canvas/canvas-image-batch-retry.ts | 84 ++++++ web/src/lib/canvas/canvas-media-versions.ts | 1 + web/src/lib/canvas/canvas-node-copy.ts | 1 + web/src/lib/canvas/canvas-project-domain.ts | 9 +- .../definitions/node-hover-tools.tsx | 8 +- .../definitions/selection-toolbar-tools.tsx | 3 +- .../canvas/tool-registry/tool-definition.ts | 1 + web/src/lib/grok-image-prompt-limit.ts | 9 + web/src/lib/image-resolution-tiers.ts | 83 ++++++ web/src/lib/model-capabilities.ts | 9 +- .../canvas-image-generation-executor.ts | 41 ++- .../canvas-project-selection-toolbar.tsx | 5 +- .../canvas/canvas-project-world-layers.tsx | 50 +++- web/src/pages/canvas/project.tsx | 53 +++- .../use-canvas-connection-controller.ts | 267 +++++++++++++++++- .../canvas/use-canvas-generation-retry.ts | 18 +- web/src/pages/canvas/use-canvas-keyboard.ts | 9 +- .../pages/canvas/use-canvas-media-tools.ts | 18 +- .../canvas/use-canvas-selection-controller.ts | 5 +- web/src/pages/canvas/use-canvas-upload.ts | 1 + web/src/services/api/generation-task.ts | 10 + web/src/types/canvas.ts | 2 + web/test/canvas-batch-connection.test.ts | 115 ++++++++ web/test/canvas-emotion-edit-plan.test.ts | 45 +++ web/test/canvas-image-batch-retry.test.ts | 88 ++++++ web/test/canvas-storyboard-references.test.ts | 22 ++ web/test/grok-image-prompt-limit.test.ts | 23 ++ web/test/image-resolution-tiers.test.ts | 62 ++++ 48 files changed, 1582 insertions(+), 83 deletions(-) create mode 100644 backend/internal/service/model_capability_test.go create mode 100644 web/src/lib/canvas/canvas-batch-connection.ts create mode 100644 web/src/lib/canvas/canvas-image-batch-retry.ts create mode 100644 web/src/lib/grok-image-prompt-limit.ts create mode 100644 web/src/lib/image-resolution-tiers.ts create mode 100644 web/test/canvas-batch-connection.test.ts create mode 100644 web/test/canvas-emotion-edit-plan.test.ts create mode 100644 web/test/canvas-image-batch-retry.test.ts create mode 100644 web/test/canvas-storyboard-references.test.ts create mode 100644 web/test/grok-image-prompt-limit.test.ts create mode 100644 web/test/image-resolution-tiers.test.ts diff --git a/backend/go.mod b/backend/go.mod index 75563b110..099660473 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/tencentyun/cos-go-sdk-v5 v0.7.75 github.com/volcengine/volc-sdk-golang v1.0.253 golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 gorm.io/driver/postgres v1.6.2 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.2 @@ -53,7 +54,6 @@ require ( go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.29.0 // indirect - golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 3f1ac92e3..6488dcd0a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "errors" "fmt" "sort" "strconv" @@ -97,7 +98,7 @@ func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { 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}, + Size: ImageSizeConfig{Parameter: "size", Values: defaultImageSizeValues(), 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}, @@ -142,6 +143,15 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab return image } +func defaultImageSizeValues() []string { + return []string{ + "auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", + "1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824", + "2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752", + "2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840", + } +} + 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}, @@ -412,6 +422,14 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if profile == nil { return nil } + modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/") + if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" { + const maxPromptBytes = 8000 + promptBytes := len(withSystemPrompt(input.Config, input.Prompt)) + if promptBytes > maxPromptBytes { + return BadAuthRequest(fmt.Sprintf("Grok 图片完整提示词为 %d UTF-8 字节,超过上游 %d 字节限制。系统不会自动删改;请精简当前输入、连线文本、角色卡、画风或模板内容后重试", promptBytes, maxPromptBytes)) + } + } if len(input.ReferenceImages) > profile.References.MaxImages { return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)) } @@ -426,6 +444,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) { return BadAuthRequest("图片尺寸不在当前模型支持范围内") } + if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + if err := validateGPTImage2CustomSize(input.Config.Size); err != nil { + return BadAuthRequest(err.Error()) + } + } if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } @@ -436,6 +459,36 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func validateGPTImage2CustomSize(value string) error { + value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) + if value == "" || value == "auto" { + return nil + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return errors.New("自定义图片尺寸请使用宽x高,例如 3840x1920") + } + width, widthErr := strconv.Atoi(parts[0]) + height, heightErr := strconv.Atoi(parts[1]) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return errors.New("图片尺寸必须是正整数") + } + if width%16 != 0 || height%16 != 0 { + return errors.New("图片尺寸宽高必须是 16 的倍数") + } + if max(width, height) > 3840 { + return errors.New("图片尺寸最长边不能超过 3840px") + } + if max(width, height) > min(width, height)*3 { + return errors.New("图片宽高比不能超过 3:1") + } + pixels := int64(width) * int64(height) + if pixels < 655360 || pixels > 8294400 { + return errors.New("图片总像素需在 655360 到 8294400 之间") + } + return nil +} + func videoDurationAllowed(value VideoDurationConfig, seconds int) bool { if value.Selection == "enum" { return containsInt(value.Values, seconds) diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go new file mode 100644 index 000000000..61b2983ef --- /dev/null +++ b/backend/internal/service/model_capability_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "fmt" + "strings" + "testing" +) + +func TestValidateImageTaskRejectsOversizedGrokPromptByUTF8Bytes(t *testing.T) { + prompt := strings.Repeat("中", 4001) + input := canvasGenerationInput{ + Mode: "image", + Prompt: prompt, + Config: providerConfig{InterfaceType: "grok-image", Model: "grok-imagine-image-quality"}, + } + + err := validateImageTask(DefaultImageCapabilityConfig("grok-image", "grok-imagine-image-quality"), input) + if err == nil { + t.Fatal("validateImageTask() error = nil") + } + wantBytes := fmt.Sprintf("%d UTF-8 字节", len(prompt)) + if !strings.Contains(err.Error(), wantBytes) || !strings.Contains(err.Error(), "8000") || !strings.Contains(err.Error(), "连线文本") { + t.Fatalf("validateImageTask() error = %q", err) + } +} + +func TestValidateImageTaskDoesNotApplyQualityPromptLimitToGrokLite(t *testing.T) { + prompt := strings.Repeat("中", 4001) + input := canvasGenerationInput{ + Mode: "image", + Prompt: prompt, + Config: providerConfig{InterfaceType: "grok-image", Model: "grok-imagine-image"}, + } + + if err := validateImageTask(DefaultImageCapabilityConfig("grok-image", "grok-imagine-image"), input); err != nil { + t.Fatalf("validateImageTask() error = %q", err) + } +} + +func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) { + profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2") + profile.Size.AllowCustom = true + + valid := canvasGenerationInput{Mode: "image", Config: providerConfig{InterfaceType: "openai-image", Model: "gpt-image-2", Size: "3840x1920"}} + if err := validateImageTask(profile, valid); err != nil { + t.Fatalf("validateImageTask(valid) error = %v", err) + } + + tests := map[string]string{ + "4096x2048": "最长边", + "3840x2161": "16 的倍数", + "3840x1024": "宽高比", + "640x640": "总像素", + } + for size, want := range tests { + t.Run(size, func(t *testing.T) { + input := canvasGenerationInput{Mode: "image", Config: providerConfig{InterfaceType: "openai-image", Model: "gpt-image-2", Size: size}} + err := validateImageTask(profile, input) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("validateImageTask(%s) error = %v, want %q", size, err, want) + } + }) + } +} diff --git a/backend/internal/service/outbound.go b/backend/internal/service/outbound.go index ae31be133..e9a9b8238 100644 --- a/backend/internal/service/outbound.go +++ b/backend/internal/service/outbound.go @@ -13,6 +13,8 @@ import ( "os" "strings" "time" + + "golang.org/x/net/http/httpproxy" ) const ( @@ -248,11 +250,16 @@ func blockedOutboundHeader(name string) bool { func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, error)) *http.Transport { dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} return &http.Transport{ + Proxy: outboundProxyFromEnvironment, DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } + // 代理地址由部署者通过环境变量配置;目标 URL 仍在请求前经过 SSRF 校验。 + if configuredProxyHost(host) { + return dialer.DialContext(ctx, network, address) + } addresses, err := resolveHost(ctx, host) if err != nil { return nil, err @@ -268,6 +275,31 @@ func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, e } } +func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) { + if req == nil || req.URL == nil { + return nil, nil + } + return httpproxy.FromEnvironment().ProxyFunc()(req.URL) +} + +func configuredProxyHost(host string) bool { + host = normalizeOutboundHost(host) + for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + continue + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + parsed, err := url.Parse(raw) + if err == nil && normalizeOutboundHost(parsed.Hostname()) == host { + return true + } + } + return false +} + func validateOutboundHost(host string) error { _, err := resolveOutboundHost(context.Background(), host) return err diff --git a/backend/internal/service/outbound_test.go b/backend/internal/service/outbound_test.go index 1ab46fc19..141ed3d47 100644 --- a/backend/internal/service/outbound_test.go +++ b/backend/internal/service/outbound_test.go @@ -5,10 +5,47 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "testing" "time" ) +func TestOutboundTransportUsesEnvironmentProxyAndHonorsNoProxy(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://172.24.176.1:10808") + t.Setenv("HTTPS_PROXY", "http://172.24.176.1:10808") + t.Setenv("NO_PROXY", ".se7endot.top,100.64.0.0/10") + transport := newOutboundTransport(resolveOutboundHost) + + proxiedRequest, _ := http.NewRequest(http.MethodGet, "https://api.mikoto.vip/v1/models", nil) + proxyURL, err := transport.Proxy(proxiedRequest) + if err != nil { + t.Fatal(err) + } + wantProxy, _ := url.Parse("http://172.24.176.1:10808") + if proxyURL == nil || proxyURL.String() != wantProxy.String() { + t.Fatalf("proxy = %v, want %v", proxyURL, wantProxy) + } + + directRequest, _ := http.NewRequest(http.MethodGet, "https://api.se7endot.top/v1/models", nil) + proxyURL, err = transport.Proxy(directRequest) + if err != nil { + t.Fatal(err) + } + if proxyURL != nil { + t.Fatalf("NO_PROXY request proxy = %v, want nil", proxyURL) + } +} + +func TestConfiguredProxyHostIsTrustedAsDeploymentEgress(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://172.24.176.1:10808") + if !configuredProxyHost("172.24.176.1") { + t.Fatal("configuredProxyHost() rejected the configured private proxy") + } + if configuredProxyHost("172.24.176.2") { + t.Fatal("configuredProxyHost() accepted an unrelated private host") + } +} + func TestNormalizeOutboundHeadersAllowsCustomUserAgent(t *testing.T) { headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}}) if err != nil { diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 3d4e363c9..152743be2 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -685,7 +685,7 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" { writeField(writer, "background", "transparent") } - if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" { + if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" && !strings.EqualFold(strings.TrimSpace(input.Config.Quality), "auto") { writeField(writer, "quality", normalizeImageQuality(input.Config.Quality)) } if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { @@ -722,7 +722,7 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string] if imageTransparentBackgroundSupported(input.ImageCapability) && input.Config.TransparentBackground == "true" { body["background"] = "transparent" } - if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" { + if imageQualitySupported(input.ImageCapability) && input.Config.Quality != "" && !strings.EqualFold(strings.TrimSpace(input.Config.Quality), "auto") { body["quality"] = normalizeImageQuality(input.Config.Quality) } if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { @@ -1451,6 +1451,16 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] status := strings.ToLower(stringField(state, "status")) if status == "completed" || status == "succeeded" || status == "success" || status == "done" { if videoURL := newAPIVideoResultURL(state); videoURL != "" { + if input.Config.InterfaceType == "xai-video" { + if _, validationErr := ValidateOutboundURL(videoURL); validationErr != nil { + data, mimeType, err := getBinary(ctx, input.Config, "/videos/"+id+"/content") + if err != nil { + return nil, err + } + mimeType = normalizedMediaMimeType(mimeType, data) + return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil + } + } data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL) if err != nil { return nil, fmt.Errorf("视频结果下载失败(任务 %s):%w", id, err) diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 330c53054..6a54587bc 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -179,6 +179,37 @@ func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) { } } +func TestRunImageTaskOmitsAutomaticQualityAndSize(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if _, exists := body["quality"]; exists { + t.Errorf("quality = %#v, want omitted for auto", body["quality"]) + } + if _, exists := body["size"]; exists { + t.Errorf("size = %#v, want omitted for auto", body["size"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"b64_json":"aGVsbG8="}]}`)) + })) + defer server.Close() + + profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2") + profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"auto", "1024x1024"}, Default: "1024x1024", AllowCustom: false} + profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "high"} + _, err := runImageTask(context.Background(), canvasGenerationInput{ + Prompt: "a product photo", + Config: providerConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-image-2", InterfaceType: "openai-image", Size: "auto", Quality: "auto"}, + ImageCapability: profile, + }) + if err != nil { + t.Fatalf("runImageTask() error = %v", err) + } +} + func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -754,6 +785,53 @@ func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { } } +func TestRunVideoTaskXAIVideoUsesContentEndpointForLoopbackResultURL(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + paths := make([]string, 0, 3) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + switch r.Method + " " + r.URL.Path { + case "POST /v1/videos/generations": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"request_id":"video-loopback"}`)) + case "GET /v1/videos/video-loopback": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"done","video":{"url":"http://127.0.0.2:1/unreachable.mp4"}}`)) + case "GET /v1/videos/video-loopback/content": + if authorization := r.Header.Get("Authorization"); authorization != "Bearer test-key" { + t.Errorf("content Authorization = %q, want Bearer test-key", authorization) + } + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + result, err := runVideoTask(context.Background(), canvasGenerationInput{ + Prompt: "make it move", + Config: providerConfig{ + BaseURL: server.URL + "/v1", + APIKey: "test-key", + Model: "grok-imagine-video", + InterfaceType: "xai-video", + }, + }) + if err != nil { + t.Fatalf("runVideoTask() error = %v", err) + } + video, ok := result["video"].(map[string]interface{}) + if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { + t.Fatalf("video = %#v", result["video"]) + } + want := "POST /v1/videos/generations,GET /v1/videos/video-loopback,GET /v1/videos/video-loopback/content" + if got := strings.Join(paths, ","); got != want { + t.Fatalf("paths = %q, want %q", got, want) + } +} + func TestXAIVideoBodyWithoutStartFramePutsAllImagesIntoReferenceImages(t *testing.T) { body, err := xaiVideoRequestBody(canvasGenerationInput{ Prompt: "make it move", diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index c4928e3b4..b7cf41c14 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -61,7 +61,10 @@ func imageSizeParameter(profile *ImageCapabilityConfig, value string) (string, s return "size", normalizePixelSize(value) } value = strings.TrimSpace(value) - if value == "" || value == "auto" { + if strings.EqualFold(value, "auto") { + return "", "" + } + if value == "" { value = strings.TrimSpace(profile.Size.Default) } switch profile.Size.Parameter { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 075a3b4df..dd93883c9 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -1175,6 +1175,7 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { + normalizeAutomaticStoryboardDurations(&plan, 0) err = validateStoryboardPlan(plan, 0, 0, input.Characters) } if err != nil { @@ -1183,6 +1184,9 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas return nil, nil, err } } + if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { + _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) + } return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle) } @@ -1219,6 +1223,7 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task text, _ := result["text"].(string) plan, err := parseAgentStoryboardPlan(text) if err == nil { + normalizeAutomaticStoryboardDurations(&plan, input.ShotDuration) err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters) } if err != nil { @@ -1227,6 +1232,9 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task return nil, nil, err } } + if complexityErr := validateStoryboardComplexity(plan); complexityErr != nil { + _ = s.log(task.UserID, task.ID, "warn", "分镜复杂度建议", complexityErr.Error()) + } rows := make([]map[string]any, 0, len(plan.Shots)) for index, shot := range plan.Shots { imagePromptVariables := storyboardImagePromptValues(input.ProjectStyle.Prompt, plan.StyleGuide, shot) @@ -1277,6 +1285,7 @@ func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, inp repairedText, _ := repaired["text"].(string) plan, parseErr := parseAgentStoryboardPlan(repairedText) if parseErr == nil { + normalizeAutomaticStoryboardDurations(&plan, shotDuration) parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters) } if parseErr == nil { @@ -1420,7 +1429,7 @@ func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCoun if err := validateStoryboardCharacterIDs(plan, characters); err != nil { return err } - return validateStoryboardComplexity(plan) + return nil } func validateStoryboardShotCount(plan agentStoryboardPlan, target int) error { @@ -1463,6 +1472,18 @@ func validateStoryboardComplexity(plan agentStoryboardPlan) error { return fmt.Errorf("镜头复杂度超限:%s", strings.Join(issues, ";")) } +func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int) { + if plan == nil || target != 0 { + return + } + for index := range plan.Shots { + shot := &plan.Shots[index] + dialogueLength := utf8.RuneCountInString(strings.TrimSpace(shot.Dialogue)) + requiredDuration := (dialogueLength + 4) / 5 + shot.Duration = min(60, max(1, shot.Duration, requiredDuration)) + } +} + func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { allowed := make(map[string]bool, len(characters)) for _, character := range characters { diff --git a/backend/internal/service/storyboard_prompt_test.go b/backend/internal/service/storyboard_prompt_test.go index 9637d71fd..c14a61a1c 100644 --- a/backend/internal/service/storyboard_prompt_test.go +++ b/backend/internal/service/storyboard_prompt_test.go @@ -73,6 +73,20 @@ func TestValidateStoryboardComplexityRejectsOverloadedShot(t *testing.T) { } } +func TestValidateStoryboardPlanTreatsComplexityAsAdvisory(t *testing.T) { + plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ + Duration: 8, + CharacterIDs: []string{"a", "b", "c"}, + MustHave: []string{"一", "二", "三", "四"}, + Motion: "航拍推进并升降", + }}} + characters := []storyboardCharacterCard{{AssetID: "a"}, {AssetID: "b"}, {AssetID: "c"}} + + if err := validateStoryboardPlan(plan, 0, 0, characters); err != nil { + t.Fatalf("complexity should be advisory for an otherwise valid plan: %v", err) + } +} + func TestValidateStoryboardComplexityAcceptsSingleDirectedMove(t *testing.T) { plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ Duration: 8, CharacterIDs: []string{"a", "b"}, MustHave: []string{"身份稳定", "动作落点", "结尾状态"}, TimeBeats: "0-2秒:建立;2-6秒:动作;6-8秒:反应", Motion: "缓慢推近后停住", Dialogue: "你终于来了。", @@ -82,6 +96,22 @@ func TestValidateStoryboardComplexityAcceptsSingleDirectedMove(t *testing.T) { } } +func TestNormalizeAutomaticStoryboardDurationsPreservesLongDialogue(t *testing.T) { + plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ + Duration: 8, + Dialogue: strings.Repeat("字", 50), + }}} + + normalizeAutomaticStoryboardDurations(&plan, 0) + + if plan.Shots[0].Duration < 10 { + t.Fatalf("duration = %d, want at least 10", plan.Shots[0].Duration) + } + if err := validateStoryboardComplexity(plan); err != nil { + t.Fatalf("normalized automatic duration should pass: %v", err) + } +} + func TestMatchStoryboardAssetsForShotPrioritizesCurrentCharacterCards(t *testing.T) { assets := []storyboardAsset{ {ID: "environment", Title: "天宫", Tags: []string{"场景:天宫"}}, diff --git a/web/package.json b/web/package.json index be44df63a..d0cf7ce4c 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx index db3586d6f..d68940c78 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, type RefObject } from "react"; import { Group, Leafer, Path, Rect } from "leafer-ui"; import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections"; +import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection"; import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport"; import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport"; import type { CanvasTheme } from "@/lib/canvas-theme"; @@ -18,6 +19,7 @@ type CanvasLeaferGraphicsLayerProps = { relatedConnectionIds: Set; scriptScrollTopById: Record; connectingParams: ConnectionHandle | null; + batchConnectionPreview: CanvasBatchConnectionPreview | null; mouseWorld: Position; connectionTargetNodeId: string | null; connectionTargetAnchorRatio?: number; @@ -42,6 +44,7 @@ type OverlayScene = LeaferScene & { selectionBounds: Rect; guides: Path; draft: Path; + batchDrafts: Group; }; export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) { @@ -125,7 +128,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) const overlay = overlayRef.current; if (!overlay) return; syncOverlayContent(overlay, props, viewportRef.current.k); - }, [props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]); + }, [props.batchConnectionPreview, props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]); useLayoutEffect(() => { const underlay = underlayRef.current; @@ -180,12 +183,14 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene { const selectionBounds = new Rect({ visible: false, hittable: false, fill: "transparent" }); const guides = new Path({ visible: false, hittable: false }); const draft = new Path({ visible: false, hittable: false }); + const batchDrafts = new Group({ visible: false, hittable: false }); world.add(selection); world.add(selectionBounds); world.add(guides); world.add(draft); + world.add(batchDrafts); leafer.add(world); - return { leafer, world, host, selection, selectionBounds, guides, draft }; + return { leafer, world, host, selection, selectionBounds, guides, draft, batchDrafts }; } function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) { @@ -236,6 +241,28 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye opacity: 0.72, }); } + + scene.batchDrafts.removeAll(true); + const batch = props.batchConnectionPreview; + scene.batchDrafts.visible = Boolean(batch); + if (!batch) return; + const target = batch.targetNodeId ? props.nodeById.get(batch.targetNodeId) : undefined; + const stroke = batch.status === "invalid" ? props.theme.accent.danger : batch.status === "partial" ? props.theme.node.activeStroke : props.theme.accent.primary; + batch.sourceNodeIds.forEach((sourceNodeId) => { + const source = props.nodeById.get(sourceNodeId); + if (!source) return; + const handle: ConnectionHandle = { nodeId: source.id, handleType: "source" }; + scene.batchDrafts.add(new Path({ + path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0, batch.targetAnchorRatio), + stroke, + strokeWidth: 1.4, + strokeScaleFixed: true, + strokeCap: "round", + dashPattern: [8, 8], + opacity: 0.72, + hittable: false, + })); + }); } function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme) { diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 437de007b..4fe298e9d 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -213,7 +213,7 @@ function ErrorContent({ node, theme, onRetry }: Pick event.stopPropagation()} > - 重试 + {node.metadata?.isBatchRoot ? "重试失败项" : "重试"} )} diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 20e4f75ac..176209a52 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -23,6 +23,7 @@ type CanvasNodeProps = { isFocusRelated: boolean; isConnectionTarget: boolean; isConnecting: boolean; + forceInputVisible?: boolean; showImageInfo: boolean; reduceMediaEffects?: boolean; readOnly?: boolean; @@ -66,6 +67,7 @@ export const CanvasNode = React.memo(function CanvasNode({ isRelated, isFocusRelated, isConnectionTarget, + forceInputVisible = false, showImageInfo, reduceMediaEffects = false, readOnly = false, @@ -442,7 +444,7 @@ export const CanvasNode = React.memo(function CanvasNode({ : null} - {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null} diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index 6d7519abe..a538a261c 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -135,7 +135,7 @@ export function CanvasToolbar({ onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); }, onDeleteSelected: onDelete, // 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位 - onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onMergeVideos: () => {}, + onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {}, onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {}, onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {}, onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {}, diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index 9b82aa4f6..a489aec60 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -13,6 +13,7 @@ export type PendingConnectionCreate = { connection: ConnectionHandle; position: Position; quick?: boolean; + batchSourceNodeIds?: string[]; }; export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject; containerRef: RefObject; count: number; children: ReactNode }) { @@ -173,7 +174,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
- 创建下一步引用当前节点 + 创建下一步{pending.batchSourceNodeIds?.length ? `引用已选 ${pending.batchSourceNodeIds.length} 个节点` : "引用当前节点"}
diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 0fb151a37..548a63075 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 { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { type AiConfig } from "@/stores/use-config-store"; @@ -15,13 +16,17 @@ const qualityOptions = [ ]; const DIMENSION_STEP = 16; -const aspectOptions = [ +type AspectOption = { value: string; label: string; width: number; height: number; icon: string; size?: string }; + +const aspectOptions: AspectOption[] = [ { value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" }, { value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" }, { value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" }, { value: "4:3", label: "4:3", width: 1360, height: 1024, icon: "landscape" }, { value: "3:4", label: "3:4", width: 1024, height: 1360, icon: "portrait" }, { value: "16:9", label: "16:9", width: 1824, height: 1024, icon: "landscape" }, + { value: "2:1", label: "2:1", size: "2048x1024", width: 2048, height: 1024, icon: "landscape" }, + { value: "1:2", label: "1:2", size: "1024x2048", width: 1024, height: 2048, icon: "portrait" }, { value: "21:9", label: "21:9", size: "2352x1008", width: 2352, height: 1008, icon: "landscape" }, { value: "9:16", label: "9:16", width: 1024, height: 1824, icon: "portrait" }, { value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" }, @@ -52,14 +57,33 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = 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 pixelSizeValues = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto"); + const usesResolutionPicker = supportsImageResolutionPresets(profile.size); + const resolutionOptions = usesResolutionPicker ? buildImageResolutionOptions(pixelSizeValues) : []; + const activeResolution = activeSize === "auto" ? undefined : imageResolutionOption(resolutionOptions, activeSize); + const activeRatio = activeResolution?.ratio || imageRatioForSize(activeSize); + const resolutionChoices = usesResolutionPicker ? imageResolutionChoices(profile.size.values) : []; + const availableAspects: AspectOption[] = usesResolutionPicker && activeSize === "auto" + ? [] + : usesResolutionPicker && activeResolution + ? resolutionOptions.filter((item) => item.tier === activeResolution.tier).map((item) => ({ value: item.ratio, label: item.ratio, size: item.size, width: item.width, height: item.height, icon: item.width === item.height ? "square" : item.width > item.height ? "landscape" : "portrait" })) + : aspectOptions.filter((item) => imageOptionAllowed(profile, item)); + const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio); 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); + const option = availableAspects.find((item) => item.value === value); onConfigChange("size", option ? imageOptionValue(profile, option) : "auto"); }; + const selectResolution = (choice: ImageResolutionChoice) => { + if (choice === "auto") { + onConfigChange("size", "auto"); + return; + } + const ratio = activeRatio || availableAspects[0]?.label; + const size = imageSizeForResolution(resolutionOptions, choice, ratio) || resolutionOptions.find((item) => item.tier === choice)?.size; + if (size) onConfigChange("size", size); + }; const updateDimension = (key: "width" | "height", value: number | null) => { const next = Math.max(1, Math.floor(value || dimensions[key] || 1024)); const width = key === "width" ? next : dimensions.width; @@ -104,23 +128,33 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = /> : null} - {profile.size.parameter !== "none" ?
+ {resolutionChoices.length ?
+ 分辨率 +
+ {resolutionChoices.map((choice) => ( + selectResolution(choice)}> + {choice === "auto" ? "自动" : choice.toUpperCase()} + + ))} +
+
: null} + {profile.size.allowCustom ?
尺寸 - {profile.size.allowCustom ?
+
16倍数对齐 event.stopPropagation()}> -
: null} +
- {profile.size.allowCustom ?
+
updateDimension("width", value)} /> ↔ updateDimension("height", value)} /> -
: null} +
: null} {availableAspects.length ?
宽高比 @@ -158,13 +192,13 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ); } -function imageOptionAllowed(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) { +function imageOptionAllowed(profile: ImageCapabilityConfig, option: AspectOption) { 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]) { +function imageOptionValue(profile: ImageCapabilityConfig, option: AspectOption) { 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"; } @@ -192,7 +226,8 @@ function isGrokResolutionQuality(profile: ImageCapabilityConfig) { } export function imageSizeLabel(size: string) { - return aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size; + const resolutionLabel = formatImageResolutionSize(size, buildImageResolutionOptions([size])); + return resolutionLabel !== size ? resolutionLabel : aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size; } function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { diff --git a/web/src/lib/canvas/canvas-batch-connection.ts b/web/src/lib/canvas/canvas-batch-connection.ts new file mode 100644 index 000000000..2079b89f2 --- /dev/null +++ b/web/src/lib/canvas/canvas-batch-connection.ts @@ -0,0 +1,139 @@ +import { nanoid } from "nanoid"; + +import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy"; +import { normalizeConnection } from "@/lib/canvas/canvas-project-domain"; +import type { AiConfig } from "@/stores/use-config-store"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type Position } from "@/types/canvas"; + +export type CanvasBatchConnectionPreview = { + sourceNodeIds: string[]; + targetNodeId: string | null; + targetHandleId?: string; + targetAnchorRatio?: number; + mouseWorld: Position; + status: "idle" | "valid" | "partial" | "invalid"; +}; + +export type BatchConnectionPlanOptions = { + sourceNodeIds: string[]; + targetNodeId: string; + targetHandleId?: string; + targetAnchorRatio?: number; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; + config: AiConfig; + // 创建聚合节点时保留完整图结构;生成提交阶段再按模型能力截取参考素材。 + allowCapacityOverflow?: boolean; +}; + +export type BatchConnectionSkip = { + nodeId: string; + reason: string; +}; + +export type BatchConnectionPlan = { + connected: string[]; + duplicates: string[]; + skipped: BatchConnectionSkip[]; + connections: CanvasConnection[]; +}; + +export type BatchConnectionCreateRequest = { + batchSourceNodeIds: string[]; + connection: ConnectionHandle; + position: Position; +}; + +export function buildBatchConnectionCreateRequest(sourceNodeIds: string[], nodes: CanvasNodeData[], position: Position): BatchConnectionCreateRequest | null { + const eligibleSourceNodeIds = Array.from(new Set(sourceNodeIds)).filter((nodeId) => { + const node = nodes.find((item) => item.id === nodeId); + return Boolean(node && !batchSourceRestriction(node)); + }); + const firstSourceNodeId = eligibleSourceNodeIds[0]; + if (!firstSourceNodeId) return null; + return { + batchSourceNodeIds: eligibleSourceNodeIds, + connection: { nodeId: firstSourceNodeId, handleType: "source" }, + position, + }; +} + +export function hasBatchConnectionCandidate(sourceNodeIds: string[], targetNodeId: string, nodes: CanvasNodeData[]) { + return sourceNodeIds.some((sourceNodeId) => { + const source = nodes.find((node) => node.id === sourceNodeId); + return Boolean(source && !batchSourceRestriction(source) && normalizeConnection(source.id, targetNodeId, nodes, "source")); + }); +} + +export function planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes, connections, config, allowCapacityOverflow = false }: BatchConnectionPlanOptions): BatchConnectionPlan { + const connected: string[] = []; + const duplicates: string[] = []; + const skipped: BatchConnectionSkip[] = []; + const nextConnections: CanvasConnection[] = []; + const workingConnections = [...connections]; + const seenSourceIds = new Set(); + + sourceNodeIds.forEach((sourceNodeId) => { + if (seenSourceIds.has(sourceNodeId)) return; + seenSourceIds.add(sourceNodeId); + + const source = nodes.find((node) => node.id === sourceNodeId); + if (!source) { + skipped.push({ nodeId: sourceNodeId, reason: "找不到连接源节点" }); + return; + } + const sourceRestriction = batchSourceRestriction(source); + if (sourceRestriction) { + skipped.push({ nodeId: source.id, reason: sourceRestriction }); + return; + } + if (source.id === targetNodeId) { + skipped.push({ nodeId: source.id, reason: "目标节点不能连接到自身" }); + return; + } + + const normalized = normalizeConnection(source.id, targetNodeId, nodes, "source"); + if (!normalized) { + skipped.push({ nodeId: source.id, reason: "节点之间不能连接" }); + return; + } + + const fromHandleId = normalized.fromNodeId === source.id ? undefined : targetHandleId; + const toHandleId = normalized.toNodeId === targetNodeId ? targetHandleId : undefined; + const duplicate = workingConnections.some((connection) => connection.fromNodeId === normalized.fromNodeId + && connection.toNodeId === normalized.toNodeId + && connection.fromHandleId === fromHandleId + && connection.toHandleId === toHandleId); + if (duplicate) { + duplicates.push(source.id); + return; + } + + const policyError = canvasConnectionError(config, nodes, workingConnections, normalized, { ignoreCapacity: allowCapacityOverflow }); + if (policyError) { + skipped.push({ nodeId: source.id, reason: policyError }); + return; + } + + const connection: CanvasConnection = { + id: nanoid(), + ...normalized, + fromHandleId, + toHandleId, + fromAnchorRatio: normalized.fromNodeId === source.id ? 0.5 : targetAnchorRatio, + toAnchorRatio: normalized.toNodeId === targetNodeId ? targetAnchorRatio : 0.5, + }; + workingConnections.push(connection); + nextConnections.push(connection); + connected.push(source.id); + }); + + return { connected, duplicates, skipped, connections: nextConnections }; +} + +export function batchSourceRestriction(node: CanvasNodeData) { + if (node.type === CanvasNodeType.Frame) return "背板不能作为连接源"; + if (node.type === CanvasNodeType.Script) return "分镜脚本需要按行连接"; + if (node.type === CanvasNodeType.Config) return "生成配置不能作为聚合连接源"; + return ""; +} diff --git a/web/src/lib/canvas/canvas-connection-policy.ts b/web/src/lib/canvas/canvas-connection-policy.ts index eb8d02fc7..570a8f310 100644 --- a/web/src/lib/canvas/canvas-connection-policy.ts +++ b/web/src/lib/canvas/canvas-connection-policy.ts @@ -3,8 +3,12 @@ import type { AiConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas"; type ConnectionCandidate = Pick; +type CanvasConnectionPolicyOptions = { + // 仅跳过参考素材数量上限,媒体类型不兼容仍然拒绝。 + ignoreCapacity?: boolean; +}; -export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate) { +export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) { const target = nodes.find((node) => node.id === candidate.toNodeId); if (!target) return "找不到连线目标节点"; const mode = nodeGenerationMode(target); @@ -15,10 +19,10 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], if (mode === "image") { if (input.videoCount > 0) return "图片生成节点不能连接参考视频"; if (input.audioCount > 0) return "图片生成节点不能连接参考音频"; - return capacityError(config, mode, "image", visualInputCount, "参考图"); + return options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图"); } if (mode === "video") { - return capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频"); + return options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频"); } if (mode === "text" && input.audioCount > 0) return "文本生成节点不能连接参考音频"; if (mode === "audio" && input.characterCount > 1) return "角色配音一次只能连接一个角色卡"; diff --git a/web/src/lib/canvas/canvas-emotion.ts b/web/src/lib/canvas/canvas-emotion.ts index 3744c4c75..2cc97f000 100644 --- a/web/src/lib/canvas/canvas-emotion.ts +++ b/web/src/lib/canvas/canvas-emotion.ts @@ -40,6 +40,24 @@ export type CanvasEmotionParams = { faceBox: CanvasFaceBox; }; +export type CanvasEmotionEditMode = "provider-mask" | "local-composite"; + +export type CanvasEmotionEditPlan = { + mode: CanvasEmotionEditMode; + includeMask: boolean; + notice?: string; +}; + +export function resolveEmotionEditPlan(maskSupported: boolean): CanvasEmotionEditPlan { + return maskSupported + ? { mode: "provider-mask", includeMask: true } + : { mode: "local-composite", includeMask: false, notice: "当前渠道不支持蒙版,使用脸部裁切与本地羽化融合" }; +} + +export function emotionProviderMask(plan: CanvasEmotionEditPlan, mask: T) { + return plan.includeMask ? mask : undefined; +} + type BlendshapeName = | "browInnerUp" | "browDown_L" @@ -167,14 +185,21 @@ export function buildEmotionPrompt(params: CanvasEmotionParams, editRegion: Canv `目标情绪:${preset.label};表情要求:${preset.prompt}。情绪强度通过眉眼、嘴角和脸颊的肌肉张力表达,不要通过夸大嘴巴或重绘整张脸表达。`, "第一张输入图是唯一编辑目标,第二张输入图仅用于核对同一人物身份,不得复制第二张图的构图、背景或光线。", targetRegion, - "如果第一张输入图中出现其他人脸,其他人脸全部视为不可编辑背景;只允许修改目标框及其透明蒙版对应的这一张脸。", + "如果第一张输入图中出现其他人脸,其他人脸全部视为不可编辑背景;只允许修改目标人脸框及其邻近表情区域。", "只改变眉眼开合、眼角、嘴角、脸颊和口腔内部的表情细节;保持眼睛大小与方向、嘴裂宽度、牙齿数量大小排列、嘴唇厚度、下巴轮廓和脸型自然且与原图一致。", - "输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。", + "输入图已裁切到目标人物头部区域;只重绘目标人脸及其表情相关细节,不要生成或修改裁切图中的其他内容。", "严格保持人物身份、五官比例、肤色、发型、发丝、耳朵、配饰、服装、姿势、头部朝向、镜头、景深、光线、背景及画面其他人物不变;不要重绘眼镜、帽子或遮挡物。", "禁止夸张卡通笑、嘴巴过大或拉宽、牙齿像整齐白墙、露出不自然牙龈、眼睛变形或眯成线、脸颊鼓包、塑料磨皮、重复五官,以及任何身份漂移。", ].join("\n"); } +export function normalizeEmotionPromptForProvider(prompt: string) { + return prompt + .replaceAll("实际可编辑范围以该人脸周围的透明椭圆蒙版为最终边界。", "最终仅将该人脸周围的椭圆区域融合回原图。") + .replaceAll("只允许修改目标框及其透明蒙版对应的这一张脸。", "只允许修改目标人脸框及其邻近表情区域。") + .replaceAll("输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。", "输入图已裁切到目标人物头部区域;只重绘目标人脸及其表情相关细节,不要生成或修改裁切图中的其他内容。"); +} + function describeEmotionTarget(box: CanvasFaceBox, region: CanvasEmotionEditRegion) { const x = clamp(Math.round(box.x - region.x), 0, Math.max(0, region.width - 1)); const y = clamp(Math.round(box.y - region.y), 0, Math.max(0, region.height - 1)); @@ -182,7 +207,7 @@ function describeEmotionTarget(box: CanvasFaceBox, region: CanvasEmotionEditRegi const height = Math.max(1, Math.min(region.height - y, Math.round(box.height))); const centerX = Math.round(((x + width / 2) / Math.max(1, region.width)) * 100); const centerY = Math.round(((y + height / 2) / Math.max(1, region.height)) * 100); - return `目标人脸框(相对于第一张裁切输入图):x=${x}px,y=${y}px,width=${width}px,height=${height}px;人脸中心约位于裁切图的 ${centerX}% 横向、${centerY}% 纵向。实际可编辑范围以该人脸周围的透明椭圆蒙版为最终边界。`; + return `目标人脸框(相对于第一张裁切输入图):x=${x}px,y=${y}px,width=${width}px,height=${height}px;人脸中心约位于裁切图的 ${centerX}% 横向、${centerY}% 纵向。最终仅将该人脸周围的椭圆区域融合回原图。`; } export async function buildEmotionImageArtifacts(dataUrl: string, box: CanvasFaceBox, imageWidth: number, imageHeight: number): Promise { diff --git a/web/src/lib/canvas/canvas-image-batch-retry.ts b/web/src/lib/canvas/canvas-image-batch-retry.ts new file mode 100644 index 000000000..0c2d5e4ac --- /dev/null +++ b/web/src/lib/canvas/canvas-image-batch-retry.ts @@ -0,0 +1,84 @@ +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas"; + +export function failedImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) { + if (root.type !== CanvasNodeType.Image || !root.metadata?.isBatchRoot) return []; + const nodeById = new Map(nodes.map((node) => [node.id, node])); + return (root.metadata.batchChildIds || []) + .map((id) => nodeById.get(id)) + .filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id && node.metadata.status === "error")); +} + +export function markImageBatchRetrying(rootId: string, childIds: string[], nodes: CanvasNodeData[]): CanvasNodeData[] { + const retryingIds = new Set(childIds); + return nodes.map((node) => { + if (node.id !== rootId && !retryingIds.has(node.id)) return node; + return { + ...node, + metadata: { + ...node.metadata, + status: "loading", + ...(node.id === rootId ? { batchFailedCount: childIds.length } : {}), + errorDetails: undefined, + generationErrorCode: undefined, + failedPromptFingerprint: undefined, + }, + }; + }); +} + +export function restoreUnsubmittedImageBatchChild(current: CanvasNodeData, original: CanvasNodeData): CanvasNodeData { + if (current.metadata?.status !== "loading") return current; + return { + ...current, + metadata: { + ...current.metadata, + status: "error", + errorDetails: original.metadata?.errorDetails || "重试请求未提交", + generationErrorCode: original.metadata?.generationErrorCode, + failedPromptFingerprint: original.metadata?.failedPromptFingerprint, + }, + }; +} + +export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeData[]) { + if (root.type !== CanvasNodeType.Image || !root.metadata?.isBatchRoot) return root; + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const children = (root.metadata.batchChildIds || []) + .map((id) => nodeById.get(id)) + .filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id)); + if (!children.length) return root; + + const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content); + const loading = children.some((node) => node.metadata?.status === "loading"); + const failed = children.find((node) => node.metadata?.status === "error"); + const metadata: CanvasNodeMetadata = { ...root.metadata }; + metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length; + + if (primary) { + metadata.content = primary.metadata?.content; + metadata.storageKey = primary.metadata?.storageKey; + metadata.mimeType = primary.metadata?.mimeType; + metadata.bytes = primary.metadata?.bytes; + metadata.naturalWidth = primary.metadata?.naturalWidth; + metadata.naturalHeight = primary.metadata?.naturalHeight; + metadata.primaryImageId = primary.id; + metadata.status = "success"; + delete metadata.errorDetails; + delete metadata.generationErrorCode; + delete metadata.failedPromptFingerprint; + } else { + delete metadata.content; + delete metadata.storageKey; + delete metadata.mimeType; + delete metadata.bytes; + delete metadata.naturalWidth; + delete metadata.naturalHeight; + delete metadata.primaryImageId; + metadata.status = loading ? "loading" : failed ? "error" : "idle"; + metadata.errorDetails = failed?.metadata?.errorDetails; + metadata.generationErrorCode = failed?.metadata?.generationErrorCode; + metadata.failedPromptFingerprint = failed?.metadata?.failedPromptFingerprint; + } + + return { ...root, metadata }; +} diff --git a/web/src/lib/canvas/canvas-media-versions.ts b/web/src/lib/canvas/canvas-media-versions.ts index 23bb5e1be..97b03cd47 100644 --- a/web/src/lib/canvas/canvas-media-versions.ts +++ b/web/src/lib/canvas/canvas-media-versions.ts @@ -17,6 +17,7 @@ function snapshotMetadata(metadata: CanvasNodeMetadata | undefined, rootId: stri delete snapshot.isBatchRoot; delete snapshot.batchRootId; delete snapshot.batchChildIds; + delete snapshot.batchFailedCount; delete snapshot.primaryImageId; delete snapshot.imageBatchExpanded; snapshot.status = snapshot.content ? "success" : "idle"; diff --git a/web/src/lib/canvas/canvas-node-copy.ts b/web/src/lib/canvas/canvas-node-copy.ts index 9610e4092..3acdcad75 100644 --- a/web/src/lib/canvas/canvas-node-copy.ts +++ b/web/src/lib/canvas/canvas-node-copy.ts @@ -37,6 +37,7 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM delete metadata.generationBatches; delete metadata.batchRootId; delete metadata.batchChildIds; + delete metadata.batchFailedCount; delete metadata.isBatchRoot; delete metadata.primaryImageId; delete metadata.imageBatchExpanded; diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 1a322755a..d88e2059b 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -4,7 +4,7 @@ import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-ang import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation"; import { isFrameNode } from "@/lib/canvas/canvas-frame"; import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; -import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { scopedLocalStorage } from "@/lib/user-scope"; import type { GenerationTask } from "@/services/api/task-center"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas"; @@ -225,9 +225,10 @@ export function storyboardRowFromHandle(nodes: CanvasNodeData[], nodeId: string, export function expandStoryboardTextMentions(prompt: string, references: CanvasResourceReference[]) { let expanded = prompt; references.filter((reference) => reference.active && reference.kind === "text" && reference.text?.trim()).forEach((reference) => { - const token = `@${reference.label}`; - if (!expanded.includes(token)) return; - expanded = expanded.split(token).join(`【项目设定:${reference.title}】\n${reference.text!.trim()}`); + const replacement = `【项目设定:${reference.title}】\n${reference.text!.trim()}`; + for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`]) { + if (expanded.includes(token)) expanded = expanded.split(token).join(replacement); + } }); return expanded; } diff --git a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx index 7dc860a59..e98fe808f 100644 --- a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx @@ -17,9 +17,11 @@ function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.node function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); } function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || isImage(ctx) || isVideo(ctx); } function simpleMode(ctx: ToolContext) { return ctx.workspaceMode === "simple"; } +function isImageBatchRoot(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.isBatchRoot && ctx.nodeMetadata.batchChildIds?.length); } function canRetry(ctx: ToolContext) { const requiresPromptChange = ctx.nodeMetadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(ctx.nodeMetadata?.errorDetails); - return ctx.nodeMetadata?.status === "error" && !requiresPromptChange; + const batchHasFailures = isImageBatchRoot(ctx) && (ctx.nodeMetadata?.batchFailedCount || (ctx.nodeMetadata?.status === "error" ? 1 : 0)) > 0; + return (ctx.nodeMetadata?.status === "error" || (batchHasFailures && ctx.nodeMetadata?.status !== "loading")) && !requiresPromptChange; } export const nodeHoverToolbarTools: ToolDefinition[] = [ @@ -52,8 +54,8 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [ id: "retry", toolbar: "node-hover", category: "node-state", - label: "重新生成", - displayLabel: "重试", + label: (ctx) => isImageBatchRoot(ctx) ? "重试批次中的失败图片" : "重新生成", + displayLabel: (ctx) => isImageBatchRoot(ctx) ? "重试失败项" : "重试", icon: , defaultVisible: true, defaultOrder: 30, diff --git a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx index 8f30071c9..f903dfe69 100644 --- a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx @@ -1,4 +1,4 @@ -import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, LoaderCircle, Workflow } from "lucide-react"; +import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react"; import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry"; @@ -20,6 +20,7 @@ export const selectionToolbarTools: ToolDefinition[] = [ // 分组组 { id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: , defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() }, { id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: , defaultVisible: true, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() }, + { id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: , defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() }, { id: "selection-merge-videos", toolbar: "selection", diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts index dd51e38e2..c35df08ab 100644 --- a/web/src/lib/canvas/tool-registry/tool-definition.ts +++ b/web/src/lib/canvas/tool-registry/tool-definition.ts @@ -57,6 +57,7 @@ export type ToolbarHandlers = { onArrange: (mode: "row" | "column" | "grid" | "flow") => void; onCreateStoryboard: () => void; onCreateReferenceGroup: () => void; + onBatchConnect: () => void; onMergeVideos: () => void; // 节点悬停工具栏——节点操作(均接收当前节点) onNodeInfo: (node: CanvasNodeData) => void; diff --git a/web/src/lib/grok-image-prompt-limit.ts b/web/src/lib/grok-image-prompt-limit.ts new file mode 100644 index 000000000..8ab82eee8 --- /dev/null +++ b/web/src/lib/grok-image-prompt-limit.ts @@ -0,0 +1,9 @@ +const GROK_IMAGE_PROMPT_MAX_BYTES = 8000; + +export function grokImagePromptLimitError(prompt: string, interfaceType?: string, model?: string) { + const modelName = model?.trim().toLowerCase().replace(/^models\//, ""); + if (interfaceType !== "grok-image" || modelName !== "grok-imagine-image-quality") return null; + const promptBytes = new TextEncoder().encode(prompt).byteLength; + if (promptBytes <= GROK_IMAGE_PROMPT_MAX_BYTES) return null; + return `Grok 图片完整提示词为 ${promptBytes} UTF-8 字节,超过上游 ${GROK_IMAGE_PROMPT_MAX_BYTES} 字节限制。完整提示词包含当前输入及自动展开的连线文本、角色卡、画风和模板;系统不会自动删改,请回到相应节点精简后重试。`; +} diff --git a/web/src/lib/image-resolution-tiers.ts b/web/src/lib/image-resolution-tiers.ts new file mode 100644 index 000000000..975847e14 --- /dev/null +++ b/web/src/lib/image-resolution-tiers.ts @@ -0,0 +1,83 @@ +export type ImageResolutionTier = "1k" | "2k" | "4k"; + +export type ImageResolutionOption = { + size: string; + tier: ImageResolutionTier; + ratio: string; + width: number; + height: number; +}; + +export type ImageResolutionChoice = "auto" | ImageResolutionTier; + +const tierOrder: ImageResolutionTier[] = ["1k", "2k", "4k"]; +const ratioOrder = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "2:1", "1:2", "21:9"]; + +export function supportsImageResolutionPresets(size: { parameter: string; values: string[]; allowCustom?: boolean }) { + return size.parameter === "size" && size.values.some((value) => /^\d+x\d+$/i.test(value.trim())); +} + +export function buildImageResolutionOptions(values: string[]) { + return values + .map(parseImageResolutionOption) + .filter((item): item is ImageResolutionOption => Boolean(item)) + .sort((left, right) => { + const tierDifference = tierOrder.indexOf(left.tier) - tierOrder.indexOf(right.tier); + if (tierDifference) return tierDifference; + return ratioOrder.indexOf(left.ratio) - ratioOrder.indexOf(right.ratio); + }); +} + +export function imageResolutionChoices(values: string[]): ImageResolutionChoice[] { + const choices: ImageResolutionChoice[] = values.some((value) => value.trim().toLowerCase() === "auto") ? ["auto"] : []; + for (const option of buildImageResolutionOptions(values)) { + if (!choices.includes(option.tier)) choices.push(option.tier); + } + return choices; +} + +export function imageResolutionOption(options: ImageResolutionOption[], size: string) { + return options.find((item) => item.size.toLowerCase() === size.trim().toLowerCase()); +} + +export function imageSizeForResolution(options: ImageResolutionOption[], tier: ImageResolutionTier, ratio: string) { + return options.find((item) => item.tier === tier && item.ratio === ratio)?.size; +} + +export function imageRatioForSize(size: string) { + return parseImageResolutionOption(size)?.ratio || ""; +} + +export function formatImageResolutionSize(size: string, options: ImageResolutionOption[]) { + if (size.trim().toLowerCase() === "auto") return "自动"; + const option = imageResolutionOption(options, size); + return option ? `${option.ratio} · ${option.tier.toUpperCase()}` : size; +} + +function parseImageResolutionOption(value: string): ImageResolutionOption | null { + const match = value.trim().toLowerCase().match(/^(\d+)x(\d+)$/); + if (!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + const pixels = width * height; + const tier: ImageResolutionTier | null = pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : null; + if (!tier) return null; + const ratio = closestRatio(width / height); + if (!ratio) return null; + return { size: `${width}x${height}`, tier, ratio, width, height }; +} + +function closestRatio(value: number) { + let result = ""; + let bestDifference = Number.POSITIVE_INFINITY; + for (const label of ratioOrder) { + const [width, height] = label.split(":").map(Number); + const expected = width / height; + const difference = Math.abs(value - expected) / expected; + if (difference < bestDifference) { + result = label; + bestDifference = difference; + } + } + return bestDifference <= 0.025 ? result : ""; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index c7f19f3d3..2e3b6a581 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -62,7 +62,14 @@ export type VideoCapabilityConfig = { defaultOperation: string; }; -const defaultImageSizes = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840"]; +// Keep explicit pixel presets for each resolution tier so the settings panel can +// switch between 1K, 2K and 4K without silently converting the requested ratio. +const defaultImageSizes = [ + "auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", + "1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824", + "2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752", + "2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840", +]; export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = ""): ImageCapabilityConfig { const image: ImageCapabilityConfig = { diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 05ca5827d..33393cca7 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -85,6 +85,7 @@ export async function executeImageGeneration({ size: generationConfig.size, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, + batchFailedCount: count > 1 ? 0 : undefined, batchUsesReferenceImages: referenceImages.length > 0, primaryImageId: undefined, ...generationMetadata, @@ -145,6 +146,7 @@ export async function executeImageGeneration({ if (count > 1 && !directCopiedBatch) startGenerationRequest(rootId, nodeId, nodeId, controller); let hasSuccess = false; let hasFailure = false; + let failureCount = 0; let representativeFailure: GenerationFailureMetadata | undefined; await Promise.all( targetIds.map(async (targetId) => { @@ -216,6 +218,7 @@ export async function executeImageGeneration({ const failure = generationFailureMetadata(error, prompt); if (!representativeFailure || failure.generationErrorCode === CONTENT_MODERATION_ERROR_CODE) representativeFailure = failure; hasFailure = true; + failureCount += 1; setNodes((current) => current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node))); return false; } finally { @@ -230,19 +233,29 @@ export async function executeImageGeneration({ } if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败"); setNodes((current) => - current.map((node) => - node.id === nodeId && (isConfigNode || reuseSourceNode) - ? { - ...node, - metadata: { - ...node.metadata, - status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, - ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }), - }, - } - : !directCopiedBatch && node.id === rootId && !hasSuccess - ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...(representativeFailure || { errorDetails: "全部图片生成失败" }) } } - : node, - ), + current.map((node) => { + if (node.id === nodeId && isConfigNode) { + return { + ...node, + metadata: { + ...node.metadata, + status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, + ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }), + }, + }; + } + if (node.id === rootId && (reuseSourceNode || !directCopiedBatch)) { + return { + ...node, + metadata: { + ...node.metadata, + status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, + batchFailedCount: count > 1 ? failureCount : undefined, + ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }), + }, + }; + } + return node; + }), ); } diff --git a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx index 6c79712bd..241aee1fe 100644 --- a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx +++ b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx @@ -18,14 +18,15 @@ type CanvasProjectSelectionToolbarProps = { onArrange: (mode: "row" | "column" | "grid" | "flow") => void; onCreateStoryboard: () => void; onCreateReferenceGroup: () => void; + onBatchConnect: () => void; onMergeVideos: () => void; }; -export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos }: CanvasProjectSelectionToolbarProps) { +export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const handlers = { - onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos, + onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, } as Partial as ToolbarHandlers; const ctx: ToolContext = { diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx index 612690c36..d276063eb 100644 --- a/web/src/pages/canvas/canvas-project-world-layers.tsx +++ b/web/src/pages/canvas/canvas-project-world-layers.tsx @@ -1,8 +1,10 @@ -import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"; +import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"; +import { Link2 } from "lucide-react"; import { ConnectionPath } from "@/components/canvas/canvas-connections"; import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; import { CanvasNode } from "@/components/canvas/canvas-node"; +import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { isFrameNode } from "@/lib/canvas/canvas-frame"; import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas"; @@ -40,6 +42,8 @@ type CanvasProjectWorldLayersProps = { mentionReferencesByNodeId: Map; mediaEffectsDisabledNodeId?: string | null; selectedNodeBounds: NodeBounds; + batchSourceNodeIds: string[]; + batchConnectionPreview: CanvasBatchConnectionPreview | null; isNodeDragging: boolean; selectionBoundsElementRef: RefObject; renderCanvasNodeContent: (node: CanvasNodeData) => ReactNode; @@ -65,6 +69,7 @@ type CanvasProjectWorldLayersProps = { onOpenTextEditor: (node: CanvasNodeData) => void; onOpenDirector: (node: CanvasNodeData) => void; onOpenDrawing: (node: CanvasNodeData) => void; + onStartBatchConnection: (event: ReactPointerEvent, sourceNodeIds: string[]) => void; }; const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = []; @@ -120,8 +125,9 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { isSelected={props.selectedNodeIds.has(node.id)} isRelated={props.relatedNodeIds.has(node.id)} isFocusRelated={props.activeNodeId === node.id} - isConnectionTarget={props.connectionTargetNodeId === node.id} - isConnecting={Boolean(props.connectingParams)} + isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id} + isConnecting={Boolean(props.connectingParams || props.batchConnectionPreview)} + forceInputVisible={Boolean(props.batchConnectionPreview)} batchCount={props.batchChildCountById.get(node.id) || 0} batchExpanded={Boolean(node.metadata?.imageBatchExpanded)} batchClosing={Boolean(node.metadata?.batchRootId && props.collapsingBatchIds.has(node.metadata.batchRootId))} @@ -168,8 +174,44 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { width: props.selectedNodeBounds.width + 24 / viewportScale, height: props.selectedNodeBounds.height + 24 / viewportScale, }} - /> + > + {props.batchSourceNodeIds.length > 0 ? ( + props.onStartBatchConnection(event, props.batchSourceNodeIds)} + /> + ) : null} +
) : null} ); } + +function BatchConnectionHandle({ scale, count, active, onPointerDown }: { scale: number; count: number; active: boolean; onPointerDown: (event: ReactPointerEvent) => void }) { + const inverseScale = 1 / Math.max(scale, 0.05); + const buttonStyle: CSSProperties = { + right: -18 * inverseScale, + top: "50%", + width: 30 * inverseScale, + height: 30 * inverseScale, + background: active ? "var(--workspace-accent)" : "var(--workspace-surface-strong)", + borderColor: active ? "var(--workspace-accent)" : "var(--workspace-border)", + color: active ? "var(--workspace-accent-foreground)" : "var(--foreground)", + }; + return ( + + ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 65d507304..68cce559c 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -64,8 +64,10 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; +import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry"; import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain"; import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"; +import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection"; import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress"; import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; @@ -785,6 +787,10 @@ function InfiniteCanvasPage() { createConnectedNode, getConnectionCreateDisabledReason, handleConnectStart, + handleBatchConnectionTargetClick, + batchConnectionPreview, + beginBatchConnectionMode, + startBatchConnection, mouseWorld, pendingConnectionCreate, setConnecting, @@ -806,6 +812,10 @@ function InfiniteCanvasPage() { setDrawingNodeId, }); + const batchSourceNodeIds = useMemo(() => nodes + .filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)) + .map((node) => node.id), [nodes, selectedNodeIds]); + const handleCanvasSelectionStart = useCallback(() => { setContextMenu(null); setDialogNodeId(null); @@ -852,6 +862,7 @@ function InfiniteCanvasPage() { onCanvasSelectionStart: handleCanvasSelectionStart, onNodeInteractionStart: handleNodeInteractionStart, onNodeClick: handleSelectedNodeClick, + onBatchConnectionTarget: handleBatchConnectionTargetClick, onDeselect: handleCanvasDeselect, onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)), }); @@ -975,7 +986,7 @@ function InfiniteCanvasPage() { const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null; const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null; const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null; - const canCreateDrawingFromConnection = pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content); + const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content); const openTextNodeEditor = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text) return; @@ -1131,6 +1142,7 @@ function InfiniteCanvasPage() { focusMode, exitFocusMode, toggleFocusMode, + beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)), }); const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => { @@ -1306,6 +1318,22 @@ function InfiniteCanvasPage() { bindGenerationTask, applyGenerationTaskResult, }); + const reconcileImageBatchRootNode = useCallback((rootId: string) => { + setNodes((current) => { + const root = current.find((item) => item.id === rootId); + if (!root) return current; + const reconciled = reconcileImageBatchRoot(root, current); + return current.map((item) => item.id === root.id ? reconciled : item); + }); + }, [setNodes]); + const retryImageBatchChildren = useCallback((rootId: string, children: CanvasNodeData[]) => { + const childIds = children.map((child) => child.id); + setNodes((current) => markImageBatchRetrying(rootId, childIds, current)); + void Promise.allSettled(children.map(async (child) => { + await handleRetryNode(child); + setNodes((current) => current.map((item) => item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item)); + })).finally(() => reconcileImageBatchRootNode(rootId)); + }, [handleRetryNode, reconcileImageBatchRootNode, setNodes]); const generateImageFromTextNode = useCallback( (node: CanvasNodeData) => { @@ -1519,9 +1547,23 @@ function InfiniteCanvasPage() { void generateScriptRows(node.id, prompt); return; } + if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) { + const failedChildren = failedImageBatchChildren(node, nodesRef.current); + if (!failedChildren.length) { + message.info("当前批次没有需要重试的失败图片"); + return; + } + message.info(`正在重试 ${failedChildren.length} 个失败图片`); + retryImageBatchChildren(node.id, failedChildren); + return; + } + if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) { + retryImageBatchChildren(node.metadata.batchRootId, [node]); + return; + } void handleRetryNode(node); }, - [generateScriptRows, handleRetryNode, message], + [generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren], ); const openCanvasNodeTaskDetails = useCallback( (node: CanvasNodeData) => { @@ -1663,6 +1705,7 @@ function InfiniteCanvasPage() { relatedConnectionIds={relatedHighlight.connectionIds} scriptScrollTopById={scriptScrollTopById} connectingParams={connectingParams} + batchConnectionPreview={batchConnectionPreview} mouseWorld={mouseWorld} connectionTargetNodeId={connectionTargetNodeId} connectionTargetAnchorRatio={connectionTargetAnchorRatio} @@ -1714,6 +1757,8 @@ function InfiniteCanvasPage() { mentionReferencesByNodeId={mentionReferencesByNodeId} mediaEffectsDisabledNodeId={emotionNodeId} selectedNodeBounds={selectedNodeBounds} + batchSourceNodeIds={batchSourceNodeIds} + batchConnectionPreview={batchConnectionPreview} isNodeDragging={isNodeDragging} selectionBoundsElementRef={selectionBoundsElementRef} renderCanvasNodeContent={renderCanvasNodeContent} @@ -1748,6 +1793,7 @@ function InfiniteCanvasPage() { onOpenTextEditor={openTextNodeEditor} onOpenDirector={editCanvasDirector} onOpenDrawing={openDrawingNode} + onStartBatchConnection={startBatchConnection} /> @@ -1892,6 +1938,7 @@ function InfiniteCanvasPage() { onArrange={arrangeSelectedNodes} onCreateStoryboard={createStoryboardGroup} onCreateReferenceGroup={createReferenceGroup} + onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))} onMergeVideos={() => void mergeSelectedVideos()} /> ) : null} @@ -1951,7 +1998,7 @@ function InfiniteCanvasPage() { extractingAudio={segmentRunningMode === "audio"} trimmingVideo={segmentRunningMode === "video"} onReversePrompt={createImageReversePromptNodes} - onRetry={(node) => void handleRetryNode(node)} + onRetry={retryCanvasNode} onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} onToggleLocked={(node) => toggleNodeLocked(node.id)} onDelete={(node) => deleteNodes(new Set([node.id]))} diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index d7d997547..95ea10076 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; import { App } from "antd"; import { nanoid } from "nanoid"; import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; import { getNodeSpec } from "@/constant/canvas"; +import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection"; import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy"; import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain"; import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage"; @@ -38,6 +39,8 @@ type ConnectionDropTarget = { isNearNode: boolean; }; +type BatchConnectionDropTarget = ConnectionDropTarget; + const CONNECTION_HANDLE_HIT_RADIUS = 40; const CONNECTION_NODE_HIT_PADDING = 32; const NODE_STATUS_IDLE = "idle" as const; @@ -65,17 +68,32 @@ export function useCanvasConnectionController({ const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState(); const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); + const [batchConnectionPreview, setBatchConnectionPreview] = useState(null); const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); const connectingParamsRef = useRef(connectingParams); const connectingPointerIdRef = useRef(null); const connectingPointerStartRef = useRef(null); const pendingConnectionCreateRef = useRef(pendingConnectionCreate); + const batchConnectionPreviewRef = useRef(null); + const batchConnectionPointerIdRef = useRef(null); + const batchConnectionPointerStartRef = useRef(null); useLayoutEffect(() => { connectingParamsRef.current = connectingParams; pendingConnectionCreateRef.current = pendingConnectionCreate; }, [connectingParams, pendingConnectionCreate]); + const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => { + batchConnectionPreviewRef.current = next; + setBatchConnectionPreview(next); + }, []); + + const clearBatchConnection = useCallback(() => { + batchConnectionPointerIdRef.current = null; + batchConnectionPointerStartRef.current = null; + updateBatchConnectionPreview(null); + }, [updateBatchConnectionPreview]); + const setConnecting = useCallback((next: ConnectionHandle | null) => { connectingParamsRef.current = next; setConnectingParams(next); @@ -95,7 +113,39 @@ export function useCanvasConnectionController({ const cancelPendingConnectionCreate = useCallback(() => { closeConnectionCreateMenu(); setConnecting(null); - }, [closeConnectionCreateMenu, setConnecting]); + clearBatchConnection(); + }, [clearBatchConnection, closeConnectionCreateMenu, setConnecting]); + + const previewBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string | null, targetHandleId: string | undefined, targetAnchorRatio: number | undefined, mouseWorld: Position) => { + const plan = targetNodeId + ? planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config }) + : null; + const eligibleSourceCount = sourceNodeIds.filter((id) => { + const node = nodesRef.current.find((item) => item.id === id); + return Boolean(node && !batchSourceRestriction(node)); + }).length; + const status = !targetNodeId || !plan ? "idle" : plan.connections.length === eligibleSourceCount ? "valid" : plan.connections.length ? "partial" : "invalid"; + updateBatchConnectionPreview({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, mouseWorld, status }); + return plan; + }, [config, connectionsRef, nodesRef, updateBatchConnectionPreview]); + + const commitBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => { + const plan = planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config }); + if (!plan.connections.length) { + const reason = plan.skipped[0]?.reason || "没有可建立的连接"; + message.warning(reason); + return plan; + } + setNodes((currentNodes) => plan.connections.reduce((current, connection) => attachNodeToStoryboardRow(current, connection), currentNodes)); + setConnections((currentConnections) => [...currentConnections, ...plan.connections]); + setContextMenu(null); + const skippedCount = plan.skipped.length; + const duplicateCount = plan.duplicates.length; + const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : ""; + if (skippedCount) message.warning(`已连接 ${plan.connected.length} 个节点${suffix}:${plan.skipped[0].reason}`); + else message.success(`已连接 ${plan.connected.length} 个节点${suffix}`); + return plan; + }, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]); const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => { if (current.nodeId === targetNodeId) return; @@ -129,10 +179,21 @@ export function useCanvasConnectionController({ message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图"); return; } - const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined; + const batchSourceNodeIds = pending.batchSourceNodeIds?.length ? Array.from(new Set(pending.batchSourceNodeIds)) : []; + const batchSourceNodes = batchSourceNodeIds + .map((nodeId) => nodesRef.current.find((node) => node.id === nodeId)) + .filter((node): node is CanvasNodeData => Boolean(node)); + const storyboardRow = batchSourceNodeIds.length ? undefined : type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined; const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : ""; const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined; - const scriptPrompt = type === CanvasNodeType.Script && sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""; + const batchScriptPrompt = batchSourceNodes + .filter((node) => node.type === CanvasNodeType.Text) + .map((node) => (node.metadata?.content || node.metadata?.prompt || "").trim()) + .filter(Boolean) + .join("\n\n"); + const scriptPrompt = type === CanvasNodeType.Script + ? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "" + : ""; const metadata = type === CanvasNodeType.Drawing ? { drawingEngine: defaultDrawingEngine } : type === CanvasNodeType.Script && scriptPrompt @@ -153,6 +214,40 @@ export function useCanvasConnectionController({ : pending.position; const newNode = createCanvasNode(type, position, metadata); if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`; + if (batchSourceNodeIds.length && type === CanvasNodeType.Drawing) { + message.error("批量连接暂不支持创建绘图,请先连接到普通节点"); + closeConnectionCreateMenu(); + return; + } + const batchPlan = batchSourceNodeIds.length + ? planBatchConnections({ sourceNodeIds: batchSourceNodeIds, targetNodeId: newNode.id, nodes: [...nodesRef.current, newNode], connections: connectionsRef.current, config, allowCapacityOverflow: true }) + : null; + if (batchPlan) { + if (!batchPlan.connections.length) { + const detail = batchPlan.skipped.slice(0, 3).map((item) => item.reason).join(";"); + message.warning(detail ? `没有可建立的连接:${detail}` : "没有可建立的连接"); + closeConnectionCreateMenu(); + setConnecting(null); + return; + } + const nextConnections = [...connectionsRef.current, ...batchPlan.connections]; + const nextNodes = batchPlan.connections.reduce((currentNodes, connection) => attachNodeToStoryboardRow(currentNodes, connection), [...nodesRef.current, newNode]); + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set([newNode.id])); + setSelectedConnectionId(null); + if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id); + const skippedCount = batchPlan.skipped.length; + const duplicateCount = batchPlan.duplicates.length; + const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : ""; + if (skippedCount) message.warning(`已创建并连接 ${batchPlan.connections.length} 个源节点${suffix}:${batchPlan.skipped[0].reason}`); + else message.success(`已创建节点并连接 ${batchPlan.connections.length} 个源节点${suffix}`); + closeConnectionCreateMenu(); + setConnecting(null); + return; + } const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); if (!connection) { message.warning("配置节点之间不能连接"); @@ -209,6 +304,12 @@ export function useCanvasConnectionController({ }, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]); const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => { + if (pending.batchSourceNodeIds?.length) { + if (type === CanvasNodeType.Drawing) return "批量连接暂不支持绘图"; + const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: getNodeSpec(type).width, height: getNodeSpec(type).height }; + const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true }); + return plan.connections.length ? "" : plan.skipped[0]?.reason || "当前选中的节点不能连接到此类型"; + } const spec = getNodeSpec(type); const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: spec.width, height: spec.height }; const pendingNodes = [...nodesRef.current, pendingNode]; @@ -257,6 +358,118 @@ export function useCanvasConnectionController({ return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode }; }, [config, connectionsRef, nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]); + const getBatchConnectionDropTarget = useCallback((clientX: number, clientY: number, sourceNodeIds: string[]): BatchConnectionDropTarget => { + const source = sourceNodeIds + .map((id) => nodesRef.current.find((node) => node.id === id)) + .find((node): node is CanvasNodeData => Boolean(node && !batchSourceRestriction(node))); + if (!source) return { nodeId: null, isNearNode: false }; + + const world = screenToCanvas(clientX, clientY); + const scale = Math.max(viewportRef.current.k, 0.05); + const padding = CONNECTION_NODE_HIT_PADDING / scale; + const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale; + let isNearNode = false; + let bestNodeId: string | null = null; + let bestHandleId: string | undefined; + let bestAnchorRatio: number | undefined; + let bestPriority = Number.POSITIVE_INFINITY; + const current: ConnectionHandle = { nodeId: source.id, handleType: "source" }; + + [...nodesRef.current] + .filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current) && !isFrameNode(node)) + .reverse() + .forEach((node) => { + const scrollTop = scriptScrollTopById[node.id] || 0; + const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined; + if (node.type === CanvasNodeType.Script && !targetHandleId) return; + const targetAnchorRatio = node.type === CanvasNodeType.Script ? undefined : Math.min(0.94, Math.max(0.06, (world.y - node.position.y) / Math.max(node.height, 1))); + const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop, targetAnchorRatio); + const dx = world.x - anchor.x; + const dy = world.y - anchor.y; + const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; + const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height; + const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding; + if (!hitsHandle && !hitsInside && !hitsExpanded) return; + isNearNode = true; + if (!hasBatchConnectionCandidate(sourceNodeIds, node.id, nodesRef.current)) return; + const priority = hitsInside ? 0 : hitsHandle ? 1 : 2; + if (priority < bestPriority) { + bestNodeId = node.id; + bestHandleId = targetHandleId; + bestAnchorRatio = targetAnchorRatio; + bestPriority = priority; + } + }); + return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode }; + }, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]); + + const startBatchConnection = useCallback((event: ReactPointerEvent, sourceNodeIds: string[]) => { + const eligible = sourceNodeIds.filter((id) => { + const node = nodesRef.current.find((item) => item.id === id); + return Boolean(node && !batchSourceRestriction(node)); + }); + if (!eligible.length) { + message.warning("当前选区没有可作为连接源的节点"); + return; + } + event.preventDefault(); + event.stopPropagation(); + batchConnectionPointerIdRef.current = event.pointerId; + batchConnectionPointerStartRef.current = { x: event.clientX, y: event.clientY }; + setSelectedConnectionId(null); + const mouseWorld = screenToCanvas(event.clientX, event.clientY); + previewBatchConnection(eligible, null, undefined, undefined, mouseWorld); + }, [message, nodesRef, previewBatchConnection, screenToCanvas, setSelectedConnectionId]); + + const beginBatchConnectionMode = useCallback((sourceNodeIds: string[]) => { + const eligible = sourceNodeIds.filter((id) => { + const node = nodesRef.current.find((item) => item.id === id); + return Boolean(node && !batchSourceRestriction(node)); + }); + const source = eligible.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => Boolean(node)); + if (!source) { + message.warning("当前选区没有可作为连接源的节点"); + return; + } + previewBatchConnection(eligible, null, undefined, undefined, { x: source.position.x + source.width, y: source.position.y + source.height / 2 }); + setSelectedConnectionId(null); + }, [message, nodesRef, previewBatchConnection, setSelectedConnectionId]); + + const finishBatchConnection = useCallback((clientX: number, clientY: number) => { + const batch = batchConnectionPreviewRef.current; + if (!batch) return false; + const target = getBatchConnectionDropTarget(clientX, clientY, batch.sourceNodeIds); + if (!target.nodeId) return false; + commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio); + clearBatchConnection(); + return true; + }, [clearBatchConnection, commitBatchConnection, getBatchConnectionDropTarget]); + + const openBatchConnectionCreateMenu = useCallback((clientX: number, clientY: number) => { + const batch = batchConnectionPreviewRef.current; + if (!batch) return false; + const position = screenToCanvas(clientX, clientY); + const request = buildBatchConnectionCreateRequest(batch.sourceNodeIds, nodesRef.current, position); + if (!request) { + clearBatchConnection(); + message.warning("当前选区没有可作为连接源的节点"); + return false; + } + const pending: PendingConnectionCreate = request; + pendingConnectionCreateRef.current = pending; + setPendingConnectionCreate(pending); + setMouseWorld(position); + clearBatchConnection(); + return true; + }, [clearBatchConnection, message, nodesRef, screenToCanvas]); + + const handleBatchConnectionTargetClick = useCallback((event: ReactPointerEvent | ReactMouseEvent) => { + if (!batchConnectionPreviewRef.current) return false; + const completed = finishBatchConnection(event.clientX, event.clientY); + if (!completed) message.warning("请点击目标节点的输入端"); + return true; + }, [finishBatchConnection, message]); + const finishConnection = useCallback((clientX: number, clientY: number) => { if (pendingConnectionCreateRef.current) return; const currentConnection = connectingParamsRef.current; @@ -279,6 +492,12 @@ export function useCanvasConnectionController({ const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => { event.preventDefault(); event.stopPropagation(); + if (batchConnectionPreviewRef.current && handleType === "target") { + commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio); + clearBatchConnection(); + return; + } + if (batchConnectionPreviewRef.current) clearBatchConnection(); connectingPointerIdRef.current = event.pointerId; connectingPointerStartRef.current = { x: event.clientX, y: event.clientY }; setMouseWorld(screenToCanvas(event.clientX, event.clientY)); @@ -286,10 +505,17 @@ export function useCanvasConnectionController({ setConnectionTargetNodeId(null); setConnectionTargetAnchorRatio(undefined); setSelectedConnectionId(null); - }, [screenToCanvas, setConnecting, setSelectedConnectionId]); + }, [clearBatchConnection, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]); useEffect(() => { const handlePointerMove = (event: PointerEvent) => { + const batch = batchConnectionPreviewRef.current; + if (batch && (batchConnectionPointerIdRef.current === null || batchConnectionPointerIdRef.current === event.pointerId)) { + const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds); + const mouseWorld = screenToCanvas(event.clientX, event.clientY); + previewBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio, mouseWorld); + return; + } const current = connectingParamsRef.current; if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return; const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current); @@ -298,6 +524,26 @@ export function useCanvasConnectionController({ setMouseWorld(screenToCanvas(event.clientX, event.clientY)); }; const handlePointerUp = (event: PointerEvent) => { + if (batchConnectionPointerIdRef.current === event.pointerId) { + const start = batchConnectionPointerStartRef.current; + const batch = batchConnectionPreviewRef.current; + if (batch && start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) { + const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds); + if (target.nodeId) { + commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio); + clearBatchConnection(); + } else if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY); + else clearBatchConnection(); + return; + } + const completed = finishBatchConnection(event.clientX, event.clientY); + if (!completed) { + const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch?.sourceNodeIds || []); + if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY); + else clearBatchConnection(); + } + return; + } if (connectingPointerIdRef.current !== event.pointerId) return; const start = connectingPointerStartRef.current; if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) { @@ -313,10 +559,15 @@ export function useCanvasConnectionController({ finishConnection(event.clientX, event.clientY); }; const handlePointerCancel = (event: PointerEvent) => { + if (batchConnectionPointerIdRef.current === event.pointerId) { + clearBatchConnection(); + return; + } if (connectingPointerIdRef.current === event.pointerId) setConnecting(null); }; const cancel = () => { if (connectingParamsRef.current) setConnecting(null); + if (batchConnectionPreviewRef.current) clearBatchConnection(); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); @@ -328,7 +579,7 @@ export function useCanvasConnectionController({ window.removeEventListener("pointercancel", handlePointerCancel); window.removeEventListener("blur", cancel); }; - }, [finishConnection, getConnectionDropTarget, screenToCanvas, setConnecting]); + }, [clearBatchConnection, commitBatchConnection, finishBatchConnection, finishConnection, getBatchConnectionDropTarget, getConnectionDropTarget, openBatchConnectionCreateMenu, previewBatchConnection, screenToCanvas, setConnecting]); return { cancelPendingConnectionCreate, @@ -339,6 +590,10 @@ export function useCanvasConnectionController({ createConnectedNode, getConnectionCreateDisabledReason, handleConnectStart, + handleBatchConnectionTargetClick, + batchConnectionPreview, + beginBatchConnectionMode, + startBatchConnection, mouseWorld, pendingConnectionCreate, setConnecting, diff --git a/web/src/pages/canvas/use-canvas-generation-retry.ts b/web/src/pages/canvas/use-canvas-generation-retry.ts index 634512081..59934190d 100644 --- a/web/src/pages/canvas/use-canvas-generation-retry.ts +++ b/web/src/pages/canvas/use-canvas-generation-retry.ts @@ -3,7 +3,7 @@ import { App } from "antd"; import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; -import { buildEmotionImageArtifacts, emotionGenerationSize } from "@/lib/canvas/canvas-emotion"; +import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion"; import { buildAudioGenerationMetadata, buildGenerationConfig, @@ -25,6 +25,7 @@ import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { navigateToSettings } from "@/lib/settings-navigation"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; @@ -288,18 +289,23 @@ export function useCanvasGenerationRetry({ if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片"); const artifacts = await buildEmotionImageArtifacts(sourceDataUrl, emotionEdit.faceBox, emotionSource.metadata.naturalWidth || 0, emotionSource.metadata.naturalHeight || 0); const emotionConfig = { ...generationConfig, size: emotionGenerationSize(artifacts.editRegion), quality: !generationConfig.quality || generationConfig.quality === "auto" ? "high" : generationConfig.quality }; + const imageProfile = modelCapabilityConfigFor(emotionConfig, emotionConfig.model).image!; + const editPlan = resolveEmotionEditPlan(imageProfile.references.maskSupported); const sourceReference = sourceNodeReferenceImages(emotionSource)[0]; if (!sourceReference) throw new Error("情绪编辑源图片不可用"); const editReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-edit-region`, name: "emotion-edit-region.png", type: "image/png", dataUrl: artifacts.sourceDataUrl }; const characterReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-character`, name: `${emotionEdit.characterName}-face.jpg`, type: "image/jpeg", dataUrl: artifacts.characterDataUrl }; - const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size }; + const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size, editMode: editPlan.mode }; + const providerPrompt = normalizeEmotionPromptForProvider(mediaPrompt); + const mask = emotionProviderMask(editPlan, { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl }); + if (editPlan.notice) message.info(editPlan.notice); const generationMetadata = { ...buildImageGenerationMetadata("edit", emotionConfig, 1, [sourceReference]), size: `${artifacts.imageWidth}x${artifacts.imageHeight}` }; setNodes((current) => current.map((item) => item.id === node.id ? { ...item, - metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit }, + metadata: { ...item.metadata, prompt: providerPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit }, } : item, ), @@ -308,12 +314,12 @@ export function useCanvasGenerationRetry({ projectId, nodeId: node.id, mode: "image", - prompt: mediaPrompt, + prompt: providerPrompt, config: emotionConfig, referenceImages: [editReference, characterReference], - mask: { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl }, + mask, signal: controller.signal, - metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata }, + metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata }, }); return; } diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index eeda55039..8b0ddce18 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -34,6 +34,7 @@ type UseCanvasKeyboardOptions = { focusMode: boolean; exitFocusMode: () => void; toggleFocusMode: () => void; + beginBatchConnection: () => void; }; export function useCanvasKeyboard({ @@ -68,6 +69,7 @@ export function useCanvasKeyboard({ focusMode, exitFocusMode, toggleFocusMode, + beginBatchConnection, }: UseCanvasKeyboardOptions) { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -106,6 +108,11 @@ export function useCanvasKeyboard({ if (isTextEditingTarget) return; const isCanvasControlTarget = Boolean(target?.closest("[data-canvas-no-zoom]")); if (isCanvasControlTarget && !(isModifierShortcut && !event.altKey && (key === "c" || key === "v"))) return; + if (event.altKey && !isModifierShortcut && key === "l") { + event.preventDefault(); + if (!event.repeat && selectedNodeIdsRef.current.size > 1) beginBatchConnection(); + return; + } if (event.key === "?" && !isModifierShortcut && !event.altKey) { event.preventDefault(); setShortcutRequestNonce((value) => value + 1); @@ -193,5 +200,5 @@ export function useCanvasKeyboard({ window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("paste", handlePaste, true); }; - }, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); + }, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 3e383197f..e66cfe3d5 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -23,12 +23,13 @@ import { runBackendCanvasGenerationTask, } from "@/lib/canvas/canvas-project-generation"; import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size"; -import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion"; +import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion"; import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS, buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame"; import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge"; import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment"; import { generationErrorMessage } from "@/lib/generation-error"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedVideo } from "@/services/api/video"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; @@ -655,9 +656,11 @@ export function useCanvasMediaTools({ const generationConfig = { ...baseConfig, count: "1", size: providerSize, quality: !baseConfig.quality || baseConfig.quality === "auto" ? "high" : baseConfig.quality }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); return; } if (resolveModelRequestConfig(generationConfig, generationConfig.model).interfaceType !== "openai-image") { - message.error("表情编辑需要支持蒙版的 OpenAI Images 渠道,当前渠道已拒绝整图重绘"); + message.error("表情编辑需要支持多参考图编辑的 OpenAI Images 渠道"); return; } + const imageProfile = modelCapabilityConfigFor(generationConfig, generationConfig.model).image!; + const editPlan = resolveEmotionEditPlan(imageProfile.references.maskSupported); const source = nodeReferenceImage(node); if (!source) return; const editReference = { @@ -676,24 +679,27 @@ export function useCanvasMediaTools({ const styleExecution = resolveImageEditStyle(node, payload.prompt, generationConfig); if (!styleExecution) return; const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution; + const providerPrompt = normalizeEmotionPromptForProvider(effectivePrompt); const generationMetadata = { ...buildImageGenerationMetadata("edit", generationConfig, 1, [source]), size: `${payload.imageWidth}x${payload.imageHeight}` }; - const emotionEdit = { sourceNodeId: node.id, characterName: payload.characterName, presetId: payload.presetId, intimacy: payload.intimacy, arousal: payload.arousal, label: payload.label, faceBox: payload.faceBox, editRegion: payload.editRegion, sourceWidth: payload.imageWidth, sourceHeight: payload.imageHeight, providerSize }; + const emotionEdit = { sourceNodeId: node.id, characterName: payload.characterName, presetId: payload.presetId, intimacy: payload.intimacy, arousal: payload.arousal, label: payload.label, faceBox: payload.faceBox, editRegion: payload.editRegion, sourceWidth: payload.imageWidth, sourceHeight: payload.imageHeight, providerSize, editMode: editPlan.mode }; + if (editPlan.notice) message.info(editPlan.notice); setEmotionNodeId(null); setRunningNodeId(childId); - setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: `${payload.characterName} · ${payload.label}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata, emotionEdit } }]); + setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: `${payload.characterName} · ${payload.label}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: providerPrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata, emotionEdit } }]); setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setSelectedConnectionId(null); setDialogNodeId(childId); const controller = startGenerationRequest(childId, node.id, childId); try { - const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [editReference, characterReference], mask: { id: `${node.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "emotion", emotion: emotionEdit, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) }); + const mask = emotionProviderMask(editPlan, { id: `${node.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: payload.maskDataUrl }); + const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: providerPrompt, config: generationConfig, referenceImages: [editReference, characterReference], mask, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: emotionEdit, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) }); const image = result.images?.[0]; if (!image?.dataUrl) throw new Error("后端任务没有返回图片"); const composited = await compositeEmotionImage(node.metadata.content, image.dataUrl, payload.editRegion, payload.faceBox); const uploaded = await uploadImage(composited); const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height); - setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata, emotionEdit } } : item)); + setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } } : item)); } catch (error) { if (isGenerationCanceled(error)) return; const details = generationErrorMessage(error); diff --git a/web/src/pages/canvas/use-canvas-selection-controller.ts b/web/src/pages/canvas/use-canvas-selection-controller.ts index e8c52f4fa..63d2e6f18 100644 --- a/web/src/pages/canvas/use-canvas-selection-controller.ts +++ b/web/src/pages/canvas/use-canvas-selection-controller.ts @@ -19,6 +19,7 @@ type UseCanvasSelectionControllerOptions = { onCanvasSelectionStart: () => void; onNodeInteractionStart: (selectionModifier: boolean) => void; onNodeClick: (node: CanvasNodeData) => void; + onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean; onDeselect: () => void; onSelectionBoxEnd?: () => void; }; @@ -57,6 +58,7 @@ export function useCanvasSelectionController({ onCanvasSelectionStart, onNodeInteractionStart, onNodeClick, + onBatchConnectionTarget, onDeselect, onSelectionBoxEnd, }: UseCanvasSelectionControllerOptions) { @@ -126,6 +128,7 @@ export function useCanvasSelectionController({ const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => { event.stopPropagation(); if (event.button !== 0) return; + if (onBatchConnectionTarget?.(event, nodeId)) return; setSelectedConnectionId(null); const currentNodes = nodesRef.current; const nextSelected = new Set(selectedNodeIdsRef.current); @@ -178,7 +181,7 @@ export function useCanvasSelectionController({ setIsNodeDragging(true); setAlignmentGuides({}); setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) }); - }, [historyPausedRef, nodesRef, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]); + }, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]); const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => { if (dragFrameRef.current) { diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 99cf6fda5..40c6b2c86 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -470,6 +470,7 @@ export function useCanvasUpload({ isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, + batchFailedCount: undefined, batchUsesReferenceImages: undefined, generationType: undefined, model: undefined, diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index d53be4a64..eb4b08463 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -5,6 +5,7 @@ import { createGenerationTask, waitForGenerationTask, type GenerationTask } from import { LOCAL_DREAMINA_WAIT_STOPPED_CODE, LocalDreaminaGenerationClientError, runLocalDreaminaGenerationTask, type LocalDreaminaGenerationInput, type LocalDreaminaGenerationTask } from "@/services/local-dreamina-generation"; import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreaminaTask, stripLocalDreaminaTaskPrefix } from "@/services/local-dreamina-task-projection"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit"; import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -85,6 +86,7 @@ export async function runBackendGenerationTask( dependencies: GenerationTaskDependencies = defaultDependencies, ) { throwIfAborted(signal); + assertClientPromptLimit(mode, prompt, config, metadata); if (isLocalDreaminaModel(config.model)) { return await runLocalDreaminaGeneration( { projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId }, @@ -99,6 +101,7 @@ export async function runBackendGenerationTask( export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }, dependencies: GenerationTaskDependencies = defaultDependencies) { const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1)); throwIfAborted(options.signal); + assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata); if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配"); if (isLocalDreaminaModel(options.config.model)) { return Promise.allSettled( @@ -305,6 +308,13 @@ function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); } +function assertClientPromptLimit(mode: BackendGenerationMode, prompt: string, config: AiConfig, metadata?: Record) { + if (mode !== "image" || metadata?.promptTemplateOperation) return; + const requestConfig = resolveModelRequestConfig(config, config.model); + const promptLimitError = grokImagePromptLimitError(prompt, requestConfig.interfaceType, requestConfig.model); + if (promptLimitError) throw new Error(promptLimitError); +} + async function prepareGenerationReferences({ referenceImages = [], referenceVideos = [], diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index e40d1ce42..43e9d5c0c 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -176,6 +176,7 @@ export type CanvasNodeMetadata = { isBatchRoot?: boolean; batchRootId?: string; batchChildIds?: string[]; + batchFailedCount?: number; batchUsesReferenceImages?: boolean; primaryImageId?: string; imageBatchExpanded?: boolean; @@ -316,6 +317,7 @@ export type CanvasNodeMetadata = { sourceHeight?: number; providerSize?: string; maskStorageKey?: string; + editMode?: "provider-mask" | "local-composite"; }; portraitTexture?: PortraitTextureSettings; }; diff --git a/web/test/canvas-batch-connection.test.ts b/web/test/canvas-batch-connection.test.ts new file mode 100644 index 000000000..31d39f5f1 --- /dev/null +++ b/web/test/canvas-batch-connection.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; + +import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection"; +import { defaultConfig } from "@/stores/use-config-store"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; + +const nodes: CanvasNodeData[] = [ + { id: "text-a", type: CanvasNodeType.Text, title: "文本 A", position: { x: 0, y: 0 }, width: 320, height: 180 }, + { id: "text-b", type: CanvasNodeType.Text, title: "文本 B", position: { x: 0, y: 220 }, width: 320, height: 180 }, + { id: "image-a", type: CanvasNodeType.Image, title: "图片 A", position: { x: 0, y: 440 }, width: 320, height: 180 }, + { id: "image-b", type: CanvasNodeType.Image, title: "图片 B", position: { x: 0, y: 660 }, width: 320, height: 180 }, + { id: "script", type: CanvasNodeType.Script, title: "分镜脚本", position: { x: 520, y: 0 }, width: 640, height: 520, metadata: { storyboard: { rows: [{ id: "row-1", shotNumber: 1, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" }] } } }, + { id: "config", type: CanvasNodeType.Config, title: "图片配置", position: { x: 520, y: 560 }, width: 360, height: 420, metadata: { generationMode: "image" } }, + { id: "frame", type: CanvasNodeType.Frame, title: "背板", position: { x: 0, y: 680 }, width: 500, height: 500 }, +]; + +const baseConfig = { ...defaultConfig }; + +describe("planBatchConnections", () => { + it("plans all legal source nodes and preserves the target handle", () => { + const result = planBatchConnections({ + sourceNodeIds: ["text-a", "text-b"], + targetNodeId: "script", + targetHandleId: "row:row-1", + targetAnchorRatio: 0.4, + nodes, + connections: [], + config: baseConfig, + }); + + expect(result.connected).toEqual(["text-a", "text-b"]); + expect(result.connections).toHaveLength(2); + expect(result.connections.every((connection) => connection.toNodeId === "script" && connection.toHandleId === "row:row-1")).toBe(true); + expect(result.skipped).toEqual([]); + }); + + it("skips duplicate connections without creating another edge", () => { + const existing: CanvasConnection[] = [{ id: "existing", fromNodeId: "text-a", toNodeId: "config" }]; + const result = planBatchConnections({ + sourceNodeIds: ["text-a", "text-b"], + targetNodeId: "config", + nodes, + connections: existing, + config: baseConfig, + }); + + expect(result.connected).toEqual(["text-b"]); + expect(result.duplicates).toEqual(["text-a"]); + expect(result.connections).toHaveLength(1); + }); + + it("reports sources that cannot be used by the aggregate connector", () => { + const result = planBatchConnections({ + sourceNodeIds: ["frame", "text-a"], + targetNodeId: "config", + nodes, + connections: [], + config: baseConfig, + }); + + expect(result.connected).toEqual(["text-a"]); + expect(result.skipped).toEqual([{ nodeId: "frame", reason: "背板不能作为连接源" }]); + }); + + it("keeps every legal graph edge when a newly created node exceeds provider reference capacity", () => { + const constrainedConfig = { + ...baseConfig, + channels: [{ + id: "grok", + name: "Grok", + baseUrl: "https://example.com", + apiKey: "test-key", + apiFormat: "openai" as const, + models: ["grok-imagine-image"], + modelCosts: [{ model: "grok-imagine-image", capability: "image" as const, billingMode: "fixed_request" as const, unitPriceMicrocredits: 1, protocol: "grok-image" as const }], + }], + models: ["grok::grok-imagine-image"], + imageModels: ["grok::grok-imagine-image"], + imageModel: "grok::grok-imagine-image", + model: "grok::grok-imagine-image", + }; + const result = planBatchConnections({ + sourceNodeIds: ["image-a", "image-b"], + targetNodeId: "new-image", + nodes: [...nodes, { id: "new-image", type: CanvasNodeType.Image, title: "新图片", position: { x: 1200, y: 0 }, width: 320, height: 180 }], + connections: [], + config: constrainedConfig, + allowCapacityOverflow: true, + }); + expect(result.connected).toEqual(["image-a", "image-b"]); + expect(result.connections).toHaveLength(2); + }); +}); + +describe("buildBatchConnectionCreateRequest", () => { + it("keeps unique eligible sources and uses the first one for the pending connection", () => { + const request = buildBatchConnectionCreateRequest(["frame", "text-a", "text-a", "text-b"], nodes, { x: 900, y: 240 }); + + expect(request).toEqual({ + batchSourceNodeIds: ["text-a", "text-b"], + connection: { nodeId: "text-a", handleType: "source" }, + position: { x: 900, y: 240 }, + }); + }); + + it("returns null when no selected node can be used as a batch source", () => { + expect(buildBatchConnectionCreateRequest(["frame", "script", "config"], nodes, { x: 0, y: 0 })).toBeNull(); + }); +}); + +describe("hasBatchConnectionCandidate", () => { + it("accepts a target when another selected source is legal even if the target itself is selected first", () => { + expect(hasBatchConnectionCandidate(["config", "text-a"], "config", nodes)).toBe(true); + }); +}); diff --git a/web/test/canvas-emotion-edit-plan.test.ts b/web/test/canvas-emotion-edit-plan.test.ts new file mode 100644 index 000000000..94afb3c2d --- /dev/null +++ b/web/test/canvas-emotion-edit-plan.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; + +import { buildEmotionPrompt, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "../src/lib/canvas/canvas-emotion"; + +const faceBox = { id: "face-1", x: 120, y: 80, width: 160, height: 180, source: "detected" as const }; +const editRegion = { x: 40, y: 20, width: 320, height: 360 }; + +describe("emotion edit provider plan", () => { + test("keeps provider mask when the selected image model supports it", () => { + expect(resolveEmotionEditPlan(true)).toEqual({ mode: "provider-mask", includeMask: true }); + }); + + test("falls back visibly to local compositing when the provider does not support masks", () => { + const plan = resolveEmotionEditPlan(false); + expect(plan).toEqual({ + mode: "local-composite", + includeMask: false, + notice: "当前渠道不支持蒙版,使用脸部裁切与本地羽化融合", + }); + expect(emotionProviderMask(plan, { dataUrl: "data:image/png;base64,bWFzaw==" })).toBeUndefined(); + }); + + test("does not promise a provider mask in the shared emotion prompt", () => { + const prompt = buildEmotionPrompt( + { presetId: "emotion-0-0", intimacy: 0, arousal: 0, characterName: "角色1", faceBox }, + editRegion, + ); + + expect(prompt).not.toContain("透明蒙版"); + expect(prompt).not.toContain("白色蒙版"); + expect(prompt).toContain("目标人脸框"); + }); + + test("removes legacy mask promises before retrying an existing emotion node", () => { + const legacyPrompt = [ + "只允许修改目标框及其透明蒙版对应的这一张脸。", + "输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。", + ].join("\n"); + + const prompt = normalizeEmotionPromptForProvider(legacyPrompt); + expect(prompt).not.toContain("透明蒙版"); + expect(prompt).not.toContain("白色蒙版"); + expect(prompt).toContain("目标人脸框及其邻近表情区域"); + }); +}); diff --git a/web/test/canvas-image-batch-retry.test.ts b/web/test/canvas-image-batch-retry.test.ts new file mode 100644 index 000000000..eff99e704 --- /dev/null +++ b/web/test/canvas-image-batch-retry.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; + +import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "../src/lib/canvas/canvas-image-batch-retry"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas"; + +function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial> = {}): CanvasNodeData { + return { + id, + type: CanvasNodeType.Image, + title: id, + position: { x: 0, y: 0 }, + width: 320, + height: 240, + metadata: { status, ...metadata }, + }; +} + +describe("canvas image batch retry", () => { + test("只按批次顺序返回属于当前根节点的失败图片", () => { + const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-2", "success", "failed-1", "loading", "foreign"] }); + const nodes = [ + root, + imageNode("failed-1", "error", { batchRootId: root.id }), + imageNode("failed-2", "error", { batchRootId: root.id }), + imageNode("success", "success", { batchRootId: root.id, content: "image:success" }), + imageNode("loading", "loading", { batchRootId: root.id }), + imageNode("foreign", "error", { batchRootId: "another-root" }), + ]; + + expect(failedImageBatchChildren(root, nodes).map((node) => node.id)).toEqual(["failed-2", "failed-1"]); + }); + + test("任一子图成功后恢复根节点主图,同时保留其他失败子图", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["failed", "success"], errorDetails: "旧错误" }); + const failed = imageNode("failed", "error", { batchRootId: root.id, errorDetails: "上游失败" }); + const success = imageNode("success", "success", { + batchRootId: root.id, + content: "image:success", + storageKey: "resource:success", + mimeType: "image/png", + naturalWidth: 1024, + naturalHeight: 1024, + }); + + const next = reconcileImageBatchRoot(root, [root, failed, success]); + + expect(next.metadata).toMatchObject({ status: "success", content: "image:success", storageKey: "resource:success", primaryImageId: "success", batchFailedCount: 1 }); + expect(next.metadata.errorDetails).toBeUndefined(); + expect(failed.metadata.status).toBe("error"); + }); + + test("全部子图失败时把代表错误同步回根节点", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["failed-1", "failed-2"] }); + const failed = imageNode("failed-1", "error", { + batchRootId: root.id, + errorDetails: "No available compatible accounts", + generationErrorCode: "upstream_unavailable", + }); + + const next = reconcileImageBatchRoot(root, [root, failed, imageNode("failed-2", "error", { batchRootId: root.id, errorDetails: "另一错误" })]); + + expect(next.metadata).toMatchObject({ status: "error", errorDetails: "No available compatible accounts", generationErrorCode: "upstream_unavailable", batchFailedCount: 2 }); + expect(next.metadata.content).toBeUndefined(); + expect(next.metadata.primaryImageId).toBeUndefined(); + }); + + test("开始批量重试时根节点和全部失败子图同步进入生成中", () => { + const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-1", "success", "failed-2"], batchFailedCount: 2, errorDetails: "全部失败" }); + const failed1 = imageNode("failed-1", "error", { batchRootId: root.id, errorDetails: "失败 1" }); + const success = imageNode("success", "success", { batchRootId: root.id, content: "image:success" }); + const failed2 = imageNode("failed-2", "error", { batchRootId: root.id, errorDetails: "失败 2" }); + + const next = markImageBatchRetrying(root.id, [failed1.id, failed2.id], [root, failed1, success, failed2]); + + expect(next.find((node) => node.id === root.id)?.metadata).toMatchObject({ status: "loading", batchFailedCount: 2 }); + expect(next.find((node) => node.id === failed1.id)?.metadata?.status).toBe("loading"); + expect(next.find((node) => node.id === failed2.id)?.metadata?.status).toBe("loading"); + expect(next.find((node) => node.id === success.id)?.metadata).toMatchObject({ status: "success", content: "image:success" }); + }); + + test("未提交请求的失败子图从预备加载状态恢复原错误", () => { + const original = imageNode("failed", "error", { batchRootId: "root", errorDetails: "原始失败" }); + const loading = imageNode("failed", "loading", { batchRootId: "root" }); + + expect(restoreUnsubmittedImageBatchChild(loading, original).metadata).toMatchObject({ status: "error", errorDetails: "原始失败" }); + expect(restoreUnsubmittedImageBatchChild(imageNode("failed", "success", { content: "image:success" }), original).metadata?.status).toBe("success"); + }); +}); diff --git a/web/test/canvas-storyboard-references.test.ts b/web/test/canvas-storyboard-references.test.ts new file mode 100644 index 000000000..8354b42ab --- /dev/null +++ b/web/test/canvas-storyboard-references.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { expandStoryboardTextMentions } from "../src/lib/canvas/canvas-project-domain"; +import type { CanvasResourceReference } from "../src/lib/canvas/canvas-resource-references"; + +describe("expandStoryboardTextMentions", () => { + test("展开引用控件保存的 canonical node token", () => { + const references: CanvasResourceReference[] = [{ + id: "script-node", + nodeId: "script-node", + kind: "text", + label: "文本1", + title: "完整剧本", + text: "第一场:主角推开仓库大门。", + active: true, + }]; + + expect(expandStoryboardTextMentions("@[node:script-node] 拆成 8 个镜头", references)).toBe( + "【项目设定:完整剧本】\n第一场:主角推开仓库大门。 拆成 8 个镜头", + ); + }); +}); diff --git a/web/test/grok-image-prompt-limit.test.ts b/web/test/grok-image-prompt-limit.test.ts new file mode 100644 index 000000000..696c19c77 --- /dev/null +++ b/web/test/grok-image-prompt-limit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; + +import { grokImagePromptLimitError } from "../src/lib/grok-image-prompt-limit"; + +describe("grokImagePromptLimitError", () => { + test("按完整提示词的 UTF-8 字节数阻止超限请求且不改写原文", () => { + const prompt = "中".repeat(4001); + + const error = grokImagePromptLimitError(prompt, "grok-image", "grok-imagine-image-quality"); + + expect(error).toContain("12003 UTF-8 字节"); + expect(error).toContain("8000"); + expect(error).toContain("系统不会自动删改"); + expect(error).toContain("连线文本"); + expect(prompt).toBe("中".repeat(4001)); + }); + + test("不限制其他图片协议、Grok Lite 或未超限的 Quality 提示词", () => { + expect(grokImagePromptLimitError("中".repeat(4001), "openai-image", "grok-imagine-image-quality")).toBeNull(); + expect(grokImagePromptLimitError("中".repeat(4001), "grok-image", "grok-imagine-image")).toBeNull(); + expect(grokImagePromptLimitError("中".repeat(2000), "grok-image", "grok-imagine-image-quality")).toBeNull(); + }); +}); diff --git a/web/test/image-resolution-tiers.test.ts b/web/test/image-resolution-tiers.test.ts new file mode 100644 index 000000000..b291171da --- /dev/null +++ b/web/test/image-resolution-tiers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildImageResolutionOptions, + formatImageResolutionSize, + imageResolutionChoices, + imageResolutionOption, + imageSizeForResolution, + supportsImageResolutionPresets, +} from "../src/lib/image-resolution-tiers"; +import { defaultImageCapabilityConfig } from "../src/lib/model-capabilities"; + +const sizes = [ + "1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824", + "2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752", + "2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840", +]; + +describe("image resolution tiers", () => { + test("将 Xiaobaishu 的精确尺寸整理为 1K、2K、4K 各十种比例", () => { + const options = buildImageResolutionOptions(sizes); + + expect(options.filter((item) => item.tier === "1k")).toHaveLength(10); + expect(options.filter((item) => item.tier === "2k")).toHaveLength(10); + expect(options.filter((item) => item.tier === "4k")).toHaveLength(10); + expect(imageResolutionOption(options, "1536x2752")).toMatchObject({ tier: "2k", ratio: "9:16" }); + }); + + test("切换档位时按相同比例还原精确像素并显示比例与档位", () => { + const options = buildImageResolutionOptions(sizes); + + expect(imageSizeForResolution(options, "4k", "9:16")).toBe("2160x3840"); + expect(formatImageResolutionSize("1536x2752", options)).toBe("9:16 · 2K"); + }); + + test("保留自动尺寸并在摘要中显示中文标签", () => { + const values = ["auto", ...sizes]; + + expect(imageResolutionChoices(values)).toEqual(["auto", "1k", "2k", "4k"]); + expect(buildImageResolutionOptions(values)).toHaveLength(30); + expect(formatImageResolutionSize("auto", [])).toBe("自动"); + }); + + test("允许自定义尺寸时仍保留自动与分辨率预设", () => { + expect(supportsImageResolutionPresets({ parameter: "size", values: ["auto", ...sizes], allowCustom: true })).toBe(true); + }); + + test("默认图片能力为 OpenAI 兼容模型提供完整 1K、2K、4K 档位", () => { + const profile = defaultImageCapabilityConfig("openai-image", "gpt-image-2-4K"); + + expect(supportsImageResolutionPresets(profile.size)).toBe(true); + expect(imageResolutionChoices(profile.size.values)).toEqual(["auto", "1k", "2k", "4k"]); + expect(buildImageResolutionOptions(profile.size.values)).toHaveLength(30); + }); + + test("识别适合全景图的 2:1 自定义尺寸", () => { + const options = buildImageResolutionOptions(["1440x720", "2048x1024", "3840x1920"]); + + expect(options.map((item) => item.tier)).toEqual(["1k", "2k", "4k"]); + expect(imageResolutionOption(options, "3840x1920")).toMatchObject({ tier: "4k", ratio: "2:1" }); + }); +});