44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
784 lines
23 KiB
Go
784 lines
23 KiB
Go
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
|
|
}
|