package application import ( "encoding/base64" "encoding/json" "math" "strings" "img_infinite_canvas/internal/domain/design" ) const projectCanvasPreviewPrefix = "CANVASPREVIEW://" type projectCanvasPreview struct { Bounds projectPreviewBounds `json:"bounds"` Nodes []projectPreviewNode `json:"nodes"` } type projectPreviewBounds struct { X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` Height float64 `json:"height"` } type projectPreviewNode struct { Type string `json:"type"` Title string `json:"title"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` Height float64 `json:"height"` Content string `json:"content,omitempty"` Tone string `json:"tone,omitempty"` LayerRole string `json:"layerRole,omitempty"` FillColor string `json:"fillColor,omitempty"` StrokeColor string `json:"strokeColor,omitempty"` StrokeWidth float64 `json:"strokeWidth,omitempty"` Color string `json:"color,omitempty"` FontSize float64 `json:"fontSize,omitempty"` FontFamily string `json:"fontFamily,omitempty"` FontWeight string `json:"fontWeight,omitempty"` TextAlign string `json:"textAlign,omitempty"` LineHeight float64 `json:"lineHeight,omitempty"` LetterSpacing float64 `json:"letterSpacing,omitempty"` TextDecoration string `json:"textDecoration,omitempty"` TextTransform string `json:"textTransform,omitempty"` Opacity float64 `json:"opacity,omitempty"` } func projectThumbnail(project design.Project) string { if thumbnail := canvasPreviewThumbnail(project.Nodes); thumbnail != "" { return thumbnail } if thumbnail := mainCanvasPreviewImage(project.Nodes); thumbnail != "" { return thumbnail } return inferThumbnail(project.Brief) } func canvasPreviewThumbnail(nodes []design.Node) string { previewNodes := canvasPreviewNodes(nodes) if len(previewNodes) == 0 { return "" } bounds := previewBounds(previewNodes) payload, err := json.Marshal(projectCanvasPreview{ Bounds: bounds, Nodes: previewNodes, }) if err != nil { return "" } return projectCanvasPreviewPrefix + base64.StdEncoding.EncodeToString(payload) } func canvasPreviewNodes(nodes []design.Node) []projectPreviewNode { items := make([]projectPreviewNode, 0, len(nodes)) for _, node := range nodes { if !isPreviewableNode(node) { continue } content := strings.TrimSpace(node.Content) if node.Type == design.NodeTypeFrame && !isGeneratedImageContent(content) { content = "" } items = append(items, projectPreviewNode{ Type: string(node.Type), Title: strings.TrimSpace(node.Title), X: node.X, Y: node.Y, Width: math.Max(1, node.Width), Height: math.Max(1, node.Height), Content: content, Tone: strings.TrimSpace(node.Tone), LayerRole: strings.TrimSpace(node.LayerRole), FillColor: strings.TrimSpace(node.FillColor), StrokeColor: strings.TrimSpace(node.StrokeColor), StrokeWidth: node.StrokeWidth, Color: strings.TrimSpace(node.Color), FontSize: node.FontSize, FontFamily: strings.TrimSpace(node.FontFamily), FontWeight: strings.TrimSpace(node.FontWeight), TextAlign: strings.TrimSpace(node.TextAlign), LineHeight: node.LineHeight, LetterSpacing: node.LetterSpacing, TextDecoration: strings.TrimSpace(node.TextDecoration), TextTransform: strings.TrimSpace(node.TextTransform), Opacity: node.Opacity, }) } if len(items) > 28 { items = items[len(items)-28:] } return items } func isPreviewableNode(node design.Node) bool { if node.Status == "generating" || node.Status == "error" || node.LayerRole == "pen-stroke" { return false } if node.Width <= 0 || node.Height <= 0 { return false } switch node.Type { case design.NodeTypeImage: content := strings.TrimSpace(node.Content) return content != "" && !strings.HasPrefix(content, "data:image/") && isGeneratedImageContent(content) case design.NodeTypeFrame, design.NodeTypeText: return true default: return false } } func previewBounds(nodes []projectPreviewNode) projectPreviewBounds { minX := math.Inf(1) minY := math.Inf(1) maxX := math.Inf(-1) maxY := math.Inf(-1) for _, node := range nodes { minX = math.Min(minX, node.X) minY = math.Min(minY, node.Y) maxX = math.Max(maxX, node.X+node.Width) maxY = math.Max(maxY, node.Y+node.Height) } if math.IsInf(minX, 0) || math.IsInf(minY, 0) || math.IsInf(maxX, 0) || math.IsInf(maxY, 0) { return projectPreviewBounds{Width: 1, Height: 1} } width := math.Max(1, maxX-minX) height := math.Max(1, maxY-minY) padding := math.Max(24, math.Min(width, height)*0.06) return projectPreviewBounds{ X: minX - padding, Y: minY - padding, Width: width + padding*2, Height: height + padding*2, } }