diff --git a/server/API.md b/server/API.md index 9fc9d14..c4d16aa 100644 --- a/server/API.md +++ b/server/API.md @@ -539,7 +539,7 @@ Returns `{ "code": 0, "msg": "success", "data": { "status": "done" } }` or the c Saves canvas layout changes with a Lovart-style lightweight payload. The frontend posts to `/api/canva/project/saveProject`; `/api/projects/:id/canvas` remains as a compatibility route. The `canvas` value is a `SHAKKERDATA://` gzip+base64 encoded snapshot containing viewport, nodes, and connections. Legacy uncompressed `{ "viewport": ..., "nodes": ..., "connections": ... }` payloads are still accepted. -Canvas nodes may include optional `semanticContext`. CAD source nodes use `layerRole: "cad-vector"`; non-destructive crop siblings use `layerRole: "cad-crop"` and carry crop-specific context. PostgreSQL persists the field in `canvas_nodes.semantic_context`. +Canvas nodes may include optional `semanticContext` and `renderContent`. CAD source nodes use `layerRole: "cad-vector"`; non-destructive crop siblings use `layerRole: "cad-crop"` and carry crop-specific context. `content` remains the original SVG/CAD-derived source used by crop, download, and model-reference flows, while `renderContent` is an optional transparent WebP texture used only for fast canvas display. PostgreSQL persists both fields. When a canvas image node disappears from the saved node list, its backing OSS asset is queued for asynchronous deletion after the canvas save succeeds. diff --git a/server/img_infinite_canvas.api b/server/img_infinite_canvas.api index e7f10c6..b7dd521 100644 --- a/server/img_infinite_canvas.api +++ b/server/img_infinite_canvas.api @@ -238,6 +238,7 @@ type CanvasNode { Width float64 `json:"width"` Height float64 `json:"height"` Content string `json:"content"` + RenderContent string `json:"renderContent,optional"` Tone string `json:"tone"` Status string `json:"status"` ParentId string `json:"parentId,optional"` diff --git a/server/internal/application/asset_cleanup.go b/server/internal/application/asset_cleanup.go index becfefe..08aeaeb 100644 --- a/server/internal/application/asset_cleanup.go +++ b/server/internal/application/asset_cleanup.go @@ -189,18 +189,16 @@ func deletedCanvasAssetURLs(previous []design.Node, next []design.Node, messages seen := make(map[string]struct{}) var deleted []string for _, node := range previous { - url := nodeAssetURL(node) - if url == "" { - continue + for _, url := range nodeAssetURLs(node) { + if assetURLSetContains(protectedURLs, url) { + continue + } + if _, ok := seen[url]; ok { + continue + } + seen[url] = struct{}{} + deleted = append(deleted, url) } - if assetURLSetContains(protectedURLs, url) { - continue - } - if _, ok := seen[url]; ok { - continue - } - seen[url] = struct{}{} - deleted = append(deleted, url) } return deleted } @@ -220,7 +218,9 @@ func projectAssetURLs(project design.Project) []string { urls = append(urls, url) } for _, node := range project.Nodes { - add(nodeAssetURL(node)) + for _, url := range nodeAssetURLs(node) { + add(url) + } } for _, url := range canvasSnapshotAssetURLs(project.Canvas) { add(url) @@ -267,9 +267,7 @@ func canvasSnapshotAssetURLs(canvas string) []string { } urls := make([]string, 0, len(snapshot.Nodes)) for _, node := range snapshot.Nodes { - if url := nodeAssetURL(node); url != "" { - urls = append(urls, url) - } + urls = append(urls, nodeAssetURLs(node)...) } return urls } @@ -277,22 +275,26 @@ func canvasSnapshotAssetURLs(canvas string) []string { func canvasAssetURLSet(nodes []design.Node) map[string]struct{} { items := make(map[string]struct{}) for _, node := range nodes { - if url := nodeAssetURL(node); url != "" { + for _, url := range nodeAssetURLs(node) { addAssetURLKeys(items, url) } } return items } -func nodeAssetURL(node design.Node) string { +func nodeAssetURLs(node design.Node) []string { if node.Type != design.NodeTypeImage && node.Type != design.NodeTypeFrame { - return "" + return nil } - content := strings.TrimSpace(node.Content) - if content == "" || strings.HasPrefix(content, "data:image/") || !isGeneratedImageContent(content) { - return "" + urls := make([]string, 0, 2) + for _, content := range []string{node.Content, node.RenderContent} { + content = strings.TrimSpace(content) + if content == "" || strings.HasPrefix(content, "data:image/") || !isGeneratedImageContent(content) { + continue + } + urls = append(urls, content) } - return content + return normalizeAssetURLs(urls) } func promptMessageAssetURLs(content string) []string { diff --git a/server/internal/application/asset_cleanup_test.go b/server/internal/application/asset_cleanup_test.go index 962ba10..e161511 100644 --- a/server/internal/application/asset_cleanup_test.go +++ b/server/internal/application/asset_cleanup_test.go @@ -63,7 +63,7 @@ func TestDeleteProjectEnqueuesAssetCleanupJobWhenJobQueueIsConfigured(t *testing Status: design.StatusReady, UpdatedAt: time.Now(), Nodes: []design.Node{ - {ID: "image-1", Type: design.NodeTypeImage, Content: "http://localhost:19000/canvas-assets/a.png", Status: "success"}, + {ID: "image-1", Type: design.NodeTypeImage, Content: "http://localhost:19000/canvas-assets/a.svg", RenderContent: "http://localhost:19000/canvas-assets/a.render.webp", Status: "success"}, }, } if err := repo.Save(ctx, project); err != nil { @@ -78,7 +78,7 @@ func TestDeleteProjectEnqueuesAssetCleanupJobWhenJobQueueIsConfigured(t *testing t.Fatalf("expected one cleanup job, got %#v", queue.jobs) } job := queue.jobs[0] - if job.Kind != design.JobAssetCleanup || len(job.PublicURLs) != 1 || job.PublicURLs[0] != "http://localhost:19000/canvas-assets/a.png" { + if job.Kind != design.JobAssetCleanup || len(job.PublicURLs) != 2 || job.PublicURLs[0] != "http://localhost:19000/canvas-assets/a.svg" || job.PublicURLs[1] != "http://localhost:19000/canvas-assets/a.render.webp" { t.Fatalf("unexpected cleanup job: %#v", job) } select { diff --git a/server/internal/application/design_service.go b/server/internal/application/design_service.go index 3c4a90a..447fc5d 100644 --- a/server/internal/application/design_service.go +++ b/server/internal/application/design_service.go @@ -5344,7 +5344,10 @@ func mainCanvasPreviewImage(nodes []design.Node) string { if node.Status == "generating" || node.Status == "error" || node.LayerRole == "pen-stroke" { continue } - content := strings.TrimSpace(node.Content) + content := strings.TrimSpace(node.RenderContent) + if content == "" { + content = strings.TrimSpace(node.Content) + } if content == "" || strings.HasPrefix(content, "data:image/") || !isGeneratedImageContent(content) { continue } diff --git a/server/internal/application/project_thumbnail.go b/server/internal/application/project_thumbnail.go index 0a037b8..ec3d91d 100644 --- a/server/internal/application/project_thumbnail.go +++ b/server/internal/application/project_thumbnail.go @@ -31,6 +31,7 @@ type projectPreviewNode struct { Width float64 `json:"width"` Height float64 `json:"height"` Content string `json:"content,omitempty"` + RenderContent string `json:"renderContent,omitempty"` Tone string `json:"tone,omitempty"` LayerRole string `json:"layerRole,omitempty"` FillColor string `json:"fillColor,omitempty"` @@ -82,6 +83,7 @@ func canvasPreviewNodes(nodes []design.Node) []projectPreviewNode { continue } content := strings.TrimSpace(node.Content) + renderContent := strings.TrimSpace(node.RenderContent) if node.Type == design.NodeTypeFrame && !isGeneratedImageContent(content) { content = "" } @@ -93,6 +95,7 @@ func canvasPreviewNodes(nodes []design.Node) []projectPreviewNode { Width: math.Max(1, node.Width), Height: math.Max(1, node.Height), Content: content, + RenderContent: renderContent, Tone: strings.TrimSpace(node.Tone), LayerRole: strings.TrimSpace(node.LayerRole), FillColor: strings.TrimSpace(node.FillColor), diff --git a/server/internal/application/project_thumbnail_test.go b/server/internal/application/project_thumbnail_test.go index 1126ac2..fb040e2 100644 --- a/server/internal/application/project_thumbnail_test.go +++ b/server/internal/application/project_thumbnail_test.go @@ -23,13 +23,14 @@ func TestProjectThumbnailUsesCanvasPreview(t *testing.T) { Status: "success", }, { - ID: "main", - Type: design.NodeTypeImage, - Title: "Main", - Content: "http://localhost:19000/canvas-assets/main.png", - Width: 1024, - Height: 768, - Status: "success", + ID: "main", + Type: design.NodeTypeImage, + Title: "Main", + Content: "http://localhost:19000/canvas-assets/main.svg", + RenderContent: "http://localhost:19000/canvas-assets/main.render.webp", + Width: 1024, + Height: 768, + Status: "success", }, }, } @@ -49,7 +50,7 @@ func TestProjectThumbnailUsesCanvasPreview(t *testing.T) { if len(preview.Nodes) != 2 { t.Fatalf("expected two preview nodes, got %#v", preview.Nodes) } - if preview.Nodes[1].Content != "http://localhost:19000/canvas-assets/main.png" { + if preview.Nodes[1].Content != "http://localhost:19000/canvas-assets/main.svg" || preview.Nodes[1].RenderContent != "http://localhost:19000/canvas-assets/main.render.webp" { t.Fatalf("expected generated image in canvas preview, got %#v", preview.Nodes[1]) } } diff --git a/server/internal/domain/design/project.go b/server/internal/domain/design/project.go index ed8d224..481a6df 100644 --- a/server/internal/domain/design/project.go +++ b/server/internal/domain/design/project.go @@ -130,6 +130,7 @@ type Node struct { Width float64 Height float64 Content string + RenderContent string Tone string Status string ParentID string diff --git a/server/internal/infrastructure/postgres/db/models.go b/server/internal/infrastructure/postgres/db/models.go index 7ff1785..c2d9d31 100644 --- a/server/internal/infrastructure/postgres/db/models.go +++ b/server/internal/infrastructure/postgres/db/models.go @@ -105,6 +105,7 @@ type CanvasNode struct { FlipY bool `json:"flip_y"` Rotation float64 `json:"rotation"` SemanticContext string `json:"semantic_context"` + RenderContent string `json:"render_content"` } type CanvasSnapshot struct { diff --git a/server/internal/infrastructure/postgres/db/query.sql.go b/server/internal/infrastructure/postgres/db/query.sql.go index 14e2f75..bda5108 100644 --- a/server/internal/infrastructure/postgres/db/query.sql.go +++ b/server/internal/infrastructure/postgres/db/query.sql.go @@ -467,7 +467,7 @@ func (q *Queries) ListMessagesByProject(ctx context.Context, arg ListMessagesByP } const listNodesByProject = `-- name: ListNodesByProject :many -SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context +SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context, render_content FROM canvas_nodes WHERE project_id = $1 AND user_id = $2 ORDER BY sort_order ASC, id ASC @@ -521,6 +521,7 @@ func (q *Queries) ListNodesByProject(ctx context.Context, arg ListNodesByProject &i.FlipY, &i.Rotation, &i.SemanticContext, + &i.RenderContent, ); err != nil { return nil, err } @@ -746,8 +747,8 @@ func (q *Queries) UpsertBrandKit(ctx context.Context, arg UpsertBrandKitParams) } const upsertCanvasNode = `-- name: UpsertCanvasNode :exec -INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33) +INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context, render_content) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, user_id = EXCLUDED.user_id, @@ -780,7 +781,8 @@ ON CONFLICT (id) DO UPDATE SET flip_x = EXCLUDED.flip_x, flip_y = EXCLUDED.flip_y, rotation = EXCLUDED.rotation, - semantic_context = EXCLUDED.semantic_context + semantic_context = EXCLUDED.semantic_context, + render_content = EXCLUDED.render_content ` type UpsertCanvasNodeParams struct { @@ -817,6 +819,7 @@ type UpsertCanvasNodeParams struct { FlipY bool `json:"flip_y"` Rotation float64 `json:"rotation"` SemanticContext string `json:"semantic_context"` + RenderContent string `json:"render_content"` } func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodeParams) error { @@ -854,6 +857,7 @@ func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodePara arg.FlipY, arg.Rotation, arg.SemanticContext, + arg.RenderContent, ) return err } diff --git a/server/internal/infrastructure/postgres/project_repository.go b/server/internal/infrastructure/postgres/project_repository.go index 7886a33..6ca18b5 100644 --- a/server/internal/infrastructure/postgres/project_repository.go +++ b/server/internal/infrastructure/postgres/project_repository.go @@ -199,6 +199,7 @@ func (r *ProjectRepository) Save(ctx context.Context, project design.Project) er Width: node.Width, Height: node.Height, Content: node.Content, + RenderContent: node.RenderContent, Tone: node.Tone, Status: node.Status, SortOrder: int32(index), @@ -518,6 +519,7 @@ func mapNodes(rows []sqlcdb.CanvasNode) []design.Node { Width: row.Width, Height: row.Height, Content: row.Content, + RenderContent: row.RenderContent, Tone: row.Tone, Status: row.Status, ParentID: row.ParentID, diff --git a/server/internal/infrastructure/postgres/query.sql b/server/internal/infrastructure/postgres/query.sql index 2ce15e4..3d9f004 100644 --- a/server/internal/infrastructure/postgres/query.sql +++ b/server/internal/infrastructure/postgres/query.sql @@ -114,7 +114,7 @@ SET thumbnail = $3, WHERE id = $1 AND user_id = $2; -- name: ListNodesByProject :many -SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context +SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context, render_content FROM canvas_nodes WHERE project_id = $1 AND user_id = $2 ORDER BY sort_order ASC, id ASC; @@ -124,8 +124,8 @@ DELETE FROM canvas_nodes WHERE project_id = $1 AND user_id = $2; -- name: UpsertCanvasNode :exec -INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33) +INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context, render_content) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34) ON CONFLICT (id) DO UPDATE SET project_id = EXCLUDED.project_id, user_id = EXCLUDED.user_id, @@ -158,7 +158,8 @@ ON CONFLICT (id) DO UPDATE SET flip_x = EXCLUDED.flip_x, flip_y = EXCLUDED.flip_y, rotation = EXCLUDED.rotation, - semantic_context = EXCLUDED.semantic_context; + semantic_context = EXCLUDED.semantic_context, + render_content = EXCLUDED.render_content; -- name: ListConnectionsByProject :many SELECT id, project_id, user_id, from_node_id, to_node_id diff --git a/server/internal/infrastructure/postgres/schema.sql b/server/internal/infrastructure/postgres/schema.sql index 185fbd3..6746199 100644 --- a/server/internal/infrastructure/postgres/schema.sql +++ b/server/internal/infrastructure/postgres/schema.sql @@ -195,7 +195,8 @@ CREATE TABLE IF NOT EXISTS canvas_nodes ( flip_x BOOLEAN NOT NULL DEFAULT FALSE, flip_y BOOLEAN NOT NULL DEFAULT FALSE, rotation DOUBLE PRECISION NOT NULL DEFAULT 0, - semantic_context TEXT NOT NULL DEFAULT '' + semantic_context TEXT NOT NULL DEFAULT '', + render_content TEXT NOT NULL DEFAULT '' ); ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS user_id UUID; @@ -227,6 +228,7 @@ ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_x BOOLEAN NOT NULL DEFAUL ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_y BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS rotation DOUBLE PRECISION NOT NULL DEFAULT 0; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS semantic_context TEXT NOT NULL DEFAULT ''; +ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS render_content TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS canvas_nodes_project_id_idx ON canvas_nodes(project_id); CREATE INDEX IF NOT EXISTS canvas_nodes_user_project_idx ON canvas_nodes(user_id, project_id); diff --git a/server/internal/logic/cad_context_mapper_test.go b/server/internal/logic/cad_context_mapper_test.go index 3519210..82e4067 100644 --- a/server/internal/logic/cad_context_mapper_test.go +++ b/server/internal/logic/cad_context_mapper_test.go @@ -13,10 +13,11 @@ func TestCanvasNodeSemanticContextRoundTrip(t *testing.T) { Title: "plan.svg", LayerRole: "cad-vector", SemanticContext: "CAD semantic context: layer ROOMS", + RenderContent: "https://assets.example/plan-preview.webp", }} roundTrip := toDomainNodes(toAPINodes(nodes)) - if len(roundTrip) != 1 || roundTrip[0].SemanticContext != nodes[0].SemanticContext { - t.Fatalf("expected semantic context round trip, got %#v", roundTrip) + if len(roundTrip) != 1 || roundTrip[0].SemanticContext != nodes[0].SemanticContext || roundTrip[0].RenderContent != nodes[0].RenderContent { + t.Fatalf("expected CAD context and render content round trip, got %#v", roundTrip) } } diff --git a/server/internal/logic/mapper.go b/server/internal/logic/mapper.go index 711c541..71e3e9e 100644 --- a/server/internal/logic/mapper.go +++ b/server/internal/logic/mapper.go @@ -259,6 +259,7 @@ func toAPINodes(items []design.Node) []types.CanvasNode { Width: item.Width, Height: item.Height, Content: item.Content, + RenderContent: item.RenderContent, Tone: item.Tone, Status: item.Status, ParentId: item.ParentID, @@ -302,6 +303,7 @@ func toDomainNodes(items []types.CanvasNode) []design.Node { Width: item.Width, Height: item.Height, Content: item.Content, + RenderContent: item.RenderContent, Tone: item.Tone, Status: item.Status, ParentID: item.ParentId, diff --git a/server/internal/logic/query_project_logic.go b/server/internal/logic/query_project_logic.go index 1d95a20..62d6465 100644 --- a/server/internal/logic/query_project_logic.go +++ b/server/internal/logic/query_project_logic.go @@ -109,7 +109,11 @@ func queryProjectCoverList(project design.Project) []string { break } if (node.Type == design.NodeTypeImage || node.Type == design.NodeTypeFrame) && node.Status == "success" { - add(node.Content) + if strings.TrimSpace(node.RenderContent) != "" { + add(node.RenderContent) + } else { + add(node.Content) + } } } return covers diff --git a/server/internal/types/types.go b/server/internal/types/types.go index 0ccf986..fa7b285 100644 --- a/server/internal/types/types.go +++ b/server/internal/types/types.go @@ -283,6 +283,7 @@ type CanvasNode struct { Width float64 `json:"width"` Height float64 `json:"height"` Content string `json:"content"` + RenderContent string `json:"renderContent,optional"` Tone string `json:"tone"` Status string `json:"status"` ParentId string `json:"parentId,optional"`