From 5e35532dbb98ea6f8b810bcdb076dee5761e3858 Mon Sep 17 00:00:00 2001 From: dindang Date: Wed, 22 Jul 2026 15:23:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(floor):=20=E4=BC=98=E5=8C=96=E5=9C=B0?= =?UTF-8?q?=E6=9D=BF=E6=A0=B7=E5=BC=8F=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=92=8CAI=E6=8D=A2=E5=9C=B0=E6=9D=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 FloorOption 结构体字段支持尺寸、描述和变体信息 - 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体 - 区分木材、瓷砖、乙烯基和层压板产品,并分类返回 - 更新室内铺装纹理选项,完善图案描述和名称本地化 - 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段 - 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例 - 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求 - 改进地板识别蒙版生成逻辑,确保精准分割地板区域 - Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息 - 更新go.mod依赖,新增redis和gorm相关包支持 --- .gitignore | 3 + apifox-import.json | 207 ++++++++++++--- cmd/import/main.go | 45 +++- go.mod | 7 +- go.sum | 23 ++ internal/api/index_handler.go | 13 + internal/api/index_html.go | 6 + internal/api/router.go | 1 + internal/handler/floor_handler.go | 379 +++++++++++++++++++++++----- internal/handler/product_handler.go | 2 + internal/model/product.go | 51 ++-- internal/openrouter/image.go | 56 +++- internal/queue/redis.go | 71 ++++-- internal/queue/worker.go | 179 +++++++++++-- internal/repository/product_repo.go | 86 +++++-- main.go | 44 +++- schema.sql | 1 + 17 files changed, 949 insertions(+), 225 deletions(-) create mode 100644 internal/api/index_handler.go create mode 100644 internal/api/index_html.go diff --git a/.gitignore b/.gitignore index 2762182..80303d6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ __MACOSX/ # Claude .claude/ + +# Env +.env diff --git a/apifox-import.json b/apifox-import.json index ed8fb28..804db40 100644 --- a/apifox-import.json +++ b/apifox-import.json @@ -232,6 +232,10 @@ "spec_count": { "type": "integer", "description": "该花色的规格数量(同品牌同花色的SKU数,含自身)。>1 表示有多种尺寸可选" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -407,6 +411,10 @@ "is_favorited": { "type": "boolean", "description": "当前用户是否已收藏。未登录时始终为 false" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -616,7 +624,7 @@ }, "FloorStyleOption": { "type": "object", - "description": "Floor style loaded from product database", + "description": "Floor style loaded from product database. Each entry = one unique style (dedup by brand+style_name). Variants list different sizes.", "properties": { "id": { "type": "string", @@ -628,23 +636,31 @@ }, "name": { "type": "string", - "description": "Style name" + "description": "Style name (花色名称)" }, "material": { "type": "string", - "description": "Material (used in AI prompt)" + "description": "Material (材质,如 Oak/Porcelain/Luxury Vinyl)" }, "color_tone": { "type": "string", - "description": "Color tone (used in AI prompt)" + "description": "Color tone (色调,如 Light Gray/Natural Oak/Dark Brown)" }, "finish": { "type": "string", - "description": "Finish (used in AI prompt)" + "description": "Finish (表面处理,如 Matte/Gloss/Textured)" }, "size_label": { "type": "string", - "description": "Size label, e.g. 6 x 48 in" + "description": "Size label, e.g. 6 x 48 in. 多尺寸时为首个尺寸" + }, + "width_in": { + "type": "number", + "description": "Width in inches. Used in AI prompt for physical scale" + }, + "length_in": { + "type": "number", + "description": "Length in inches. Used in AI prompt for physical scale" }, "image_url": { "type": "string", @@ -652,7 +668,24 @@ }, "category": { "type": "string", - "description": "Product category" + "description": "Product category (Solid Hardwood / Engineered Hardwood / Tile/Stone / SPC-LVP / Laminate)" + }, + "description": { + "type": "string", + "description": "Product description. Used in AI prompt for richer visual detail" + }, + "variants": { + "type": "array", + "description": "Size variants of the same style (same brand+style_name, different SKU/size). Empty array if only one size.", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Variant SKU" }, + "size_label": { "type": "string", "description": "Variant size label" }, + "width_in": { "type": "number" }, + "length_in": { "type": "number" } + } + } } } }, @@ -664,21 +697,21 @@ "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Hardwood + Engineered Wood products" + "description": "Wood products (Oak/Ash/Hickory/Maple etc.) — classified by material field" }, "tile_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Wood-Look Tile + Laminate products" + "description": "Tile/Stone (Porcelain/Stone Look/Glass) + Laminate products" }, "vinyl_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "SPC/LVP products" + "description": "Vinyl products (Luxury Vinyl/Rigid Core)" }, "wood_patterns": { "type": "array", @@ -1817,8 +1850,8 @@ "tags": [ "地板更换" ], - "summary": "获取地板样式选项(从产品库动态加载)", - "description": "从产品数据库(3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU,/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明:\n- wood_floors:实木+实木复合产品\n- tile_floors:瓷砖仿木纹+强化复合产品\n- vinyl_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型", + "summary": "获取地板样式选项(从产品库动态加载,含尺寸变体)", + "description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU,含 width_in/length_in/description/variants 等字段。\n\n材质分类(按 material 字段自动归类):\n- wood_floors:Oak/Ash/Hickory/Maple 等木材\n- tile_floors:Porcelain/Stone Look/Glass + Laminate\n- vinyl_floors:Luxury Vinyl/Rigid Core\n\n前端应使用 variants 渲染尺寸选择面板。", "responses": { "200": { "description": "成功", @@ -1842,59 +1875,95 @@ "wood_floors": [], "tile_floors": [], "vinyl_floors": [], + "wood_floors": [ + { + "id": "CB218LG", + "sku": "CB218LG", + "name": "Cherry", + "material": "Oak", + "color_tone": "Natural Oak", + "finish": "Matte", + "size_label": "2.25 x 84 in", + "width_in": 2.25, + "length_in": 84.0, + "image_url": "https://...", + "category": "Solid Hardwood", + "description": "Natural cherry oak with subtle grain", + "variants": [ + { "sku": "CB4218", "size_label": "4 x 84 in", "width_in": 4.0, "length_in": 84.0 }, + { "sku": "CB5218", "size_label": "5 x 84 in", "width_in": 5.0, "length_in": 84.0 } + ] + } + ], + "tile_floors": [], + "vinyl_floors": [], "wood_patterns": [ { "code": 1, - "name": "Straight Lay", - "pattern": "straight/parallel planks...", + "name": "直铺(工字拼)Straight Lay", + "pattern": "straight/parallel planks running from front to back with staggered joints", "category": "wood" }, { "code": 2, - "name": "Horizontal Lay", - "pattern": "horizontal planks...", + "name": "横铺 Horizontal Lay", + "pattern": "horizontal planks running side to side across the width of the room", "category": "wood" }, { "code": 3, - "name": "Herringbone", - "pattern": "herringbone/zigzag pattern...", + "name": "人字拼 Herringbone", + "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape", "category": "wood" }, { "code": 4, - "name": "Chevron", - "pattern": "chevron pattern...", + "name": "鱼骨拼 Chevron", + "pattern": "chevron pattern with planks cut at an angle forming a continuous V shape", "category": "wood" } ], "tile_patterns": [ { "code": 5, - "name": "Straight Grid", - "pattern": "straight grid layout...", + "name": "直铺(网格对缝)Straight Grid", + "pattern": "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines", "category": "tile" }, { "code": 6, - "name": "Running Bond", - "pattern": "running bond/brick pattern...", + "name": "工字铺(1/2错缝)Running Bond", + "pattern": "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance", + "category": "tile" + }, + { + "code": 7, + "name": "三七错铺 1/3 Offset", + "pattern": "tiles offset by 1/3 of their length in each row, subtle stepped pattern", + "category": "tile" + }, + { + "code": 8, + "name": "六边形 Hexagonal", + "pattern": "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh", + "category": "tile" + }, + { + "code": 9, + "name": "斜铺(45度对角)Diagonal", + "pattern": "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation", "category": "tile" } ], "rooms": [ - { - "code": 0, - "name": "Auto Detect" - }, - { - "code": 1, - "name": "Living Room" - }, - { - "code": 2, - "name": "Bedroom" - } + { "code": 0, "name": "自动识别" }, + { "code": 1, "name": "客厅" }, + { "code": 2, "name": "卧室" }, + { "code": 3, "name": "厨房" }, + { "code": 4, "name": "餐厅" }, + { "code": 5, "name": "浴室" }, + { "code": 6, "name": "书房" }, + { "code": 7, "name": "走廊" } ] } } @@ -1909,8 +1978,8 @@ "tags": [ "地板更换" ], - "summary": "AI换地板(使用真实产品数据)", - "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度", + "summary": "AI换地板(使用真实产品数据,含尺寸/变体)", + "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。用 GET /floor/status?job_id=xxx 查询进度。\n\nfloor_id 支持有尺寸变体的产品——选择变体对应 SKU 即可,AI 会根据 width_in/length_in 生成对应物理尺寸的地板。", "requestBody": { "content": { "multipart/form-data": { @@ -1940,6 +2009,16 @@ "type": "integer", "description": "房间类型编码(0-7),见文档顶部数字编码对照表。0 或省略表示自动检测", "example": 1 + }, + "grout_lines": { + "type": "boolean", + "description": "Optional. Tile floors only. true adds realistic visible grout lines between tiles.", + "example": false + }, + "white_baseboard": { + "type": "boolean", + "description": "Optional. true adds or refreshes a realistic white baseboard/skirting board along visible wall-floor edges.", + "example": false } } } @@ -2204,8 +2283,8 @@ "tags": [ "地板更换" ], - "summary": "查询生成进度(HTTP 轮询)", - "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error", + "summary": "查询生成进度(HTTP 轮询,含耗时统计)", + "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error。\n\n返回 timings_ms 字段包含每步耗时:check/mask/inpaint/cleanup/total。elapsed_ms 为总运行时间。", "parameters": [ { "name": "job_id", @@ -2219,7 +2298,51 @@ ], "responses": { "200": { - "description": "SSE 事件流" + "description": "Job status with timing", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 200 }, + "data": { + "type": "object", + "properties": { + "job_id": { "type": "string" }, + "status": { "type": "string", "description": "pending | processing | done | error" }, + "step": { "type": "string", "description": "check | mask | inpaint | done | error" }, + "message": { "type": "string" }, + "result": { "type": "string", "description": "Generated image path, e.g. /outputs/result_xxx.png" }, + "error": { "type": "string" }, + "elapsed_ms": { "type": "integer", "description": "Total elapsed milliseconds since job start" }, + "timings_ms": { + "type": "object", + "description": "Per-step duration in milliseconds", + "properties": { + "check": { "type": "integer" }, + "mask": { "type": "integer" }, + "inpaint": { "type": "integer" }, + "total": { "type": "integer" } + } + } + } + } + } + }, + "example": { + "code": 200, + "data": { + "job_id": "1784530075874083600", + "status": "done", + "step": "done", + "message": "Done", + "result": "/outputs/result_1784530075874083600.png", + "elapsed_ms": 100340, + "timings_ms": { "check": 5148, "mask": 25971, "inpaint": 69218, "total": 100340 } + } + } + } + } } } } @@ -2328,4 +2451,4 @@ } } } -} \ No newline at end of file +} diff --git a/cmd/import/main.go b/cmd/import/main.go index a688ace..d2b5777 100644 --- a/cmd/import/main.go +++ b/cmd/import/main.go @@ -1,21 +1,52 @@ package main + import ( - "encoding/json"; "fmt"; "os" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "floorvisualizer/internal/model" "floorvisualizer/internal/repository" ) + func main() { - db, _ := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable") + dsn := repository.DefaultDSN + if v := os.Getenv("DATABASE_URL"); v != "" { + dsn = v + } + dataDir := "data/products" + if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" { + dataDir = v + } + + db, err := repository.Connect(dsn) + if err != nil { + panic(err) + } defer db.Close() db.Exec("DELETE FROM products") - files, _ := os.ReadDir("data/products") + files, err := os.ReadDir(dataDir) + if err != nil { + panic(err) + } total := 0 for _, f := range files { - if f.IsDir() { continue } - data, _ := os.ReadFile("data/products/" + f.Name()) + if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dataDir, f.Name())) + if err != nil { + panic(err) + } var prods []model.Product - json.Unmarshal(data, &prods) - repository.InsertProducts(db, prods) + if err := json.Unmarshal(data, &prods); err != nil { + panic(fmt.Errorf("parse %s: %w", f.Name(), err)) + } + if err := repository.InsertProducts(db, prods); err != nil { + panic(err) + } total += len(prods) fmt.Printf(" %s: %d\n", f.Name(), len(prods)) } diff --git a/go.mod b/go.mod index 07dc8eb..6fab5c8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.26.4 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/lib/pq v1.12.3 + github.com/redis/go-redis/v9 v9.21.0 golang.org/x/image v0.43.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.2 ) require ( @@ -16,11 +19,9 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/redis/go-redis/v9 v9.21.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect - gorm.io/driver/postgres v1.6.0 // indirect - gorm.io/gorm v1.31.2 // indirect ) diff --git a/go.sum b/go.sum index e413204..f2ac7f9 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,14 +21,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= @@ -31,11 +48,17 @@ golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go new file mode 100644 index 0000000..67df1db --- /dev/null +++ b/internal/api/index_handler.go @@ -0,0 +1,13 @@ +package api + +import "net/http" + +func Index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(indexHTML)) +} diff --git a/internal/api/index_html.go b/internal/api/index_html.go new file mode 100644 index 0000000..bc888e3 --- /dev/null +++ b/internal/api/index_html.go @@ -0,0 +1,6 @@ +package api + +import _ "embed" + +//go:embed index.html +var indexHTML string diff --git a/internal/api/router.go b/internal/api/router.go index 7526cfb..7d942f8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -63,6 +63,7 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a mux.HandleFunc("/articles/recommend", articleRecommend) mux.HandleFunc("/articles", articles) mux.HandleFunc("/articles/", articlesSub) + mux.HandleFunc("/", Index) // Static files mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index 9ada160..bc4f4ce 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -18,15 +18,19 @@ import ( // ── Floor options ────────────────────────────────────────── type FloorOption struct { - ID string `json:"id"` - Name string `json:"name"` - Category string `json:"category"` - Material string `json:"material"` - ColorTone string `json:"color_tone"` - Finish string `json:"finish"` - SizeLabel string `json:"size_label"` - ImageURL string `json:"image_url"` - SKU string `json:"sku"` + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Material string `json:"material"` + ColorTone string `json:"color_tone"` + Finish string `json:"finish"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + ImageURL string `json:"image_url"` + SKU string `json:"sku"` + Description string `json:"description"` + Variants []FloorOption `json:"variants,omitempty"` } type PatternOption struct { @@ -42,29 +46,29 @@ type RoomOption struct { } var woodPatterns = []PatternOption{ - {Code: 1, Name: "Straight Lay", Category: "wood", Pattern: "straight/parallel planks running from front to back with staggered joints"}, - {Code: 2, Name: "Horizontal Lay", Category: "wood", Pattern: "horizontal planks running side to side across the width of the room"}, - {Code: 3, Name: "Herringbone", Category: "wood", Pattern: "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape"}, - {Code: 4, Name: "Chevron", Category: "wood", Pattern: "chevron pattern with planks cut at an angle forming a continuous V shape"}, + {Code: 1, Name: "直铺(工字拼)Straight Lay", Category: "wood", Pattern: "planks running from bottom-left toward top-right of the image, joints staggered randomly, at least 6 inch offset between rows"}, + {Code: 2, Name: "横铺 Horizontal Lay", Category: "wood", Pattern: "planks running horizontally left to right, parallel to the bottom edge of the image, joints staggered randomly, at least 6 inch offset"}, + {Code: 3, Name: "人字拼 Herringbone", Category: "wood", Pattern: "planks at 90° forming continuous zigzag V-lines from bottom-left to top-right of the image"}, + {Code: 4, Name: "鱼骨拼 Chevron", Category: "wood", Pattern: "planks with 45° mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"}, } var tilePatterns = []PatternOption{ - {Code: 5, Name: "Straight Grid", Category: "tile", Pattern: "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines"}, - {Code: 6, Name: "Running Bond", Category: "tile", Pattern: "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance"}, - {Code: 7, Name: "1/3 Offset", Category: "tile", Pattern: "tiles offset by 1/3 of their length in each row, subtle stepped pattern"}, - {Code: 8, Name: "Hexagonal", Category: "tile", Pattern: "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh"}, - {Code: 9, Name: "Diagonal (45deg)", Category: "tile", Pattern: "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation"}, + {Code: 5, Name: "直铺(网格对缝)Straight Grid", Category: "tile", Pattern: "tiles aligned in rows (parallel to bottom edge) and columns (perpendicular to bottom edge), all grout lines continuous, no offset"}, + {Code: 6, Name: "工字铺(1/2错缝)Running Bond", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset 50% from previous, horizontal grout lines continuous, vertical grout lines staggered"}, + {Code: 7, Name: "三七错铺 1/3 Offset", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset exactly 33% of tile length, every third row aligns"}, + {Code: 8, Name: "六边形 Hexagonal", Category: "tile", Pattern: "six-sided tiles interlocked in honeycomb mesh, flat edges horizontal (parallel to bottom edge of image), grout follows hexagonal edges"}, + {Code: 9, Name: "斜铺(60度对角)Diagonal", Category: "tile", Pattern: "tiles rotated 60° to the image frame, grout lines run diagonally at 60° and 150° to the bottom edge, no lines parallel to image edges"}, } var roomOptions = []RoomOption{ - {Code: 0, Name: "Auto Detect"}, - {Code: 1, Name: "Living Room"}, - {Code: 2, Name: "Bedroom"}, - {Code: 3, Name: "Kitchen"}, - {Code: 4, Name: "Dining Room"}, - {Code: 5, Name: "Bathroom"}, - {Code: 6, Name: "Study Room"}, - {Code: 7, Name: "Hallway"}, + {Code: 0, Name: "自动识别"}, + {Code: 1, Name: "客厅"}, + {Code: 2, Name: "卧室"}, + {Code: 3, Name: "厨房"}, + {Code: 4, Name: "餐厅"}, + {Code: 5, Name: "浴室"}, + {Code: 6, Name: "书房"}, + {Code: 7, Name: "走廊"}, } func findPatternByCode(code int) *PatternOption { @@ -88,43 +92,109 @@ func findRoomByCode(code int) *RoomOption { return nil } -// loadFloorOptions loads floor options from the database, one per style_name per brand. -func loadFloorOptions(db *sql.DB, category string) ([]FloorOption, error) { - prods, _, err := repository.QueryFilteredProducts(db, repository.FilterParams{ - Category: category, - Limit: 200, - }) +// loadFloorOptions loads all products, deduplicated by brand+style_name. Variants +// (different sizes of the same style) are attached to the representative entry. +func loadFloorOptions(db *sql.DB) ([]FloorOption, error) { + // Use raw query without DISTINCT ON so all size variants are returned + rows, err := db.Query(`SELECT brand, group_name, sku, series_name, style_name, + category, material, color_tone, finish, price_per_sqft, price_tier, price_source, + is_official_price, main_image_url, room_image_url, source_url, description, status, + COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''), + size_label, COALESCE(width_in,0), COALESCE(length_in,0) + FROM products ORDER BY brand, style_name, id LIMIT 3000`) if err != nil { return nil, err } - // Dedup by brand+style_name, take first as representative - seen := map[string]bool{} + defer rows.Close() + prods, err := repository.ScanProducts(rows) + if err != nil { + return nil, err + } + type key struct{ brand, style string } + seen := map[key]int{} // key → index in out slice var out []FloorOption for _, p := range prods { - key := p.Brand + "|" + p.StyleName - if seen[key] { - continue + k := key{p.Brand, p.StyleName} + if idx, ok := seen[k]; ok { + // Add as variant to the representative entry + out[idx].Variants = append(out[idx].Variants, FloorOption{ + SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn, + SKU: p.SKU, + }) + + } else { + seen[k] = len(out) + out = append(out, FloorOption{ + ID: p.SKU, Name: p.StyleName, Category: p.Category, + Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, + SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn, + ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description, + }) } - seen[key] = true - out = append(out, FloorOption{ - ID: p.SKU, Name: p.StyleName, Category: p.Category, - Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, - SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU, - }) } return out, nil } +func isTileProduct(p FloorOption) bool { + m := strings.ToLower(p.Material) + return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") || + strings.Contains(m, "stone look") || m == "glass" +} +func isVinylProduct(p FloorOption) bool { + m := strings.ToLower(p.Material) + return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl" +} +func isLaminateProduct(p FloorOption) bool { + return strings.EqualFold(p.Material, "laminate") +} +func isWoodProduct(p FloorOption) bool { + return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p) +} + func findFloorBySKU(db *sql.DB, sku string) *FloorOption { p, err := repository.GetProductBySKU(db, sku) if err != nil { return nil } - return &FloorOption{ + f := &FloorOption{ ID: p.SKU, Name: p.StyleName, Category: p.Category, Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, - SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU, + SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn, + ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description, } + // Fetch size variants (same style, different dimensions) + variants, _ := repository.GetVariantsByStyle(db, sku) + for _, vp := range variants { + f.Variants = append(f.Variants, FloorOption{ + SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn, + SKU: vp.SKU, + }) + } + // Canonical reference image = smallest size variant (by width) + // All sizes of the same style use the same reference image to prevent color drift + canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn + rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products + WHERE style_name = $1 AND brand = $2 + ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`, + p.StyleName, p.Brand) + if rows != nil { + defer rows.Close() + if rows.Next() { + rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength) + } + } + f.ImageURL = canonicalImage + + // Build dynamic size instruction if selected size differs from reference image size + if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) { + if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn { + f.Description = fmt.Sprintf( + `SIZE MAPPING: The reference image shows %.1f"×%.1f" planks. Generate %.1f"×%.1f" planks — same exact material, only scale the board size proportionally. | %s`, + canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description, + ) + } + } + return f } // ── Public accessors for worker ──────────────────────────── @@ -145,6 +215,54 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st return buildFloorPrompt(f, p, room) } +// BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it. +// No separate mask generation step — the AI does both in one pass. +func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption) string { + floorPrompt := buildFloorPrompt(f, p, room) + roomCtx := "" + if room != nil && room.Code != 0 { + roomCtx = fmt.Sprintf(" This is a %s.", room.Name) + } + + return fmt.Sprintf( + `FIRST — identify the floor: Look at this room photo.%s +The floor is the flat horizontal walking surface — NOT walls, NOT baseboards, NOT furniture, NOT stairs. +You will replace ONLY the floor area. Every other pixel must remain bit-identical to the input photo. + +%s + +Return ONLY the completed image. No text.`, + roomCtx, + floorPrompt, + ) +} + +// buildPatternConstraint returns installation rules specific to each pattern code. +func buildPatternConstraint(code int) string { + switch code { + case 1: + return `PATTERN RULES — Straight Lay (wood): Planks run from the bottom-left of the image toward the top-right, at roughly 45° to the image frame. Long edges of each plank point toward the upper-right corner. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.` + case 2: + return `PATTERN RULES — Horizontal Lay (wood): Planks run horizontally — left to right, parallel to the bottom edge of the image. Long edges of each plank are horizontal across the photo. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.` + case 3: + return `PATTERN RULES — Herringbone (wood): Planks at 90° forming continuous zigzag. Each plank end meets neighbor at 90°. V-lines run from bottom-left to top-right of the image. Joints tight — continuous unbroken zigzag.` + case 4: + return `PATTERN RULES — Chevron (wood): Planks with 45° mitered ends form continuous V. V-points aligned in a straight line running bottom-left to top-right across the image. Joint lines are straight and continuous. NOT herringbone — mitered joints, not staggered.` + case 5: + return `PATTERN RULES — Straight Grid (tile): Tiles aligned in rows and columns. Grout lines run horizontally (parallel to bottom edge of image) and vertically (perpendicular to bottom edge). NO offset between rows. Grout ~1/8".` + case 6: + return `PATTERN RULES — Running Bond (tile): Each row offset 50% from previous — classic brickwork. Grout lines parallel to bottom edge of image are continuous; grout lines perpendicular to bottom edge are staggered. Grout ~1/8".` + case 7: + return `PATTERN RULES — 1/3 Offset (tile): Each row offset exactly 33% of tile length. Every third row aligns. Rows run parallel to bottom edge of image. Grout ~1/8". NOT 50% bond — offset is exactly 1/3.` + case 8: + return `PATTERN RULES — Hexagonal (tile): Six-sided tiles interlocked in honeycomb mesh. Each tile touches 6 neighbors. Flat edges of hexagons are horizontal (parallel to bottom edge of image). Grout follows hexagonal edges — network pattern, not grid.` + case 9: + return `PATTERN RULES — Diagonal (tile): Tiles rotated 60° relative to the image frame — all tiles at a steep diagonal, diamond orientation. Grout lines run diagonally across the photo at 60° and 150° to the bottom edge. None parallel or perpendicular to image edges. Triangle cuts at all walls. Grout ~1/8".` + default: + return "" + } +} + // ── Floor Generate Handler (Redis queue) ────────────────── func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) { @@ -256,14 +374,18 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) { func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error - woodFloors2, _ := loadFloorOptions(db, "Engineered Wood") - tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") - vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") - laminateFloors, _ := loadFloorOptions(db, "Laminate") - - allWood := append(woodFloors, woodFloors2...) - allTile := append(tileFloors, laminateFloors...) + all, _ := loadFloorOptions(db) + var allWood, allTile, vinylFloors []FloorOption + for _, f := range all { + switch { + case isVinylProduct(f): + vinylFloors = append(vinylFloors, f) + case isTileProduct(f) || isLaminateProduct(f): + allTile = append(allTile, f) + default: + allWood = append(allWood, f) + } + } writeJSON(w, 200, map[string]any{ "wood_floors": allWood, @@ -279,10 +401,25 @@ func FloorOptions(db *sql.DB) http.HandlerFunc { // ── Prompts ──────────────────────────────────────────────── func buildMaskPrompt(room *RoomOption) string { - base := `Create a pure black-and-white mask image for this room photo. -WHITE (#FFFFFF) areas: ONLY the flat horizontal floor. -BLACK (#000000) areas — everything else: stairs, baseboards, walls, furniture, doors, windows, ceiling, decorations. -Output ONLY the mask image. No text.` + base := `You are a precise floor segmentation tool. Create a pure black-and-white mask for this room photo. + +STRICT DEFINITIONS: +- WHITE (#FFFFFF): ONLY the flat, horizontal walking surface (the floor). Include the entire visible floor area up to the exact edge where it meets walls, baseboards, cabinets, or stairs. +- BLACK (#000000): EVERYTHING that is not flat horizontal floor — walls, baseboards/skirting boards, stairs/stair risers (they are NOT flat floor), furniture and furniture legs, doors and door frames, windows, cabinets, appliances, rugs/carpets, ceiling, decorations, people, pets. + +EDGE RULES: +- At the wall-floor boundary: cut exactly along the junction. The baseboard/skirting board is BLACK, the floor is WHITE. +- Furniture legs resting on the floor: the legs are BLACK. The floor visible BETWEEN and AROUND legs is WHITE. +- Stairs: each step's horizontal tread AND vertical riser are BLACK (stairs are not a continuous flat plane). +- Rugs/carpets on the floor: BLACK (they are not the permanent floor). + +SELF-CHECK before output: +- Did you include any wall area? → fix it. +- Did you miss any floor area between furniture legs? → fill it WHITE. +- Did you paint any stair surface WHITE? → make it BLACK. +- Are all edges sharp and precise at boundaries? → verify. + +Output ONLY the mask image. No text, no explanation.` if room != nil && room.Code != 0 { base += fmt.Sprintf("\nContext: This is a %s.", room.Name) } @@ -295,8 +432,7 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name) } matType := "wood plank" - isTile := floor.Category == "Wood-Look Tile" || floor.Category == "Laminate" - if isTile { + if isTileProduct(floor) || isLaminateProduct(floor) { matType = "tile" } @@ -305,12 +441,127 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel) } + // Include product description if available for richer visual detail + var descriptionText string + if floor.Description != "" { + descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description) + } + + productLock := "" + if floor.ImageURL != "" { + productLock = fmt.Sprintf( + `The reference image is the EXACT product material — not style inspiration, not a mood board. +LOCK THESE PROPERTIES from the reference image: base color, color temperature, grain/vein pattern, +knot density, surface texture, gloss level, material character. +FORBIDDEN: color shift, different wood species or stone type, new grain pattern, different stain, +gloss change, "luxury upgrade" or aesthetic reinterpretation.`, + ) + } + + // Size instruction: only change board proportion, never the material itself + sizeInstruction := "" + if len(floor.Variants) > 0 { + var variantList []string + for _, v := range floor.Variants { + marker := "" + if v.SKU == floor.SKU { + marker = " ← SELECTED" + } + if v.SizeLabel != "" { + variantList = append(variantList, v.SizeLabel+marker) + } else if v.WidthIn > 0 && v.LengthIn > 0 { + variantList = append(variantList, fmt.Sprintf(`%.1f"×%.1f"`, v.WidthIn, v.LengthIn)+marker) + } + } + if len(variantList) > 0 { + sizeInstruction = fmt.Sprintf( + "\nSIZE: This product comes in multiple sizes: %s.\nONLY change board width and seam spacing. The material texture, color, and grain are from the reference image — identical in every size. Wider boards use the SAME texture source, just with fewer seams. Do NOT reinterpret the wood to look \"more premium\" for wider planks.", + strings.Join(variantList, ", "), + ) + } + } else if floor.SizeLabel != "" { + sizeInstruction = fmt.Sprintf( + "\nSIZE: Each piece is %s. Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.", + floor.SizeLabel, + ) + } else if floor.WidthIn > 0 && floor.LengthIn > 0 { + sizeInstruction = fmt.Sprintf( + `\nSIZE: Each piece is %.1f" × %.1f". Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.`, + floor.WidthIn, floor.LengthIn, + ) + } + + patternInstruction := buildPatternConstraint(pattern.Code) + return fmt.Sprintf( - `Replace ONLY the white-masked floor area with %s (%s) %s flooring in a %s pattern.%s -Material details: %s | Layout: %s -Style: photorealistic, soft natural matte finish, minimal reflections, no mirror-like gloss, seamless blend with room lighting.`, - floor.Name, matType, materialDesc, pattern.Name, roomCtx, - materialDesc, pattern.Pattern, + `You are a flooring product visualization renderer for a commercial catalog. Your sole function is to replace the floor surface in interior photos with a specific flooring SKU. This is product-accurate catalog photography — NOT interior design or artistic interpretation. + +STRICT OUTPUT RULES: +1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper. +2. The output must have the exact same pixel dimensions as the input image. + Do not crop, pad, upscale, downscale, rotate, or alter the image geometry in any way. +3. Do not apply global filtering, sharpening, blurring, tone-mapping, contrast adjustment, + or style transfer. The only permitted change is the floor surface. + +FLOOR SURFACE DEFINITION: +- "Floor" — the flat horizontal walking surface. The visible ground plane inside the room. + DO NOT bleed onto: walls, baseboards/skirting boards, stairs, stair risers, furniture, + chair legs, table legs, cabinet bases, doors, door frames, window glass or frames, + trim, pipes, conduit, rugs, carpets, ceiling, appliances, decorations. +- Stairs are NOT floor — each tread and riser must remain completely unchanged. +- If a surface is not the flat horizontal floor, PRESERVE IT EXACTLY. + +FLOOR REPLACEMENT RULES: +4. Apply the new floor ONLY to the floor surface. Every pixel outside must be bit-identical to input. +5. Preserve the original room lighting, shadows, and highlights ON the new floor. +6. ADD realistic contact shadows: chair wheels, table legs, cabinet bases MUST cast tight dark shadows + directly at the contact point — sharpest there, softening outward. This shows physical weight. +7. Maintain clean but physically installed-looking floor edges — not a Photoshop cutout. +8. The new floor must look physically installed and photographed — not rendered, not composited. + +FIDELITY RULES: +9. The output must be a photorealistic edit of the input photograph. + Do not generate new content, synthesise areas, invent objects, or alter composition. +10. Preserve camera sensor noise, natural vignette, and original white balance on all non-floor surfaces. + +AMBIGUITY RULE: +11. If a pixel could be floor OR furniture/wall/baseboard — preserve the original unchanged. + Precision over completeness. + +FLOOR SPECIFICATION: +- Product: %s (%s %s) +- Pattern: %s — %s + The pattern changes layout only. The material comes from the reference image — identical regardless of pattern. +%s +- Material details: %s%s%s%s + +%s + +TEXTURE FIDELITY: +- Every plank/tile must have UNIQUE grain, vein, or tonal variation — no visible texture repetition. +- Natural imperfections: minor scratches, micro-wear near chair wheels, colour variance between pieces. +- Seams and joints: subtle dust in gaps, micro-shadow at edges, slight edge wear. +- Polished surfaces: reflections UNEVEN and angle-dependent — not uniform glossy sheen. +- Do NOT invent sunbeams, light rays, or window reflections not in the original. + +NEGATIVE CONSTRAINTS — the floor must NOT contain: +CGI look, 3D render, plastic texture, repeating patterns, uniform gloss, invented lights, +HDR tone-mapping, "luxury showroom" aesthetic, sterile clean seams, floating furniture, +color shift, different wood species, new grain, different stain, aesthetic reinterpretation. + +REQUIREMENTS SUMMARY: +✓ Only the flat horizontal floor changes — all other pixels preserved bit-identical. +✓ Contact shadows under all furniture — sharp at contact, softening outward. +✓ Original lighting and shadows match on new floor. +✓ Output geometry identical to input — same dimensions, no cropping. +✓ Scene composition unchanged — no new objects, no synthesised content. +✓ Material color, grain, gloss, species match the reference image exactly. + +Return ONLY the completed image. No text.`, + floor.Name, matType, materialDesc, + pattern.Name, pattern.Pattern, patternInstruction, + materialDesc, + roomCtx, descriptionText, sizeInstruction, productLock, ) } diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go index 25a4a42..c351886 100644 --- a/internal/handler/product_handler.go +++ b/internal/handler/product_handler.go @@ -26,6 +26,7 @@ type ProductItem struct { MainImageURL string `json:"main_image_url"` SizeLabel string `json:"size_label"` CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + IsOfficialPrice bool `json:"is_official_price"` IsFavorited bool `json:"is_favorited"` SpecCount int `json:"spec_count"` } @@ -152,6 +153,7 @@ func Products(db *sql.DB) http.HandlerFunc { PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, CoverageSqftPerBox: p.CoverageSqftPerBox, + IsOfficialPrice: p.IsOfficialPrice, IsFavorited: favSKUs[p.SKU], SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], }) diff --git a/internal/model/product.go b/internal/model/product.go index c5d3e45..7968124 100644 --- a/internal/model/product.go +++ b/internal/model/product.go @@ -1,31 +1,32 @@ package model type Product struct { - Brand string `json:"brand"` - GroupName string `json:"group_name"` - SKU string `json:"sku"` - SeriesName string `json:"series_name"` - StyleName string `json:"style_name"` - Category string `json:"category"` - Material string `json:"material"` - ColorTone string `json:"color_tone"` - Finish string `json:"finish"` - PricePerSqft float64 `json:"price_per_sqft"` - PriceTier string `json:"price_tier"` - PriceSource string `json:"price_source"` - MainImageURL string `json:"main_image_url"` - RoomImageURL string `json:"room_image_url"` - SourceURL string `json:"source_url"` - Description string `json:"description"` - Status string `json:"status"` - CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` - WoodSpecies string `json:"wood_species"` - SizeLabel string `json:"size_label"` - WidthIn float64 `json:"width_in"` - LengthIn float64 `json:"length_in"` - Variants []Product `json:"variants,omitempty"` - Specs []ProductSpec `json:"specs,omitempty"` - IsFavorited bool `json:"is_favorited"` + Brand string `json:"brand"` + GroupName string `json:"group_name"` + SKU string `json:"sku"` + SeriesName string `json:"series_name"` + StyleName string `json:"style_name"` + Category string `json:"category"` + Material string `json:"material"` + ColorTone string `json:"color_tone"` + Finish string `json:"finish"` + PricePerSqft float64 `json:"price_per_sqft"` + PriceTier string `json:"price_tier"` + PriceSource string `json:"price_source"` + IsOfficialPrice bool `json:"is_official_price"` + MainImageURL string `json:"main_image_url"` + RoomImageURL string `json:"room_image_url"` + SourceURL string `json:"source_url"` + Description string `json:"description"` + Status string `json:"status"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + WoodSpecies string `json:"wood_species"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + Variants []Product `json:"variants,omitempty"` + Specs []ProductSpec `json:"specs,omitempty"` + IsFavorited bool `json:"is_favorited"` } // ProductSpec is a single size/price variant of a product style. diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go index f2e872e..ff92d99 100644 --- a/internal/openrouter/image.go +++ b/internal/openrouter/image.go @@ -24,6 +24,8 @@ const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview" const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged." +const referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material." + var supportedAspectRatios = []struct { value string width int @@ -51,6 +53,8 @@ type ImageGenerationRequest struct { InputMimeType string MaskImagePath string MaskMimeType string + ReferenceImagePath string // optional: reference image for floor material style + ReferenceMimeType string AspectRatio string ImageSize string TopP *float64 @@ -284,7 +288,7 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err return nil, stderrors.New("openrouter: mask image requires input image") } - content := make([]ContentPart, 0, 4) + content := make([]ContentPart, 0, 6) if req.InputImagePath != "" { dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType) if err != nil { @@ -306,6 +310,17 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err ImageURL: &ImageURL{URL: dataURI}, }) } + if req.ReferenceImagePath != "" { + dataURI, err := imageFileDataURI(req.ReferenceImagePath, req.ReferenceMimeType) + if err != nil { + return nil, err + } + content = append(content, ContentPart{ + Type: "image_url", + ImageURL: &ImageURL{URL: dataURI}, + }) + content = append(content, ContentPart{Type: "text", Text: referenceImageInstruction}) + } content = append(content, ContentPart{Type: "text", Text: req.Prompt}) return content, nil } @@ -464,3 +479,42 @@ func decodeDataURL(dataURL string) (string, []byte, error) { return mimeType, data, nil } + +// MaxImageDimension is the maximum width or height for images sent to the AI API. +const MaxImageDimension = 2048 + +// ResizeImageToFit reads an image, scales it so the longest side ≤ maxDim pixels, +// and writes the result as PNG. +func ResizeImageToFit(inputPath, outputPath string) error { + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("resize: read input: %w", err) + } + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("resize: decode: %w", err) + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + longest := w + if h > longest { + longest = h + } + dst := src + if longest > MaxImageDimension { + scale := float64(MaxImageDimension) / float64(longest) + newW := int(float64(w)*scale + 0.5) + newH := int(float64(h)*scale + 0.5) + resized := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil) + dst = resized + } + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return fmt.Errorf("resize: encode png: %w", err) + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + return os.WriteFile(outputPath, buf.Bytes(), 0644) +} diff --git a/internal/queue/redis.go b/internal/queue/redis.go index 4036547..f4ad27f 100644 --- a/internal/queue/redis.go +++ b/internal/queue/redis.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "time" "github.com/redis/go-redis/v9" @@ -17,12 +18,14 @@ const ( // JobStatus represents the state of a generation job. type JobStatus struct { - JobID string `json:"job_id"` - Status string `json:"status"` // pending | processing | done | error - Step string `json:"step"` - Message string `json:"message"` - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` + JobID string `json:"job_id"` + Status string `json:"status"` // pending | processing | done | error + Step string `json:"step"` + Message string `json:"message"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + ElapsedMs int64 `json:"elapsed_ms,omitempty"` + TimingsMs map[string]int64 `json:"timings_ms,omitempty"` } // NewRedisClient creates a Redis client and pings to verify connectivity. @@ -42,7 +45,7 @@ func NewRedisClient(addr, password string) (*redis.Client, error) { func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error { pipe := rdb.Pipeline() pipe.RPush(ctx, queueKey, jobID) - pipe.HSet(ctx, jobKey(jobID), "status", "pending") + pipe.HSet(ctx, jobKey(jobID), "status", "pending", "queued_at", nowMillis(), "updated_at", nowMillis()) pipe.Expire(ctx, jobKey(jobID), jobTTL) _, err := pipe.Exec(ctx) return err @@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) { // SetJobStatus updates the job status field. func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", status).Err() + fields := []any{"status", status, "updated_at", nowMillis()} + if status == "processing" { + fields = append(fields, "started_at", nowMillis()) + } + return rdb.HSet(ctx, jobKey(jobID), fields...).Err() } // SetJobProgress updates the step and message for a job. func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error { - return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg).Err() + return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg, "updated_at", nowMillis()).Err() +} + +// SetJobTiming stores a single step duration and the total elapsed time so far. +func SetJobTiming(ctx context.Context, rdb *redis.Client, jobID, step string, duration, elapsed time.Duration) error { + return rdb.HSet(ctx, jobKey(jobID), + step+"_ms", duration.Milliseconds(), + "elapsed_ms", elapsed.Milliseconds(), + "updated_at", nowMillis(), + ).Err() } // SetJobResult marks a job as done with the result image URL. func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url, "updated_at", nowMillis()).Err() } // SetJobError marks a job as errored with an error message. func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg, "updated_at", nowMillis()).Err() } // GetJobStatus retrieves the full status of a job. @@ -90,17 +106,27 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta return nil, fmt.Errorf("job not found") } js := &JobStatus{ - JobID: jobID, - Status: m["status"], - Step: m["step"], - Message: m["message"], - Result: m["result"], - Error: m["error"], + JobID: jobID, + Status: m["status"], + Step: m["step"], + Message: m["message"], + Result: m["result"], + Error: m["error"], + ElapsedMs: parseInt64(m["elapsed_ms"]), + TimingsMs: map[string]int64{}, + } + for _, step := range []string{"check", "mask", "inpaint", "cleanup", "total"} { + if value := parseInt64(m[step+"_ms"]); value > 0 { + js.TimingsMs[step] = value + } + } + if len(js.TimingsMs) == 0 { + js.TimingsMs = nil } return js, nil } -// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path). +// StoreJobPayload saves the job's input parameters and render options. func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error { data, _ := json.Marshal(payload) return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err() @@ -120,3 +146,12 @@ func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[st func jobKey(id string) string { return jobKeyPrefix + id } + +func nowMillis() int64 { + return time.Now().UnixMilli() +} + +func parseInt64(raw string) int64 { + value, _ := strconv.ParseInt(raw, 10, 64) + return value +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index a36e21d..719b5b3 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -1,12 +1,17 @@ package queue import ( + "bytes" "context" "database/sql" + "encoding/json" "floorvisualizer/internal/logger" "fmt" + "io" + "net/http" "os" "path/filepath" + "strings" "time" "github.com/redis/go-redis/v9" @@ -44,7 +49,9 @@ func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrou } func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) { + totalStart := time.Now() SetJobStatus(ctx, rdb, jobID, "processing") + logger.Info("[%s] TIMER job start", jobID) // Load payload payload, err := GetJobPayload(ctx, rdb, jobID) @@ -58,7 +65,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien patternCode := payload["pattern_code"] roomCode := payload["room_code"] - // Look up floor and pattern from DB (same logic as before) + // Look up floor and pattern from DB floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) @@ -67,17 +74,27 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien SetJobError(ctx, rdb, jobID, "Invalid floor or pattern") return } + logger.Info("[%s] TIMER options floor_id=%s pattern_code=%s room_code=%s elapsed=%s", + jobID, floorID, patternCode, roomCode, time.Since(totalStart).Round(time.Millisecond)) + + // Resize input to max 2048px to speed up AI processing + workPath := filepath.Join("uploads", fmt.Sprintf("input_%s_resized.png", jobID)) + if err := openrouter.ResizeImageToFit(inputPath, workPath); err != nil { + logger.Warn("[%s] Failed to resize input, using original: %v", jobID, err) + workPath = inputPath + } - maskPath := filepath.Join("outputs", fmt.Sprintf("mask_%s.png", jobID)) outputPath := filepath.Join("outputs", fmt.Sprintf("result_%s.png", jobID)) - jobCtx, cancel := context.WithTimeout(ctx, 180*time.Second) + jobCtx, cancel := context.WithTimeout(ctx, 600*time.Second) defer cancel() // Step 0: content check SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") - logger.Info("[%s] Content check", jobID) - passed, score := validateContent(client, jobCtx, inputPath) + stepStart := time.Now() + logger.Info("[%s] TIMER step=check start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) + passed, score := validateContent(client, jobCtx, workPath) + logStepDone(jobCtx, rdb, jobID, "check", stepStart, totalStart, "score=%d passed=%t", score, passed) if score == 0 { logger.Warn("[%s] Content check failed (score=0)", jobID) } @@ -86,55 +103,94 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien return } - // Step 1: mask - SetJobProgress(jobCtx, rdb, jobID, "mask", "Generating floor mask...") - logger.Info("[%s] Mask generation", jobID) - maskPrompt := handler.BuildMaskPromptPublic(room) - if _, err := client.RecognizeImage(jobCtx, openrouter.ImageRecognitionRequest{ - Prompt: maskPrompt, InputImagePath: inputPath, OutputPath: maskPath, - }); err != nil { - SetJobError(jobCtx, rdb, jobID, "Mask generation failed: "+err.Error()) - return - } - - // Step 2: inpaint + // Step 1+2 combined: identify floor + replace in one call (no separate mask) roomLabel := "" if room != nil { roomLabel = " · " + room.Name } - SetJobProgress(jobCtx, rdb, jobID, "inpaint", fmt.Sprintf("Applying %s %s%s...", floor.Name, pattern.Name, roomLabel)) - logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name) + SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel)) + stepStart = time.Now() + logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s", + jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond)) - floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room) + refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + + imageSize := floorImageSize() + logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s", + jobID, imageSize, workPath, outputPath, refImagePath) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ - Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath, - MaskImagePath: maskPath, ImageSize: "2K", + Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, + ImageSize: imageSize, ReferenceImagePath: refImagePath, }) if err != nil { + logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err) SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) return } + logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "") // Clean up intermediate files + stepStart = time.Now() + logger.Info("[%s] TIMER step=cleanup start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) if err := os.Remove(inputPath); err != nil { logger.Warn("[%s] Failed to remove input: %v", jobID, err) } - if err := os.Remove(maskPath); err != nil { - logger.Warn("[%s] Failed to remove mask: %v", jobID, err) + if workPath != inputPath { + if err := os.Remove(workPath); err != nil { + logger.Warn("[%s] Failed to remove resized input: %v", jobID, err) + } + } + if refImagePath != "" { + if err := os.Remove(refImagePath); err != nil { + logger.Warn("[%s] Failed to remove reference image: %v", jobID, err) + } } + totalDuration := time.Since(totalStart) + _ = SetJobTiming(jobCtx, rdb, jobID, "total", totalDuration, totalDuration) SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath)) - logger.Info("[%s] Done", jobID) + logger.Info("[%s] TIMER job done total=%s result=%s", jobID, totalDuration.Round(time.Millisecond), "/"+filepath.ToSlash(outputPath)) +} + +func logStepDone(ctx context.Context, rdb *redis.Client, jobID, step string, stepStart, totalStart time.Time, format string, args ...any) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + if format != "" { + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s %s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), fmt.Sprintf(format, args...)) + return + } + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond)) +} + +func logStepError(ctx context.Context, rdb *redis.Client, jobID, step string, totalStart, stepStart time.Time, err error) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + logger.Error("[%s] TIMER step=%s error duration=%s elapsed=%s err=%v", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), err) } // validateContent checks if the image is an indoor room photo. -func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { +// Uses local CLIP ViT-B/32 first, falls back to Gemini 2.5 Flash if CLIP is unavailable. +func validateContent(geminiClient *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { imgData, err := os.ReadFile(imagePath) if err != nil { return false, 0 } + // Try local CLIP server first + if score, ok := clipCheck(imgData); ok { + logger.Info("Content check via local CLIP: score=%d passed=%t", score, score >= 6) + return score >= 6, score + } + // Fallback to Gemini + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() dataURI := "data:image/png;base64," + base64enc(imgData) - resp, err := client.Chat(ctx, openrouter.ChatRequest{ + resp, err := geminiClient.Chat(checkCtx, openrouter.ChatRequest{ Model: "google/gemini-2.5-flash", Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{ {Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}}, @@ -150,6 +206,31 @@ Return ONLY a number (1-10).`}, return score >= 6, score } +// clipCheck sends image bytes to the local CLIP server and returns (score, ok). +func clipCheck(imgData []byte) (int, bool) { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:5100/check", bytes.NewReader(imgData)) + if err != nil { + return 0, false + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, false + } + var result struct { + Score int `json:"score"` + Passed bool `json:"passed"` + } + if json.NewDecoder(resp.Body).Decode(&result) != nil { + return 0, false + } + return result.Score, true +} + func parseScore(raw string) int { for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} { if len(raw) >= len(s) && raw[:len(s)] == s { @@ -187,3 +268,47 @@ func base64enc(data []byte) string { } return string(b) } + +// downloadReferenceImage downloads the floor material image from the given URL +// and saves it to a local temp file. Returns the local file path, or empty string on failure. +func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string { + if imageURL == "" { + return "" + } + refPath := filepath.Join("outputs", fmt.Sprintf("ref_%s.png", jobID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + logger.Warn("[%s] Failed to create reference image request: %v", jobID, err) + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Warn("[%s] Failed to download reference image from %s: %v", jobID, imageURL, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logger.Warn("[%s] Reference image download returned HTTP %d from %s", jobID, resp.StatusCode, imageURL) + return "" + } + f, err := os.Create(refPath) + if err != nil { + logger.Warn("[%s] Failed to create reference image file: %v", jobID, err) + return "" + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + logger.Warn("[%s] Failed to save reference image: %v", jobID, err) + return "" + } + logger.Info("[%s] Downloaded reference image from %s", jobID, imageURL) + return refPath +} + +func floorImageSize() string { + value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE")) + if value == "" { + return "2K" + } + return value +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go index 4abfe53..da41f44 100644 --- a/internal/repository/product_repo.go +++ b/internal/repository/product_repo.go @@ -13,28 +13,27 @@ import ( const selectCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''), size_label, COALESCE(width_in,0), COALESCE(length_in,0)` const insertCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, coverage_sqft_per_box, wood_species, size_label, width_in, length_in` - // ── Import ────────────────────────────────────────────────── func InsertProducts(d *sql.DB, products []model.Product) error { for _, p := range products { _, err := d.Exec(` - INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) `, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, - p.PricePerSqft, p.PriceTier, p.PriceSource, + p.PricePerSqft, p.PriceTier, p.PriceSource, p.IsOfficialPrice, p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status, p.CoverageSqftPerBox, p.WoodSpecies, p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn)) @@ -46,7 +45,9 @@ func InsertProducts(d *sql.DB, products []model.Product) error { } func nf(v float64) interface{} { - if v == 0 { return nil } + if v == 0 { + return nil + } return v } @@ -54,12 +55,12 @@ func nf(v float64) interface{} { type FilterParams struct { SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string - Page, Limit int + Page, Limit int } type FilterOptions struct { - Categories []string `json:"categories"` - SeriesNames []string `json:"series_names"` + Categories []string `json:"categories"` + SeriesNames []string `json:"series_names"` Brands []model.BrandInfo `json:"brands"` } @@ -71,8 +72,12 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) { } func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { - if p.Limit <= 0 { p.Limit = 20 } - if p.Page <= 0 { p.Page = 1 } + if p.Limit <= 0 { + p.Limit = 20 + } + if p.Page <= 0 { + p.Page = 1 + } var conds []string var args []interface{} @@ -101,12 +106,16 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err fmt.Sprintf("brand ILIKE $%d", i+2), } conds = append(conds, "("+strings.Join(clauses, " OR ")+")") - for k := 0; k < 3; k++ { args = append(args, pat) } + for k := 0; k < 3; k++ { + args = append(args, pat) + } i += 3 } where := "" - if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") } + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } // Count distinct styles (dedup same brand+style_name across different sizes) var total int @@ -118,18 +127,29 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err args = append(args, p.Limit, offset) rows, err := d.Query(query, args...) - if err != nil { return nil, 0, err } + if err != nil { + return nil, 0, err + } defer rows.Close() - prods, err := scanProducts(rows) + prods, err := ScanProducts(rows) return prods, total, err } func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) { - var p model.Product - if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil { + rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku) + if err != nil { return nil, err } - return &p, nil + defer rows.Close() + + prods, err := ScanProducts(rows) + if err != nil { + return nil, err + } + if len(prods) == 0 { + return nil, sql.ErrNoRows + } + return &prods[0], nil } // GetVariantsByStyle returns all products with the same brand+style_name but different SKU. @@ -138,13 +158,14 @@ func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) { SELECT `+selectCols+` FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) - AND series_name = (SELECT series_name FROM products WHERE sku = $1) AND sku != $1 ORDER BY width_in, length_in `, sku) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() - return scanProducts(rows) + return ScanProducts(rows) } func VariantCount(d *sql.DB, sku string) int { @@ -190,13 +211,22 @@ func GetSpecCounts(d *sql.DB, skus []string) map[string]int { func GetFilterOptions(d *sql.DB) (*FilterOptions, error) { opts := &FilterOptions{} - for _, q := range []struct{ query string; out *[]string }{ + for _, q := range []struct { + query string + out *[]string + }{ {"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories}, {"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames}, } { rows, err := d.Query(q.query) - if err != nil { return nil, err } - for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) } + if err != nil { + return nil, err + } + for rows.Next() { + var v string + rows.Scan(&v) + *q.out = append(*q.out, v) + } rows.Close() } opts.Brands = GetBrandInfos(d) @@ -254,18 +284,20 @@ func GetBrandInfos(d *sql.DB) []model.BrandInfo { return out } -func scanProducts(rows *sql.Rows) ([]model.Product, error) { +func ScanProducts(rows *sql.Rows) ([]model.Product, error) { var out []model.Product for rows.Next() { var p model.Product if err := rows.Scan( &p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName, &p.Category, &p.Material, &p.ColorTone, &p.Finish, - &p.PricePerSqft, &p.PriceTier, &p.PriceSource, + &p.PricePerSqft, &p.PriceTier, &p.PriceSource, &p.IsOfficialPrice, &p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status, &p.CoverageSqftPerBox, &p.WoodSpecies, &p.SizeLabel, &p.WidthIn, &p.LengthIn, - ); err != nil { return nil, err } + ); err != nil { + return nil, err + } out = append(out, p) } return out, rows.Err() diff --git a/main.go b/main.go index a4d2a59..fa309e2 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ import ( "strconv" "time" + "github.com/joho/godotenv" + "floorvisualizer/internal/api" "floorvisualizer/internal/handler" "floorvisualizer/internal/logger" @@ -37,6 +39,8 @@ func init() { } func main() { + godotenv.Load() + logDir := "logs" if v := os.Getenv("LOG_DIR"); v != "" { logDir = v @@ -85,6 +89,7 @@ func main() { if v := os.Getenv("REDIS_ADDR"); v != "" { redisAddr = v } + var cancelWorkers context.CancelFunc rdb, err := queue.NewRedisClient(redisAddr, "") if err != nil { logger.Warn("Redis not available (%v), worker queue disabled", err) @@ -119,19 +124,11 @@ func main() { workerCount = n } } - workerCtx, cancelWorkers := context.WithCancel(context.Background()) + workerCtx, cancel := context.WithCancel(context.Background()) + cancelWorkers = cancel defer cancelWorkers() queue.StartWorkers(workerCtx, workerCount, rdb, client, db) logger.Info("Worker pool started: %d workers", workerCount) - - // Graceful shutdown - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt) - <-sig - logger.Info("Shutting down workers...") - cancelWorkers() - }() } // ── Hot product cache ──────────────────────────────── @@ -171,6 +168,31 @@ func main() { if p := os.Getenv("PORT"); p != "" { port = p } + + server := &http.Server{ + Addr: ":" + port, + Handler: middleware.Logging(mux), + } + + // Graceful shutdown: Ctrl+C stops HTTP server + worker pool + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + logger.Info("Shutting down...") + if cancelWorkers != nil { + cancelWorkers() + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Warn("HTTP server forced shutdown: %v", err) + } + }() + logger.Info("FloorVisualizer -> http://localhost:%s", port) - log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux))) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } + logger.Info("Server stopped gracefully") } diff --git a/schema.sql b/schema.sql index cd22e3f..bf44a97 100644 --- a/schema.sql +++ b/schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS products ( price_per_sqft NUMERIC(10,2) DEFAULT 0, price_tier TEXT DEFAULT '', price_source TEXT DEFAULT '', + is_official_price BOOLEAN DEFAULT false, main_image_url TEXT DEFAULT '', room_image_url TEXT DEFAULT '', source_url TEXT DEFAULT '',