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>
920 lines
30 KiB
Go
920 lines
30 KiB
Go
package application
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
)
|
|
|
|
var mockupBlobStore sync.Map
|
|
|
|
const mockupModelVersion = 4
|
|
|
|
type mockupBlob struct {
|
|
Data []byte
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
type mockupModelComputation struct {
|
|
Placement design.MockupPlacement
|
|
Surface design.MockupSurface
|
|
Model design.MockupModel
|
|
SourceContent string
|
|
}
|
|
|
|
func (s *DesignService) PrepareMockupSurface(ctx context.Context, projectID string, nodeID string) (design.MockupSurfaceResult, error) {
|
|
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
|
if err != nil {
|
|
return design.MockupSurfaceResult{}, err
|
|
}
|
|
|
|
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
|
if nodeIndex < 0 {
|
|
return design.MockupSurfaceResult{}, design.ErrNotFound
|
|
}
|
|
target := project.Nodes[nodeIndex]
|
|
if target.Type != design.NodeTypeImage && target.Type != design.NodeTypeFrame {
|
|
return design.MockupSurfaceResult{}, fmt.Errorf("%w: mockup surface requires an image node", design.ErrInvalidInput)
|
|
}
|
|
|
|
return mockupSurfaceForNode(target), nil
|
|
}
|
|
|
|
func (s *DesignService) GenerateMockupModel(ctx context.Context, projectID string, nodeID string) (design.MockupModelResult, error) {
|
|
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
|
if err != nil {
|
|
return design.MockupModelResult{}, err
|
|
}
|
|
|
|
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
|
if nodeIndex < 0 {
|
|
return design.MockupModelResult{}, design.ErrNotFound
|
|
}
|
|
target := project.Nodes[nodeIndex]
|
|
if err := validateMockupModelTarget(target); err != nil {
|
|
return design.MockupModelResult{}, err
|
|
}
|
|
|
|
computed, err := s.computeMockupModel(ctx, project, target)
|
|
if err != nil {
|
|
return design.MockupModelResult{}, err
|
|
}
|
|
|
|
target.MockupModel = computed.Model
|
|
target.MockupSurface = computed.Surface
|
|
target.MockupSourceContent = computed.SourceContent
|
|
project.Nodes[nodeIndex] = target
|
|
project = s.refreshCanvasSnapshot(project, s.now())
|
|
if err := s.repo.Save(ctx, project); err != nil {
|
|
return design.MockupModelResult{}, err
|
|
}
|
|
|
|
return design.MockupModelResult{
|
|
Placement: computed.Placement,
|
|
Model: computed.Model,
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesignService) GenerateMockupModelAsync(ctx context.Context, projectID string, nodeID string) (design.AgentThread, error) {
|
|
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
|
if err != nil {
|
|
return design.AgentThread{}, err
|
|
}
|
|
|
|
nodeIndex := findNodeIndex(project.Nodes, strings.TrimSpace(nodeID))
|
|
if nodeIndex < 0 {
|
|
return design.AgentThread{}, design.ErrNotFound
|
|
}
|
|
target := project.Nodes[nodeIndex]
|
|
if err := validateMockupModelTarget(target); err != nil {
|
|
return design.AgentThread{}, err
|
|
}
|
|
|
|
now := s.now()
|
|
threadID := newID()
|
|
status := "running"
|
|
messageStatus := "running"
|
|
title := "生成 Mockup 模型"
|
|
content := "正在分析图片表面、深度、分割和摄像机参数。"
|
|
if mockupModelCurrent(target) {
|
|
if len(target.MockupSurface.Polygon) < 3 {
|
|
target.MockupSurface = mockupSurfaceForNode(target).Surface
|
|
project.Nodes[nodeIndex] = target
|
|
}
|
|
status = "success"
|
|
messageStatus = "success"
|
|
title = "Mockup 模型已就绪"
|
|
content = "已复用当前图片的 Mockup 模型参数。"
|
|
} else {
|
|
project.Nodes[nodeIndex].Status = "mockup-modeling"
|
|
}
|
|
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
|
project.LastThreadID = threadID
|
|
project.UpdatedAt = now
|
|
inputArgs := design.GeneratorTaskInputArgs{
|
|
AspectRatio: aspectRatioLabel(int64(math.Round(target.Width)), int64(math.Round(target.Height))),
|
|
Image: []string{target.Content},
|
|
Prompt: "Generate mockup surface model",
|
|
Resolution: "1K",
|
|
}
|
|
project.Messages = append(project.Messages, design.Message{
|
|
ID: newID(),
|
|
Role: "tool",
|
|
Type: "tool",
|
|
Title: title,
|
|
Content: content,
|
|
ThreadID: threadID,
|
|
ActionID: threadID,
|
|
StepID: newID(),
|
|
Name: "mockup_model",
|
|
ToolHint: "mockup_model",
|
|
Status: messageStatus,
|
|
CreatedAt: now,
|
|
}, generatorTaskMetadataMessage(threadID, "mockup/model", inputArgs, generatorTaskPrice(inputArgs, "mockup/model"), now.Add(time.Millisecond)))
|
|
project = s.refreshCanvasSnapshot(project, s.now())
|
|
if err := s.repo.Save(ctx, project); err != nil {
|
|
return design.AgentThread{}, err
|
|
}
|
|
|
|
if status == "running" {
|
|
s.startMockupModelGeneration(project.ID, project.UserID, threadID, target)
|
|
}
|
|
return design.AgentThread{
|
|
ThreadID: threadID,
|
|
Status: status,
|
|
ThreadIDType: 9,
|
|
Project: project,
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesignService) startMockupModelGeneration(projectID string, userID string, threadID string, target design.Node) {
|
|
s.enqueueOrRunJob(design.Job{
|
|
Kind: design.JobMockupModelGeneration,
|
|
UserID: userID,
|
|
ProjectID: projectID,
|
|
ThreadID: threadID,
|
|
Target: target,
|
|
}, func(ctx context.Context) error {
|
|
if err := s.completeMockupModelGeneration(ctx, projectID, threadID, target); err != nil {
|
|
return s.markMockupModelFailed(detachedUserContext(ctx), projectID, threadID, target, err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *DesignService) completeMockupModelGeneration(ctx context.Context, projectID string, threadID string, originalTarget design.Node) error {
|
|
project, err := s.repo.Get(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeIndex := findNodeIndex(project.Nodes, originalTarget.ID)
|
|
if nodeIndex < 0 {
|
|
return design.ErrNotFound
|
|
}
|
|
target := project.Nodes[nodeIndex]
|
|
if err := validateMockupModelTarget(target); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(originalTarget.Content) != "" && strings.TrimSpace(target.Content) != strings.TrimSpace(originalTarget.Content) {
|
|
return fmt.Errorf("%w: mockup source image changed before model generation completed", design.ErrConflict)
|
|
}
|
|
|
|
computed, err := s.computeMockupModel(ctx, project, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
project, err = s.repo.Get(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeIndex = findNodeIndex(project.Nodes, originalTarget.ID)
|
|
if nodeIndex < 0 {
|
|
return design.ErrNotFound
|
|
}
|
|
current := project.Nodes[nodeIndex]
|
|
if strings.TrimSpace(current.Content) != strings.TrimSpace(computed.SourceContent) {
|
|
return fmt.Errorf("%w: mockup source image changed before model generation completed", design.ErrConflict)
|
|
}
|
|
|
|
now := s.now()
|
|
current.MockupModel = computed.Model
|
|
current.MockupSurface = computed.Surface
|
|
current.MockupSourceContent = computed.SourceContent
|
|
current.Status = restoredNodeStatus(originalTarget.Status)
|
|
project.Nodes[nodeIndex] = current
|
|
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
|
project.UpdatedAt = now
|
|
project.Messages = append(project.Messages, design.Message{
|
|
ID: newID(),
|
|
Role: "tool",
|
|
Type: "tool",
|
|
Title: "Mockup 模型完成",
|
|
Content: "已生成深度、分割、偏移和摄像机参数,可以继续放置设计图。",
|
|
ThreadID: threadID,
|
|
ActionID: threadID,
|
|
StepID: newID(),
|
|
Name: "mockup_model",
|
|
ToolHint: "mockup_model",
|
|
Status: "success",
|
|
CreatedAt: now,
|
|
})
|
|
project = s.refreshCanvasSnapshot(project, now)
|
|
return s.repo.Save(ctx, project)
|
|
}
|
|
|
|
func (s *DesignService) markMockupModelFailed(ctx context.Context, projectID string, threadID string, originalTarget design.Node, failure error) error {
|
|
project, err := s.repo.Get(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeIndex := findNodeIndex(project.Nodes, originalTarget.ID)
|
|
if nodeIndex >= 0 {
|
|
project.Nodes[nodeIndex].Status = restoredNodeStatus(originalTarget.Status)
|
|
}
|
|
now := s.now()
|
|
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed)
|
|
project.UpdatedAt = now
|
|
project.Messages = append(project.Messages, design.Message{
|
|
ID: newID(),
|
|
Role: "error",
|
|
Type: "error",
|
|
Title: "Mockup 模型生成失败",
|
|
Content: failure.Error(),
|
|
ThreadID: threadID,
|
|
ActionID: threadID,
|
|
StepID: newID(),
|
|
Name: "mockup_model",
|
|
ToolHint: "mockup_model",
|
|
Status: "failed",
|
|
CreatedAt: now,
|
|
})
|
|
project = s.refreshCanvasSnapshot(project, now)
|
|
return s.repo.Save(ctx, project)
|
|
}
|
|
|
|
func (s *DesignService) computeMockupModel(ctx context.Context, project design.Project, target design.Node) (mockupModelComputation, error) {
|
|
if mockupModelCurrent(target) {
|
|
surface := target.MockupSurface
|
|
if len(surface.Polygon) < 3 {
|
|
surface = mockupSurfaceForNode(target).Surface
|
|
}
|
|
return mockupModelComputation{
|
|
Placement: mockupPlacementFromModel(target.MockupModel, target),
|
|
Surface: surface,
|
|
Model: target.MockupModel,
|
|
SourceContent: target.Content,
|
|
}, nil
|
|
}
|
|
|
|
if s.mockupAnalyzer == nil {
|
|
return mockupModelComputation{}, fmt.Errorf("%w: mockup model analyzer is not configured", ErrUnsupportedAgent)
|
|
}
|
|
|
|
sourceData, contentType, err := loadImageContent(ctx, target.Content)
|
|
if err != nil {
|
|
return mockupModelComputation{}, err
|
|
}
|
|
source, _, err := image.Decode(bytes.NewReader(sourceData))
|
|
if err != nil {
|
|
return mockupModelComputation{}, err
|
|
}
|
|
imageDataURL := mockupImageDataURL(sourceData, contentType)
|
|
analysis, err := s.mockupAnalyzer.AnalyzeMockupModel(ctx, design.MockupModelAnalysisRequest{
|
|
Project: project,
|
|
Node: target,
|
|
ImageDataURL: imageDataURL,
|
|
ImageWidth: source.Bounds().Dx(),
|
|
ImageHeight: source.Bounds().Dy(),
|
|
})
|
|
if err != nil {
|
|
return mockupModelComputation{}, err
|
|
}
|
|
placement, surface, model, err := normalizeMockupAnalysis(target, analysis)
|
|
if err != nil {
|
|
return mockupModelComputation{}, err
|
|
}
|
|
return mockupModelComputation{
|
|
Placement: placement,
|
|
Surface: surface,
|
|
Model: model,
|
|
SourceContent: target.Content,
|
|
}, nil
|
|
}
|
|
|
|
func validateMockupModelTarget(target design.Node) error {
|
|
if target.Type != design.NodeTypeImage && target.Type != design.NodeTypeFrame {
|
|
return fmt.Errorf("%w: mockup model requires an image node", design.ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DesignService) GetMockupBlob(ctx context.Context, dataID string) ([]byte, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
value, ok := mockupBlobStore.Load(strings.TrimSpace(dataID))
|
|
if !ok {
|
|
repo, repoOK := s.repo.(design.MockupBlobRepository)
|
|
if !repoOK {
|
|
return nil, design.ErrNotFound
|
|
}
|
|
blob, err := repo.GetMockupBlob(ctx, strings.TrimSpace(dataID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storeMockupBlob(blob.DataID, blob.Data, blob.Width, blob.Height)
|
|
return append([]byte(nil), blob.Data...), nil
|
|
}
|
|
blob, ok := value.(mockupBlob)
|
|
if !ok {
|
|
return nil, design.ErrNotFound
|
|
}
|
|
return append([]byte(nil), blob.Data...), nil
|
|
}
|
|
|
|
func mockupSurfaceForNode(node design.Node) design.MockupSurfaceResult {
|
|
placement := defaultMockupPlacement(node)
|
|
polygon := defaultMockupPolygon(node)
|
|
return design.MockupSurfaceResult{
|
|
Placement: placement,
|
|
Surface: design.MockupSurface{
|
|
TargetID: node.ID,
|
|
Polygon: polygon,
|
|
Mesh: mockupMeshFromPolygon(polygon, 5, 5),
|
|
},
|
|
}
|
|
}
|
|
|
|
func mockupImageDataURL(data []byte, contentType string) string {
|
|
contentType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
|
if contentType == "" || !strings.HasPrefix(contentType, "image/") {
|
|
contentType = http.DetectContentType(data)
|
|
}
|
|
if !strings.HasPrefix(contentType, "image/") {
|
|
contentType = "image/png"
|
|
}
|
|
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)
|
|
}
|
|
|
|
func normalizeMockupAnalysis(target design.Node, analysis design.MockupModelAnalysis) (design.MockupPlacement, design.MockupSurface, design.MockupModel, error) {
|
|
surface := analysis.Surface
|
|
surface.TargetID = target.ID
|
|
if len(surface.Polygon) < 3 {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, fmt.Errorf("%w: mockup analyzer returned no printable surface polygon", design.ErrInvalidInput)
|
|
}
|
|
surface.Polygon = normalizeMockupPoints(surface.Polygon)
|
|
if surface.Mesh.Columns < 2 || surface.Mesh.Rows < 2 || len(surface.Mesh.Points) == 0 {
|
|
surface.Mesh = mockupMeshFromPolygon(surface.Polygon, 5, 5)
|
|
} else {
|
|
surface.Mesh.Points = normalizeMockupPoints(surface.Mesh.Points)
|
|
}
|
|
|
|
model := analysis.Model
|
|
if model.Version < mockupModelVersion {
|
|
model.Version = mockupModelVersion
|
|
}
|
|
var err error
|
|
if model.DepthMap, err = normalizeMockupModelMap(model.DepthMap, "depthMap", 0, 1); err != nil {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, err
|
|
}
|
|
if model.ShadeMap, err = normalizeMockupModelMap(model.ShadeMap, "shadeMap", 0, 1); err != nil {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, err
|
|
}
|
|
if model.SegmentationMap, err = normalizeMockupModelMap(model.SegmentationMap, "segmentationMap", 0, 255); err != nil {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, err
|
|
}
|
|
if model.XOffsetMap, err = normalizeMockupModelMap(model.XOffsetMap, "xOffsetMap", -1, 1); err != nil {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, err
|
|
}
|
|
if model.YOffsetMap, err = normalizeMockupModelMap(model.YOffsetMap, "yOffsetMap", -1, 1); err != nil {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, err
|
|
}
|
|
if len(model.CameraIntrinsics.Matrix) != 9 {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, fmt.Errorf("%w: mockup analyzer returned invalid cameraIntrinsics", design.ErrInvalidInput)
|
|
}
|
|
model.CameraIntrinsics.Matrix = append([]float64(nil), model.CameraIntrinsics.Matrix...)
|
|
for index, value := range model.CameraIntrinsics.Matrix {
|
|
if math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return design.MockupPlacement{}, design.MockupSurface{}, design.MockupModel{}, fmt.Errorf("%w: mockup analyzer returned invalid cameraIntrinsics", design.ErrInvalidInput)
|
|
}
|
|
if index == 0 || index == 4 {
|
|
model.CameraIntrinsics.Matrix[index] = clampMockupFloat(value, 0.2, 6)
|
|
continue
|
|
}
|
|
if index == 2 || index == 5 {
|
|
model.CameraIntrinsics.Matrix[index] = clampMockupFloat(value, 0, 1)
|
|
}
|
|
}
|
|
model.DepthScaleMin = clampMockupFloat(model.DepthScaleMin, 0, 1)
|
|
if model.DepthScaleMax <= model.DepthScaleMin {
|
|
model.DepthScaleMax = 1
|
|
}
|
|
model.DepthScaleMax = clampMockupFloat(model.DepthScaleMax, model.DepthScaleMin+0.01, 1)
|
|
if strings.TrimSpace(model.ColorCorrection) == "" {
|
|
model.ColorCorrection = "limited_brightness"
|
|
}
|
|
|
|
placement := analysis.Placement
|
|
if placement.Width <= 0 || placement.Height <= 0 {
|
|
placement = mockupPlacementFromSurface(surface)
|
|
}
|
|
placement = normalizeMockupPlacement(placement)
|
|
if mockupSurfaceCoversWholeProduct(placement) || mockupSurfaceTooLogoBound(target, placement) {
|
|
defaultSurface := mockupSurfaceForNode(target)
|
|
placement = defaultSurface.Placement
|
|
surface = defaultSurface.Surface
|
|
}
|
|
if mockupSegmentationSurfaceCoverage(model.SegmentationMap, surface.Polygon) < 0.28 {
|
|
model.SegmentationMap = mockupModelMapFromSurface(surface.Polygon, model.SegmentationMap.Width, model.SegmentationMap.Height)
|
|
}
|
|
return placement, surface, model, nil
|
|
}
|
|
|
|
func mockupSurfaceCoversWholeProduct(placement design.MockupPlacement) bool {
|
|
area := placement.Width * placement.Height
|
|
return placement.Width > 0.5 || placement.Height > 0.58 || area > 0.28
|
|
}
|
|
|
|
func mockupSurfaceTooLogoBound(target design.Node, placement design.MockupPlacement) bool {
|
|
aspect := target.Width / math.Max(target.Height, 1)
|
|
area := placement.Width * placement.Height
|
|
centered := placement.X > 0.18 && placement.X+placement.Width < 0.82 && placement.Y > 0.16 && placement.Y < 0.56
|
|
garmentLikeFrame := aspect > 0.72 && aspect < 1.38
|
|
return garmentLikeFrame && centered && placement.Width >= 0.28 && (placement.Height < 0.22 || area < 0.082)
|
|
}
|
|
|
|
func mockupSegmentationSurfaceCoverage(segmentation design.MockupModelMap, polygon []design.MockupPoint) float64 {
|
|
if segmentation.Width <= 0 || segmentation.Height <= 0 || len(segmentation.Data) != segmentation.Width*segmentation.Height || len(polygon) < 3 {
|
|
return 0
|
|
}
|
|
inside := 0
|
|
covered := 0
|
|
for y := 0; y < segmentation.Height; y++ {
|
|
v := (float64(y) + 0.5) / float64(segmentation.Height)
|
|
for x := 0; x < segmentation.Width; x++ {
|
|
u := (float64(x) + 0.5) / float64(segmentation.Width)
|
|
if !pointInMockupPolygon(u, v, polygon) {
|
|
continue
|
|
}
|
|
inside++
|
|
if segmentation.Data[y*segmentation.Width+x] >= 16 {
|
|
covered++
|
|
}
|
|
}
|
|
}
|
|
if inside == 0 {
|
|
return 0
|
|
}
|
|
return float64(covered) / float64(inside)
|
|
}
|
|
|
|
func mockupModelMapFromSurface(polygon []design.MockupPoint, width int, height int) design.MockupModelMap {
|
|
if width < 2 {
|
|
width = 32
|
|
}
|
|
if height < 2 {
|
|
height = 32
|
|
}
|
|
data := make([]float64, width*height)
|
|
for y := 0; y < height; y++ {
|
|
v := (float64(y) + 0.5) / float64(height)
|
|
for x := 0; x < width; x++ {
|
|
u := (float64(x) + 0.5) / float64(width)
|
|
data[y*width+x] = mockupSurfaceMaskValue(u, v, polygon, width, height)
|
|
}
|
|
}
|
|
return design.MockupModelMap{Width: width, Height: height, Data: data}
|
|
}
|
|
|
|
func mockupSurfaceMaskValue(u float64, v float64, polygon []design.MockupPoint, width int, height int) float64 {
|
|
if len(polygon) < 3 {
|
|
return 0
|
|
}
|
|
inside := 0
|
|
for _, offset := range []struct {
|
|
x float64
|
|
y float64
|
|
}{
|
|
{-0.35, -0.35},
|
|
{0.35, -0.35},
|
|
{-0.35, 0.35},
|
|
{0.35, 0.35},
|
|
} {
|
|
su := clampMockupFloat(u+offset.x/float64(width), 0, 1)
|
|
sv := clampMockupFloat(v+offset.y/float64(height), 0, 1)
|
|
if pointInMockupPolygon(su, sv, polygon) {
|
|
inside++
|
|
}
|
|
}
|
|
return math.Round(float64(inside) / 4 * 255)
|
|
}
|
|
|
|
func normalizeMockupModelMap(modelMap design.MockupModelMap, name string, min float64, max float64) (design.MockupModelMap, error) {
|
|
if modelMap.Width < 2 || modelMap.Height < 2 || modelMap.Width > 128 || modelMap.Height > 128 {
|
|
return design.MockupModelMap{}, fmt.Errorf("%w: mockup analyzer returned invalid %s size", design.ErrInvalidInput, name)
|
|
}
|
|
expected := modelMap.Width * modelMap.Height
|
|
if len(modelMap.Data) == 0 {
|
|
return design.MockupModelMap{}, fmt.Errorf("%w: mockup analyzer returned empty %s data", design.ErrInvalidInput, name)
|
|
}
|
|
sourceData := normalizeMockupModelMapData(modelMap.Data, expected)
|
|
data := make([]float64, expected)
|
|
for index, value := range sourceData {
|
|
if math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return design.MockupModelMap{}, fmt.Errorf("%w: mockup analyzer returned invalid %s data", design.ErrInvalidInput, name)
|
|
}
|
|
data[index] = clampMockupFloat(value, min, max)
|
|
}
|
|
return design.MockupModelMap{Width: modelMap.Width, Height: modelMap.Height, Data: data}, nil
|
|
}
|
|
|
|
func normalizeMockupModelMapData(source []float64, expected int) []float64 {
|
|
if len(source) == expected {
|
|
return append([]float64(nil), source...)
|
|
}
|
|
if expected <= 0 || len(source) == 0 {
|
|
return nil
|
|
}
|
|
data := make([]float64, expected)
|
|
tolerance := maxInt(4, expected/64)
|
|
if absInt(len(source)-expected) <= tolerance {
|
|
copy(data, source)
|
|
if len(source) < expected {
|
|
fill := source[len(source)-1]
|
|
for index := len(source); index < expected; index++ {
|
|
data[index] = fill
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
if len(source) == 1 {
|
|
for index := range data {
|
|
data[index] = source[0]
|
|
}
|
|
return data
|
|
}
|
|
if expected == 1 {
|
|
data[0] = source[0]
|
|
return data
|
|
}
|
|
scale := float64(len(source)-1) / float64(expected-1)
|
|
for index := range data {
|
|
position := float64(index) * scale
|
|
left := int(math.Floor(position))
|
|
right := minInt(left+1, len(source)-1)
|
|
weight := position - float64(left)
|
|
data[index] = source[left]*(1-weight) + source[right]*weight
|
|
}
|
|
return data
|
|
}
|
|
|
|
func normalizeMockupPoints(points []design.MockupPoint) []design.MockupPoint {
|
|
next := make([]design.MockupPoint, 0, len(points))
|
|
for _, point := range points {
|
|
next = append(next, design.MockupPoint{X: clampMockupCoord(point.X), Y: clampMockupCoord(point.Y)})
|
|
}
|
|
return next
|
|
}
|
|
|
|
func normalizeMockupPlacement(placement design.MockupPlacement) design.MockupPlacement {
|
|
placement.X = clampMockupFloat(placement.X, 0, 0.98)
|
|
placement.Y = clampMockupFloat(placement.Y, 0, 0.98)
|
|
placement.Width = clampMockupFloat(placement.Width, 0.02, 1-placement.X)
|
|
placement.Height = clampMockupFloat(placement.Height, 0.02, 1-placement.Y)
|
|
return placement
|
|
}
|
|
|
|
func mockupPlacementFromSurface(surface design.MockupSurface) design.MockupPlacement {
|
|
if len(surface.Polygon) == 0 {
|
|
return design.MockupPlacement{X: 0.38, Y: 0.3, Width: 0.24, Height: 0.28}
|
|
}
|
|
minX, minY := 1.0, 1.0
|
|
maxX, maxY := 0.0, 0.0
|
|
for _, point := range surface.Polygon {
|
|
minX = math.Min(minX, point.X)
|
|
minY = math.Min(minY, point.Y)
|
|
maxX = math.Max(maxX, point.X)
|
|
maxY = math.Max(maxY, point.Y)
|
|
}
|
|
return normalizeMockupPlacement(design.MockupPlacement{X: minX, Y: minY, Width: maxX - minX, Height: maxY - minY})
|
|
}
|
|
|
|
func mockupPlacementFromModel(model design.MockupModel, target design.Node) design.MockupPlacement {
|
|
if len(model.SegmentationMap.Data) == model.SegmentationMap.Width*model.SegmentationMap.Height && model.SegmentationMap.Width > 0 && model.SegmentationMap.Height > 0 {
|
|
width := model.SegmentationMap.Width
|
|
height := model.SegmentationMap.Height
|
|
minX, minY := width, height
|
|
maxX, maxY := -1, -1
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
if model.SegmentationMap.Data[y*width+x] < 16 {
|
|
continue
|
|
}
|
|
minX = minInt(minX, x)
|
|
minY = minInt(minY, y)
|
|
maxX = maxInt(maxX, x)
|
|
maxY = maxInt(maxY, y)
|
|
}
|
|
}
|
|
if maxX >= minX && maxY >= minY {
|
|
return normalizeMockupPlacement(design.MockupPlacement{
|
|
X: float64(minX) / float64(width),
|
|
Y: float64(minY) / float64(height),
|
|
Width: float64(maxX-minX+1) / float64(width),
|
|
Height: float64(maxY-minY+1) / float64(height),
|
|
})
|
|
}
|
|
}
|
|
return defaultMockupPlacement(target)
|
|
}
|
|
|
|
func (s *DesignService) registerMockupBlob(ctx context.Context, data []byte, width int, height int) (design.MockupModelMap, error) {
|
|
sum := md5.Sum(data)
|
|
dataID := hex.EncodeToString(sum[:]) + ".bin"
|
|
storeMockupBlob(dataID, data, width, height)
|
|
if repo, ok := s.repo.(design.MockupBlobRepository); ok {
|
|
if err := repo.SaveMockupBlob(ctx, design.MockupBlobData{
|
|
DataID: dataID,
|
|
Width: width,
|
|
Height: height,
|
|
Data: append([]byte(nil), data...),
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
return design.MockupModelMap{}, err
|
|
}
|
|
}
|
|
return design.MockupModelMap{
|
|
DataID: dataID,
|
|
Width: width,
|
|
Height: height,
|
|
URL: "/api/mockup/blob/" + dataID,
|
|
}, nil
|
|
}
|
|
|
|
func storeMockupBlob(dataID string, data []byte, width int, height int) {
|
|
mockupBlobStore.Store(dataID, mockupBlob{
|
|
Data: append([]byte(nil), data...),
|
|
Width: width,
|
|
Height: height,
|
|
})
|
|
}
|
|
|
|
func hasMockupModel(model design.MockupModel) bool {
|
|
return model.Version > 0 &&
|
|
hasMockupModelMap(model.DepthMap) &&
|
|
hasMockupModelMap(model.ShadeMap) &&
|
|
hasMockupModelMap(model.SegmentationMap) &&
|
|
hasMockupModelMap(model.XOffsetMap) &&
|
|
hasMockupModelMap(model.YOffsetMap)
|
|
}
|
|
|
|
func mockupModelCurrent(target design.Node) bool {
|
|
return hasMockupModel(target.MockupModel) &&
|
|
target.MockupModel.Version >= mockupModelVersion &&
|
|
hasInlineMockupModelData(target.MockupModel) &&
|
|
strings.TrimSpace(target.MockupSourceContent) == strings.TrimSpace(target.Content)
|
|
}
|
|
|
|
func hasMockupModelMap(modelMap design.MockupModelMap) bool {
|
|
return strings.TrimSpace(modelMap.DataID) != "" || len(modelMap.Data) == modelMap.Width*modelMap.Height && modelMap.Width > 0 && modelMap.Height > 0
|
|
}
|
|
|
|
func hasInlineMockupModelData(model design.MockupModel) bool {
|
|
return len(model.DepthMap.Data) == model.DepthMap.Width*model.DepthMap.Height &&
|
|
len(model.ShadeMap.Data) == model.ShadeMap.Width*model.ShadeMap.Height &&
|
|
len(model.SegmentationMap.Data) == model.SegmentationMap.Width*model.SegmentationMap.Height &&
|
|
len(model.XOffsetMap.Data) == model.XOffsetMap.Width*model.XOffsetMap.Height &&
|
|
len(model.YOffsetMap.Data) == model.YOffsetMap.Width*model.YOffsetMap.Height
|
|
}
|
|
|
|
func (s *DesignService) hydrateMockupModelBlobs(ctx context.Context, model design.MockupModel) error {
|
|
maps := []design.MockupModelMap{
|
|
model.DepthMap,
|
|
model.ShadeMap,
|
|
model.SegmentationMap,
|
|
model.XOffsetMap,
|
|
model.YOffsetMap,
|
|
}
|
|
for _, modelMap := range maps {
|
|
dataID := strings.TrimSpace(modelMap.DataID)
|
|
if dataID == "" {
|
|
return design.ErrNotFound
|
|
}
|
|
if _, ok := mockupBlobStore.Load(dataID); ok {
|
|
continue
|
|
}
|
|
repo, repoOK := s.repo.(design.MockupBlobRepository)
|
|
if !repoOK {
|
|
return design.ErrNotFound
|
|
}
|
|
blob, err := repo.GetMockupBlob(ctx, dataID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
storeMockupBlob(blob.DataID, blob.Data, blob.Width, blob.Height)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mockupFloatMap(source image.Image, width int, height int, value func(luma float64, gradientX float64, gradientY float64, u float64, v float64, mask float64) float32) []byte {
|
|
output := make([]byte, width*height*4)
|
|
for y := 0; y < height; y++ {
|
|
v := float64(y) / math.Max(float64(height-1), 1)
|
|
for x := 0; x < width; x++ {
|
|
u := float64(x) / math.Max(float64(width-1), 1)
|
|
luma := mockupImageLuma(source, u, v)
|
|
left := mockupImageLuma(source, clampMockupFloat(u-1/float64(width), 0, 1), v)
|
|
right := mockupImageLuma(source, clampMockupFloat(u+1/float64(width), 0, 1), v)
|
|
top := mockupImageLuma(source, u, clampMockupFloat(v-1/float64(height), 0, 1))
|
|
bottom := mockupImageLuma(source, u, clampMockupFloat(v+1/float64(height), 0, 1))
|
|
mask := defaultMockupMask(u, v, source.Bounds().Dy() > source.Bounds().Dx())
|
|
binary.LittleEndian.PutUint32(output[(y*width+x)*4:], math.Float32bits(value(luma, right-left, bottom-top, u, v, mask)))
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
func mockupSegmentationMap(target design.Node, width int, height int) []byte {
|
|
output := make([]byte, width*height)
|
|
polygon := defaultMockupPolygon(target)
|
|
for y := 0; y < height; y++ {
|
|
v := float64(y) / math.Max(float64(height-1), 1)
|
|
for x := 0; x < width; x++ {
|
|
u := float64(x) / math.Max(float64(width-1), 1)
|
|
var inside int
|
|
for _, offset := range []struct {
|
|
x float64
|
|
y float64
|
|
}{
|
|
{-0.25, -0.25},
|
|
{0.25, -0.25},
|
|
{-0.25, 0.25},
|
|
{0.25, 0.25},
|
|
} {
|
|
su := clampMockupFloat(u+offset.x/float64(width), 0, 1)
|
|
sv := clampMockupFloat(v+offset.y/float64(height), 0, 1)
|
|
if pointInMockupPolygon(su, sv, polygon) {
|
|
inside++
|
|
}
|
|
}
|
|
output[y*width+x] = byte(math.Round(float64(inside) / 4 * 255))
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
func mockupImageLuma(source image.Image, u float64, v float64) float64 {
|
|
bounds := source.Bounds()
|
|
x := bounds.Min.X + int(clampMockupFloat(u, 0, 1)*float64(bounds.Dx()-1))
|
|
y := bounds.Min.Y + int(clampMockupFloat(v, 0, 1)*float64(bounds.Dy()-1))
|
|
rgba := color.NRGBAModel.Convert(source.At(x, y)).(color.NRGBA)
|
|
return (float64(rgba.R)*0.299 + float64(rgba.G)*0.587 + float64(rgba.B)*0.114) / 255
|
|
}
|
|
|
|
func defaultMockupMask(u float64, v float64, portrait bool) float64 {
|
|
if portrait {
|
|
if u < 0.12 || u > 0.74 || v < 0.27 || v > 0.84 {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
if u < 0.14 || u > 0.86 || v < 0.18 || v > 0.82 {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func defaultMockupPlacement(node design.Node) design.MockupPlacement {
|
|
portrait := node.Height > node.Width*1.08
|
|
if portrait {
|
|
return design.MockupPlacement{X: 0.3, Y: 0.34, Width: 0.4, Height: 0.3}
|
|
}
|
|
return design.MockupPlacement{X: 0.29, Y: 0.31, Width: 0.42, Height: 0.3}
|
|
}
|
|
|
|
func defaultMockupPolygon(node design.Node) []design.MockupPoint {
|
|
placement := defaultMockupPlacement(node)
|
|
topInset := placement.Width * 0.045
|
|
bottomInset := placement.Width * 0.015
|
|
return []design.MockupPoint{
|
|
{X: placement.X + topInset, Y: placement.Y},
|
|
{X: placement.X + placement.Width - topInset, Y: placement.Y},
|
|
{X: placement.X + placement.Width - bottomInset, Y: placement.Y + placement.Height},
|
|
{X: placement.X + bottomInset, Y: placement.Y + placement.Height},
|
|
}
|
|
}
|
|
|
|
func mockupMeshFromPolygon(polygon []design.MockupPoint, columns int, rows int) design.MockupMesh {
|
|
if len(polygon) < 4 {
|
|
return design.MockupMesh{}
|
|
}
|
|
if columns < 2 {
|
|
columns = 2
|
|
}
|
|
if rows < 2 {
|
|
rows = 2
|
|
}
|
|
points := make([]design.MockupPoint, 0, columns*rows)
|
|
for y := 0; y < rows; y++ {
|
|
v := float64(y) / float64(rows-1)
|
|
for x := 0; x < columns; x++ {
|
|
u := float64(x) / float64(columns-1)
|
|
point := bilinearMockupPoint(polygon[0], polygon[1], polygon[2], polygon[3], u, v)
|
|
point.X += math.Sin(v*math.Pi) * math.Sin((u-0.5)*math.Pi) * 0.006
|
|
point.Y += math.Sin(u*math.Pi) * math.Sin((v-0.5)*math.Pi) * 0.004
|
|
points = append(points, design.MockupPoint{
|
|
X: clampMockupCoord(point.X),
|
|
Y: clampMockupCoord(point.Y),
|
|
})
|
|
}
|
|
}
|
|
return design.MockupMesh{
|
|
Columns: columns,
|
|
Rows: rows,
|
|
Points: points,
|
|
}
|
|
}
|
|
|
|
func bilinearMockupPoint(topLeft design.MockupPoint, topRight design.MockupPoint, bottomRight design.MockupPoint, bottomLeft design.MockupPoint, u float64, v float64) design.MockupPoint {
|
|
top := lerpMockupPoint(topLeft, topRight, u)
|
|
bottom := lerpMockupPoint(bottomLeft, bottomRight, u)
|
|
return lerpMockupPoint(top, bottom, v)
|
|
}
|
|
|
|
func lerpMockupPoint(a design.MockupPoint, b design.MockupPoint, t float64) design.MockupPoint {
|
|
return design.MockupPoint{
|
|
X: a.X + (b.X-a.X)*t,
|
|
Y: a.Y + (b.Y-a.Y)*t,
|
|
}
|
|
}
|
|
|
|
func pointInMockupPolygon(x float64, y float64, polygon []design.MockupPoint) bool {
|
|
if len(polygon) < 3 {
|
|
return false
|
|
}
|
|
inside := false
|
|
j := len(polygon) - 1
|
|
for i := range polygon {
|
|
pi := polygon[i]
|
|
pj := polygon[j]
|
|
if (pi.Y > y) != (pj.Y > y) {
|
|
crossX := (pj.X-pi.X)*(y-pi.Y)/(pj.Y-pi.Y) + pi.X
|
|
if x < crossX {
|
|
inside = !inside
|
|
}
|
|
}
|
|
j = i
|
|
}
|
|
return inside
|
|
}
|
|
|
|
func clampMockupFloat(value float64, min float64, max float64) float64 {
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|
|
|
|
func clampMockupCoord(value float64) float64 {
|
|
return math.Max(0, math.Min(1, value))
|
|
}
|
|
|
|
func minInt(a int, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func absInt(value int) int {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func maxInt(a int, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|