package application import ( "context" "encoding/json" "fmt" "math" "net/url" "strings" "time" "img_infinite_canvas/internal/domain/design" ) type generatorTaskMetadata struct { GeneratorName string `json:"generator_name"` OriginalInputArgs design.GeneratorTaskInputArgs `json:"original_input_args"` Price int64 `json:"price"` } const ( removeBackgroundGeneratorName = "removeBG" imageVectorizerGeneratorName = "gpt-5.5-vectorizer" moveObjectGeneratorName = "move-object" ) func (s *DesignService) GeneratorTask(ctx context.Context, projectID string, taskID string) (design.GeneratorTask, error) { taskID = strings.TrimSpace(taskID) if taskID == "" { return design.GeneratorTask{}, fmt.Errorf("%w: task_id is required", design.ErrInvalidInput) } if task, ok := s.standaloneGeneratorTask(taskID); ok { return task, nil } project, err := s.generatorTaskProject(ctx, strings.TrimSpace(projectID), taskID) if err != nil { return design.GeneratorTask{}, err } return generatorTaskFromProject(project, taskID), nil } func (s *DesignService) RecognizeObject(ctx context.Context, req design.ObjectRecognitionRequest) (design.ObjectRecognition, error) { req.BoundingBox = normalizeBox(req.BoundingBox) if req.BoundingBox.Width <= 0 || req.BoundingBox.Height <= 0 { return design.ObjectRecognition{}, fmt.Errorf("%w: bounding_box is required", design.ErrInvalidInput) } if strings.TrimSpace(req.ImageBase64) == "" && strings.TrimSpace(req.ImageURL) == "" { return design.ObjectRecognition{}, fmt.Errorf("%w: image_base64 or image_url is required", design.ErrInvalidInput) } if s.objectRecognizer != nil { result, err := s.objectRecognizer.RecognizeObject(ctx, req) if err == nil && strings.TrimSpace(result.Label) != "" { result.BBox = normalizeBoxWithFallback(result.BBox, req.BoundingBox) result.Confidence = clampUnit(result.Confidence) return result, nil } } label := "选中对象" if strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Lang)), "en") { label = "selected object" } return design.ObjectRecognition{ Label: label, Confidence: 0.5, BBox: req.BoundingBox, }, nil } func (s *DesignService) SubmitGeneratorTask(ctx context.Context, req design.GeneratorTaskSubmitRequest) (design.GeneratorTask, error) { generatorName := normalizeGeneratorTaskName(req.GeneratorName) if generatorName == "" { generatorName = moveObjectGeneratorName } if normalizeGeneratorTaskName(generatorName) != moveObjectGeneratorName { return design.GeneratorTask{}, fmt.Errorf("%w: unsupported generator_name %s", design.ErrInvalidInput, req.GeneratorName) } input := normalizeMoveObjectInput(req.InputArgs) if strings.TrimSpace(input.ImageURL) == "" { return design.GeneratorTask{}, fmt.Errorf("%w: input_args.image_url is required", design.ErrInvalidInput) } if input.SrcBox == nil || input.SrcBox.Width <= 0 || input.SrcBox.Height <= 0 { return design.GeneratorTask{}, fmt.Errorf("%w: input_args.src_box is required", design.ErrInvalidInput) } if input.DstBox == nil || input.DstBox.Width <= 0 || input.DstBox.Height <= 0 { return design.GeneratorTask{}, fmt.Errorf("%w: input_args.dst_box is required", design.ErrInvalidInput) } taskID := newID() price := int64(5) task := design.GeneratorTask{ GeneratorTaskID: taskID, GeneratorName: moveObjectGeneratorName, Status: "submitted", OriginalInputArgs: input, QueueInfo: generatorTaskQueueInfo(moveObjectGeneratorName, input, price), } s.storeStandaloneGeneratorTask(task) go s.completeStandaloneMoveObjectTask(taskID, strings.TrimSpace(req.ProjectID), input) return task, nil } func (s *DesignService) completeStandaloneMoveObjectTask(taskID string, projectID string, input design.GeneratorTaskInputArgs) { ctx, cancel := context.WithTimeout(context.Background(), generationBudget) defer cancel() prompt := standaloneMoveObjectPrompt(input) width, height := design.GeneratedImageDimensions(design.DefaultGeneratedImageSize) project := design.Project{ ID: projectID, Title: "Move object", Brief: "Move selected object inside the source image.", Status: design.StatusExploring, UpdatedAt: s.now(), Nodes: []design.Node{ { ID: "source", Type: design.NodeTypeImage, Title: "Source image", Content: strings.TrimSpace(input.ImageURL), Width: width, Height: height, Status: "success", }, }, } plan, err := s.agent.Plan(ctx, design.AgentRequest{ Project: project, Prompt: prompt, Mode: "image-action:move-object", ImageSize: design.DefaultGeneratedImageSize, Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: prompt, TextSource: "input"}, {Type: "image", ImageURL: strings.TrimSpace(input.ImageURL), ImageWidth: int64(width), ImageHeight: int64(height)}, }, }, }, TaskPlan: design.AgentTaskPlan{ Intent: "image", ImageCount: 1, ImageTasks: []design.AgentImageTask{ {Title: "Moved Object", Brief: prompt}, }, }, }) if err != nil { s.markStandaloneGeneratorTaskFailed(taskID, err) return } nodes, err := s.persistNodeImageAssets(ctx, standaloneArtifactProjectID(projectID, taskID), plan.GeneratedNodes) if err != nil { s.markStandaloneGeneratorTaskFailed(taskID, err) return } artifacts := generatorTaskArtifactsFromNodes(taskID, input.Prompt, nodes) if len(artifacts) == 0 { s.markStandaloneGeneratorTaskFailed(taskID, fmt.Errorf("move-object generation returned no image")) return } s.completeStandaloneGeneratorTask(taskID, artifacts) } func standaloneArtifactProjectID(projectID string, taskID string) string { projectID = strings.TrimSpace(projectID) if projectID != "" { return projectID } return taskID } func standaloneMoveObjectPrompt(input design.GeneratorTaskInputArgs) string { var builder strings.Builder builder.WriteString("Use this exact source image as the base image: ") builder.WriteString(strings.TrimSpace(input.ImageURL)) builder.WriteString("\nAction: move the selected object from src_box to dst_box. Keep the object's identity, texture, lighting, and scale believable. Remove the object from the original source box and inpaint that original area naturally. Place the object inside the destination box. Preserve all other pixels and composition as much as possible.") if input.SrcBox != nil { builder.WriteString("\nsrc_box: ") builder.WriteString(boxString(*input.SrcBox)) } if input.DstBox != nil { builder.WriteString("\ndst_box: ") builder.WriteString(boxString(*input.DstBox)) } if prompt := strings.TrimSpace(input.Prompt); prompt != "" { builder.WriteString("\nAdditional user instruction: ") builder.WriteString(prompt) } return builder.String() } func boxString(box design.NormalizedBox) string { return fmt.Sprintf(`{"x":%.6f,"y":%.6f,"width":%.6f,"height":%.6f}`, box.X, box.Y, box.Width, box.Height) } func normalizeMoveObjectInput(input design.GeneratorTaskInputArgs) design.GeneratorTaskInputArgs { if strings.TrimSpace(input.ImageURL) == "" && len(input.Image) > 0 { input.ImageURL = strings.TrimSpace(input.Image[0]) } if len(input.Image) == 0 && strings.TrimSpace(input.ImageURL) != "" { input.Image = []string{strings.TrimSpace(input.ImageURL)} } if input.SrcBox != nil { box := normalizeBox(*input.SrcBox) input.SrcBox = &box } if input.DstBox != nil { box := normalizeBox(*input.DstBox) input.DstBox = &box } input.ImageURL = strings.TrimSpace(input.ImageURL) input.Prompt = strings.TrimSpace(input.Prompt) input.Resolution = strings.TrimSpace(input.Resolution) input.AspectRatio = strings.TrimSpace(input.AspectRatio) return input } func normalizeBox(box design.NormalizedBox) design.NormalizedBox { width := clampUnit(box.Width) height := clampUnit(box.Height) return design.NormalizedBox{ X: clampFloat(box.X, 0, math.Max(0, 1-width)), Y: clampFloat(box.Y, 0, math.Max(0, 1-height)), Width: width, Height: height, } } func clampUnit(value float64) float64 { return clampFloat(value, 0, 1) } func normalizeBoxWithFallback(box design.NormalizedBox, fallback design.NormalizedBox) design.NormalizedBox { box = normalizeBox(box) if box.Width <= 0 || box.Height <= 0 { return normalizeBox(fallback) } return box } func (s *DesignService) standaloneGeneratorTask(taskID string) (design.GeneratorTask, bool) { s.generatorTaskMu.RLock() defer s.generatorTaskMu.RUnlock() task, ok := s.generatorTasks[taskID] return task, ok } func (s *DesignService) storeStandaloneGeneratorTask(task design.GeneratorTask) { s.generatorTaskMu.Lock() defer s.generatorTaskMu.Unlock() s.generatorTasks[task.GeneratorTaskID] = task } func (s *DesignService) completeStandaloneGeneratorTask(taskID string, artifacts []design.GeneratorTaskArtifact) { s.generatorTaskMu.Lock() defer s.generatorTaskMu.Unlock() task, ok := s.generatorTasks[taskID] if !ok { return } task.Status = "completed" task.Artifacts = artifacts s.generatorTasks[taskID] = task } func (s *DesignService) markStandaloneGeneratorTaskFailed(taskID string, failure error) { s.generatorTaskMu.Lock() defer s.generatorTaskMu.Unlock() task, ok := s.generatorTasks[taskID] if !ok { return } task.Status = "failed" s.generatorTasks[taskID] = task } func generatorTaskArtifactsFromNodes(taskID string, prompt string, nodes []design.Node) []design.GeneratorTaskArtifact { artifacts := make([]design.GeneratorTaskArtifact, 0, len(nodes)) for _, node := range nodes { if node.Type != design.NodeTypeImage || !isGeneratedImageContent(node.Content) { continue } artifacts = append(artifacts, design.GeneratorTaskArtifact{ Type: "image", Content: strings.TrimSpace(node.Content), Metadata: design.GeneratorTaskArtifactMetadata{ ArtifactID: node.ID, Format: generatorArtifactFormat("", node.Content), Height: int64(node.Height), Label: strings.TrimSpace(prompt), NodeID: node.ID, NodeName: node.Title, Width: int64(node.Width), }, }) } return artifacts } func (s *DesignService) generatorTaskProject(ctx context.Context, projectID string, taskID string) (design.Project, error) { if projectID != "" { return s.repo.Get(ctx, projectID) } summaries, err := s.repo.List(ctx) if err != nil { return design.Project{}, err } for _, summary := range summaries { project, err := s.repo.Get(ctx, summary.ID) if err != nil { continue } if project.LastThreadID == taskID || projectHasThread(project, taskID) { return project, nil } } return design.Project{}, design.ErrNotFound } func projectHasThread(project design.Project, taskID string) bool { for _, message := range project.Messages { if message.ThreadID == taskID { return true } } return false } func generatorTaskFromProject(project design.Project, taskID string) design.GeneratorTask { metadata := generatorTaskMetadataFromMessages(project.Messages, taskID) input := metadata.OriginalInputArgs generatorName := normalizeGeneratorTaskName(metadata.GeneratorName) if generatorName == "" { generatorName = normalizeGeneratorTaskName(generatorTaskGeneratorNameFromMessages(project.Messages, taskID)) } if generatorName == "" { generatorName = "gpt-image-2" } if strings.TrimSpace(input.Prompt) == "" && generatorName != removeBackgroundGeneratorName && generatorName != imageVectorizerGeneratorName { input.Prompt = generatorTaskPromptFromMessages(project.Messages, taskID) } if strings.TrimSpace(input.Resolution) == "" && generatorName != imageVectorizerGeneratorName { input.Resolution = "1K" } if strings.TrimSpace(input.AspectRatio) == "" && generatorName != imageVectorizerGeneratorName { input.AspectRatio = generatorTaskAspectRatioFromArtifacts(project.Messages, taskID) } if strings.TrimSpace(input.AspectRatio) == "" && generatorName != imageVectorizerGeneratorName { input.AspectRatio = "1:1" } price := metadata.Price if price <= 0 { price = generatorTaskPrice(input, generatorName) } return design.GeneratorTask{ GeneratorTaskID: taskID, GeneratorName: generatorName, Status: generatorTaskStatus(project, taskID), OriginalInputArgs: input, QueueInfo: generatorTaskQueueInfo(generatorName, input, price), Artifacts: generatorTaskArtifacts(project.Messages, taskID), Project: project, } } func generatorTaskMetadataFromMessages(messages []design.Message, taskID string) generatorTaskMetadata { for _, message := range messages { if message.ThreadID != taskID || message.Name != "generator_task" { continue } var metadata generatorTaskMetadata if err := json.Unmarshal([]byte(message.Content), &metadata); err == nil { return metadata } } return generatorTaskMetadata{} } func generatorTaskStatus(project design.Project, taskID string) string { messages := project.Messages status := "submitted" completed := false for _, message := range messages { if message.ThreadID != taskID { continue } if message.Role == "error" || message.Status == "failed" { return "failed" } switch message.Status { case "success", "cancelled": completed = true case "running", "streaming", "submitted": status = "submitted" } } if generatorTaskHasActiveCanvasWork(project, taskID) { return "submitted" } if project.Status == design.StatusFailed { return "failed" } if project.Status == design.StatusExploring { return status } if len(generatorTaskArtifacts(messages, taskID)) > 0 || completed { return "completed" } return status } func generatorTaskHasActiveCanvasWork(project design.Project, taskID string) bool { taskID = strings.TrimSpace(taskID) lastThreadID := strings.TrimSpace(project.LastThreadID) if taskID != "" && lastThreadID != "" && taskID != lastThreadID { return false } for _, node := range project.Nodes { switch strings.ToLower(strings.TrimSpace(node.Status)) { case "generating", "uploading", "text-extracting", "mockup-modeling": return true } } return false } func generatorTaskPromptFromMessages(messages []design.Message, taskID string) string { for _, message := range messages { if message.ThreadID == taskID && message.Role == "user" && strings.TrimSpace(message.Content) != "" { return strings.TrimSpace(message.Content) } } for _, artifact := range generatorTaskArtifacts(messages, taskID) { if strings.TrimSpace(artifact.Metadata.Label) != "" { return artifact.Metadata.Label } } return "" } func generatorTaskGeneratorNameFromMessages(messages []design.Message, taskID string) string { for _, message := range messages { if message.ThreadID != taskID { continue } switch { case message.ToolHint == "generate_image_gpt_image_2" || strings.Contains(strings.ToLower(message.Title), "gpt image"): return "gpt-image-2" case strings.Contains(strings.ToLower(message.Title), "rmbg"): return removeBackgroundGeneratorName case message.Name == "mockup_model": return "mockup/model" case message.Name == "text_extraction": return "gpt-5.4-mini" } } return "" } func generatorTaskAspectRatioFromArtifacts(messages []design.Message, taskID string) string { artifacts := generatorTaskArtifacts(messages, taskID) if len(artifacts) == 0 { return "" } width := artifacts[0].Metadata.Width height := artifacts[0].Metadata.Height if width <= 0 || height <= 0 { return "" } return aspectRatioLabel(width, height) } func generatorTaskArtifacts(messages []design.Message, taskID string) []design.GeneratorTaskArtifact { var artifacts []design.GeneratorTaskArtifact for _, message := range messages { if message.ThreadID != taskID || !isToolArtifactsMessage(message) { continue } artifacts = append(artifacts, parseGeneratorArtifacts(message.Content)...) } return artifacts } func isToolArtifactsMessage(message design.Message) bool { return message.Type == "tool_use" || message.Name == "generate_media" || message.Title == "generate_media" } func parseGeneratorArtifacts(content string) []design.GeneratorTaskArtifact { var events []struct { EventData struct { Artifact []struct { ArtifactID string `json:"artifact_id"` Description string `json:"description"` Format string `json:"format"` Height int64 `json:"height"` ImageName string `json:"image_name"` ImageURL string `json:"image_url"` PromptText string `json:"prompt_text"` Width int64 `json:"width"` } `json:"artifact"` } `json:"event_data"` } if err := json.Unmarshal([]byte(content), &events); err != nil { return nil } var artifacts []design.GeneratorTaskArtifact for _, event := range events { for _, item := range event.EventData.Artifact { if strings.TrimSpace(item.ImageURL) == "" { continue } artifacts = append(artifacts, design.GeneratorTaskArtifact{ Type: "image", Content: item.ImageURL, Metadata: design.GeneratorTaskArtifactMetadata{ ArtifactID: item.ArtifactID, Format: generatorArtifactFormat(item.Format, item.ImageURL), Height: item.Height, Label: item.PromptText, NodeID: item.ArtifactID, NodeName: item.ImageName, Width: item.Width, }, }) } } return artifacts } func generatorArtifactFormat(format string, imageURL string) string { format = strings.Trim(strings.ToLower(strings.TrimSpace(format)), ".") if format != "" { return format } path := strings.ToLower(strings.TrimSpace(imageURL)) if parsed, err := url.Parse(path); err == nil { path = parsed.Path } switch { case strings.HasSuffix(path, ".svg"): return "svg" case strings.HasSuffix(path, ".png"): return "png" case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"): return "jpg" case strings.HasSuffix(path, ".webp"): return "webp" default: return "" } } func generatorTaskQueueInfo(generatorName string, input design.GeneratorTaskInputArgs, price int64) design.GeneratorTaskQueueInfo { return design.GeneratorTaskQueueInfo{ InQueue: false, Position: 0, TotalQueueCount: 0, EstimatedEndTime: "", RemainingTimeSeconds: 0, DisableUnlimitedPrice: design.GeneratorTaskDisableUnlimitedPrice{ Balance: 70, Price: price, DisableUnlimited: true, PriceDetail: design.GeneratorTaskPriceDetail{ BasePrice: price, ExtInfoForUnitCount: "no unit count, by default 1", GeneratorName: generatorName, InputArgs: input, SearchKey: input.Resolution, TimeVariantPeriod: "normal", TotalPrice: price, UnitCount: 1, UnitName: "image_count", UnitPrice: price, }, }, } } func generatorTaskPrice(input design.GeneratorTaskInputArgs, generatorName string) int64 { switch normalizeGeneratorTaskName(generatorName) { case removeBackgroundGeneratorName: return 4 case imageVectorizerGeneratorName: return 14 case moveObjectGeneratorName: return 5 } switch strings.ToUpper(strings.TrimSpace(input.Resolution)) { case "2K": return 28 case "4K": return 56 default: return 14 } } func generatorTaskMetadataMessage(threadID string, generatorName string, input design.GeneratorTaskInputArgs, price int64, createdAt time.Time) design.Message { content, _ := json.Marshal(generatorTaskMetadata{ GeneratorName: generatorName, OriginalInputArgs: input, Price: price, }) return design.Message{ ID: newID(), Role: "tool", Type: "generator_task", Title: "generator_task", Content: string(content), ThreadID: threadID, ActionID: threadID, StepID: newID(), Name: "generator_task", ToolHint: "canvas_action", Status: "submitted", CreatedAt: createdAt, } } func generatorTaskInputArgsForNodeAction(target design.Node, req design.NodeActionRequest) design.GeneratorTaskInputArgs { if normalizeNodeAction(req.Action) == "vectorize" { return design.GeneratorTaskInputArgs{ Image: []string{target.Content}, } } imageSize := strings.TrimSpace(req.ImageSize) if imageSize == "" { imageSize = design.DefaultGeneratedImageSize } width, height := design.GeneratedImageDimensions(imageSize) input := design.GeneratorTaskInputArgs{ AspectRatio: aspectRatioLabel(int64(math.Round(width)), int64(math.Round(height))), Image: []string{target.Content}, ImageURL: target.Content, Prompt: generatorTaskPromptForNodeAction(req), Resolution: resolutionLabel(imageSize), } if normalizeNodeAction(req.Action) == "move-object" { input.SrcBox, input.DstBox = moveObjectBoxesFromSelection(req.Selection) } return input } func generatorTaskNameForNodeAction(req design.NodeActionRequest) string { switch normalizeNodeAction(req.Action) { case "remove-background": return removeBackgroundGeneratorName case "edit-text": return "gpt-5.4-mini" case "move-object": return moveObjectGeneratorName case "vectorize": return imageVectorizerGeneratorName default: return "gpt-image-2" } } func moveObjectBoxesFromSelection(selection string) (*design.NormalizedBox, *design.NormalizedBox) { selection = strings.TrimSpace(selection) if selection == "" { return nil, nil } type box = design.NormalizedBox var payload struct { SrcBox *box `json:"src_box"` DstBox *box `json:"dst_box"` Src *box `json:"src"` Dst *box `json:"dst"` X float64 Y float64 Width float64 Height float64 } if err := json.Unmarshal([]byte(selection), &payload); err != nil { return nil, nil } src := payload.SrcBox if src == nil { src = payload.Src } dst := payload.DstBox if dst == nil { dst = payload.Dst } if src == nil && payload.Width > 0 && payload.Height > 0 { value := normalizeBox(design.NormalizedBox{ X: payload.X, Y: payload.Y, Width: payload.Width, Height: payload.Height, }) src = &value } if src != nil { value := normalizeBox(*src) src = &value } if dst != nil { value := normalizeBox(*dst) dst = &value } return src, dst } func generatorTaskPromptForNodeAction(req design.NodeActionRequest) string { if normalizeNodeAction(req.Action) == "remove-background" { return "" } prompt := strings.TrimSpace(req.Prompt) if prompt != "" { return prompt } return "" } func normalizeGeneratorTaskName(generatorName string) string { name := strings.TrimSpace(generatorName) if name == "" { return "" } normalized := strings.ToLower(name) if normalized == "removebg" || strings.Contains(normalized, "rmbg") { return removeBackgroundGeneratorName } if normalized == "move-object" || normalized == "moveobject" { return moveObjectGeneratorName } return name } func resolutionLabel(imageSize string) string { parts := strings.Split(strings.ToLower(strings.TrimSpace(imageSize)), "x") if len(parts) != 2 { return "1K" } var maxSide int for _, part := range parts { var value int _, _ = fmt.Sscanf(part, "%d", &value) if value > maxSide { maxSide = value } } switch { case maxSide >= 3840: return "4K" case maxSide >= 1920: return "2K" default: return "1K" } } func aspectRatioLabel(width int64, height int64) string { if width <= 0 || height <= 0 { return "1:1" } divisor := gcdInt64(width, height) return fmt.Sprintf("%d:%d", width/divisor, height/divisor) } func gcdInt64(a int64, b int64) int64 { if a < 0 { a = -a } if b < 0 { b = -b } for b != 0 { a, b = b, a%b } if a == 0 { return 1 } return a }