Files

784 lines
23 KiB
Go
Raw Permalink Normal View History

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
}