dev #2

Merged
dindang merged 11 commits from dev into main 2026-07-27 08:12:46 +00:00
17 changed files with 949 additions and 225 deletions
Showing only changes of commit 5e35532dbb - Show all commits

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ __MACOSX/
# Claude # Claude
.claude/ .claude/
# Env
.env

View File

@ -232,6 +232,10 @@
"spec_count": { "spec_count": {
"type": "integer", "type": "integer",
"description": "该花色的规格数量同品牌同花色的SKU数含自身。>1 表示有多种尺寸可选" "description": "该花色的规格数量同品牌同花色的SKU数含自身。>1 表示有多种尺寸可选"
},
"is_official_price": {
"type": "boolean",
"description": "是否为官方定价。true=品牌官网标价false=市场估价或未找到"
} }
} }
}, },
@ -407,6 +411,10 @@
"is_favorited": { "is_favorited": {
"type": "boolean", "type": "boolean",
"description": "当前用户是否已收藏。未登录时始终为 false" "description": "当前用户是否已收藏。未登录时始终为 false"
},
"is_official_price": {
"type": "boolean",
"description": "是否为官方定价。true=品牌官网标价false=市场估价或未找到"
} }
} }
}, },
@ -616,7 +624,7 @@
}, },
"FloorStyleOption": { "FloorStyleOption": {
"type": "object", "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": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
@ -628,23 +636,31 @@
}, },
"name": { "name": {
"type": "string", "type": "string",
"description": "Style name" "description": "Style name (花色名称)"
}, },
"material": { "material": {
"type": "string", "type": "string",
"description": "Material (used in AI prompt)" "description": "Material (材质,如 Oak/Porcelain/Luxury Vinyl)"
}, },
"color_tone": { "color_tone": {
"type": "string", "type": "string",
"description": "Color tone (used in AI prompt)" "description": "Color tone (色调,如 Light Gray/Natural Oak/Dark Brown)"
}, },
"finish": { "finish": {
"type": "string", "type": "string",
"description": "Finish (used in AI prompt)" "description": "Finish (表面处理,如 Matte/Gloss/Textured)"
}, },
"size_label": { "size_label": {
"type": "string", "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": { "image_url": {
"type": "string", "type": "string",
@ -652,7 +668,24 @@
}, },
"category": { "category": {
"type": "string", "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": { "items": {
"$ref": "#/components/schemas/FloorStyleOption" "$ref": "#/components/schemas/FloorStyleOption"
}, },
"description": "Hardwood + Engineered Wood products" "description": "Wood products (Oak/Ash/Hickory/Maple etc.) — classified by material field"
}, },
"tile_floors": { "tile_floors": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/FloorStyleOption" "$ref": "#/components/schemas/FloorStyleOption"
}, },
"description": "Wood-Look Tile + Laminate products" "description": "Tile/Stone (Porcelain/Stone Look/Glass) + Laminate products"
}, },
"vinyl_floors": { "vinyl_floors": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/FloorStyleOption" "$ref": "#/components/schemas/FloorStyleOption"
}, },
"description": "SPC/LVP products" "description": "Vinyl products (Luxury Vinyl/Rigid Core)"
}, },
"wood_patterns": { "wood_patterns": {
"type": "array", "type": "array",
@ -1817,8 +1850,8 @@
"tags": [ "tags": [
"地板更换" "地板更换"
], ],
"summary": "获取地板样式选项(从产品库动态加载", "summary": "获取地板样式选项(从产品库动态加载,含尺寸变体",
"description": "从产品数据库3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明\n- wood_floors实木+实木复合产品\n- tile_floors瓷砖仿木纹+强化复合产品\n- vinyl_floorsSPC/LVP产品\n- wood_patterns / tile_patterns铺设方式\n- rooms房间类型", "description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU含 width_in/length_in/description/variants 等字段。\n\n材质分类按 material 字段自动归类):\n- wood_floorsOak/Ash/Hickory/Maple 等木材\n- tile_floorsPorcelain/Stone Look/Glass + Laminate\n- vinyl_floorsLuxury Vinyl/Rigid Core\n\n前端应使用 variants 渲染尺寸选择面板。",
"responses": { "responses": {
"200": { "200": {
"description": "成功", "description": "成功",
@ -1842,59 +1875,95 @@
"wood_floors": [], "wood_floors": [],
"tile_floors": [], "tile_floors": [],
"vinyl_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": [ "wood_patterns": [
{ {
"code": 1, "code": 1,
"name": "Straight Lay", "name": "直铺(工字拼)Straight Lay",
"pattern": "straight/parallel planks...", "pattern": "straight/parallel planks running from front to back with staggered joints",
"category": "wood" "category": "wood"
}, },
{ {
"code": 2, "code": 2,
"name": "Horizontal Lay", "name": "横铺 Horizontal Lay",
"pattern": "horizontal planks...", "pattern": "horizontal planks running side to side across the width of the room",
"category": "wood" "category": "wood"
}, },
{ {
"code": 3, "code": 3,
"name": "Herringbone", "name": "人字拼 Herringbone",
"pattern": "herringbone/zigzag pattern...", "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape",
"category": "wood" "category": "wood"
}, },
{ {
"code": 4, "code": 4,
"name": "Chevron", "name": "鱼骨拼 Chevron",
"pattern": "chevron pattern...", "pattern": "chevron pattern with planks cut at an angle forming a continuous V shape",
"category": "wood" "category": "wood"
} }
], ],
"tile_patterns": [ "tile_patterns": [
{ {
"code": 5, "code": 5,
"name": "Straight Grid", "name": "直铺(网格对缝)Straight Grid",
"pattern": "straight grid layout...", "pattern": "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines",
"category": "tile" "category": "tile"
}, },
{ {
"code": 6, "code": 6,
"name": "Running Bond", "name": "工字铺1/2错缝Running Bond",
"pattern": "running bond/brick pattern...", "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" "category": "tile"
} }
], ],
"rooms": [ "rooms": [
{ { "code": 0, "name": "自动识别" },
"code": 0, { "code": 1, "name": "客厅" },
"name": "Auto Detect" { "code": 2, "name": "卧室" },
}, { "code": 3, "name": "厨房" },
{ { "code": 4, "name": "餐厅" },
"code": 1, { "code": 5, "name": "浴室" },
"name": "Living Room" { "code": 6, "name": "书房" },
}, { "code": 7, "name": "走廊" }
{
"code": 2,
"name": "Bedroom"
}
] ]
} }
} }
@ -1909,8 +1978,8 @@
"tags": [ "tags": [
"地板更换" "地板更换"
], ],
"summary": "AI换地板使用真实产品数据", "summary": "AI换地板使用真实产品数据,含尺寸/变体",
"description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度", "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。用 GET /floor/status?job_id=xxx 查询进度。\n\nfloor_id 支持有尺寸变体的产品——选择变体对应 SKU 即可AI 会根据 width_in/length_in 生成对应物理尺寸的地板。",
"requestBody": { "requestBody": {
"content": { "content": {
"multipart/form-data": { "multipart/form-data": {
@ -1940,6 +2009,16 @@
"type": "integer", "type": "integer",
"description": "房间类型编码0-7见文档顶部数字编码对照表。0 或省略表示自动检测", "description": "房间类型编码0-7见文档顶部数字编码对照表。0 或省略表示自动检测",
"example": 1 "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": [ "tags": [
"地板更换" "地板更换"
], ],
"summary": "查询生成进度HTTP 轮询", "summary": "查询生成进度HTTP 轮询,含耗时统计",
"description": "Redis 异步队列任务状态查询。status: pending | processing | done | error", "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error。\n\n返回 timings_ms 字段包含每步耗时check/mask/inpaint/cleanup/total。elapsed_ms 为总运行时间。",
"parameters": [ "parameters": [
{ {
"name": "job_id", "name": "job_id",
@ -2219,7 +2298,51 @@
], ],
"responses": { "responses": {
"200": { "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 }
}
}
}
}
} }
} }
} }

View File

@ -1,21 +1,52 @@
package main package main
import ( import (
"encoding/json"; "fmt"; "os" "encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"floorvisualizer/internal/model" "floorvisualizer/internal/model"
"floorvisualizer/internal/repository" "floorvisualizer/internal/repository"
) )
func main() { 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() defer db.Close()
db.Exec("DELETE FROM products") db.Exec("DELETE FROM products")
files, _ := os.ReadDir("data/products") files, err := os.ReadDir(dataDir)
if err != nil {
panic(err)
}
total := 0 total := 0
for _, f := range files { for _, f := range files {
if f.IsDir() { continue } if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") {
data, _ := os.ReadFile("data/products/" + f.Name()) continue
}
data, err := os.ReadFile(filepath.Join(dataDir, f.Name()))
if err != nil {
panic(err)
}
var prods []model.Product var prods []model.Product
json.Unmarshal(data, &prods) if err := json.Unmarshal(data, &prods); err != nil {
repository.InsertProducts(db, prods) panic(fmt.Errorf("parse %s: %w", f.Name(), err))
}
if err := repository.InsertProducts(db, prods); err != nil {
panic(err)
}
total += len(prods) total += len(prods)
fmt.Printf(" %s: %d\n", f.Name(), len(prods)) fmt.Printf(" %s: %d\n", f.Name(), len(prods))
} }

7
go.mod
View File

@ -5,7 +5,10 @@ go 1.26.4
require ( require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/redis/go-redis/v9 v9.21.0
golang.org/x/image v0.43.0 golang.org/x/image v0.43.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
) )
require ( require (
@ -16,11 +19,9 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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 go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.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
) )

23
go.sum
View File

@ -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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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.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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= 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/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 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= 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/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.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.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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 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/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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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/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.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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 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 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@ -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))
}

View File

@ -0,0 +1,6 @@
package api
import _ "embed"
//go:embed index.html
var indexHTML string

View File

@ -63,6 +63,7 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
mux.HandleFunc("/articles/recommend", articleRecommend) mux.HandleFunc("/articles/recommend", articleRecommend)
mux.HandleFunc("/articles", articles) mux.HandleFunc("/articles", articles)
mux.HandleFunc("/articles/", articlesSub) mux.HandleFunc("/articles/", articlesSub)
mux.HandleFunc("/", Index)
// Static files // Static files
mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs"))))

View File

@ -18,15 +18,19 @@ import (
// ── Floor options ────────────────────────────────────────── // ── Floor options ──────────────────────────────────────────
type FloorOption struct { type FloorOption struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` Category string `json:"category"`
Material string `json:"material"` Material string `json:"material"`
ColorTone string `json:"color_tone"` ColorTone string `json:"color_tone"`
Finish string `json:"finish"` Finish string `json:"finish"`
SizeLabel string `json:"size_label"` SizeLabel string `json:"size_label"`
ImageURL string `json:"image_url"` WidthIn float64 `json:"width_in"`
SKU string `json:"sku"` 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 { type PatternOption struct {
@ -42,29 +46,29 @@ type RoomOption struct {
} }
var woodPatterns = []PatternOption{ var woodPatterns = []PatternOption{
{Code: 1, Name: "Straight Lay", Category: "wood", Pattern: "straight/parallel planks running from front to back with staggered joints"}, {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: "horizontal planks running side to side across the width of the room"}, {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: "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape"}, {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: "chevron pattern with planks cut at an angle forming a continuous V shape"}, {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{ 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: 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: "Running Bond", Category: "tile", Pattern: "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance"}, {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: "tiles offset by 1/3 of their length in each row, subtle stepped pattern"}, {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: "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh"}, {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: "Diagonal (45deg)", Category: "tile", Pattern: "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation"}, {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{ var roomOptions = []RoomOption{
{Code: 0, Name: "Auto Detect"}, {Code: 0, Name: "自动识别"},
{Code: 1, Name: "Living Room"}, {Code: 1, Name: "客厅"},
{Code: 2, Name: "Bedroom"}, {Code: 2, Name: "卧室"},
{Code: 3, Name: "Kitchen"}, {Code: 3, Name: "厨房"},
{Code: 4, Name: "Dining Room"}, {Code: 4, Name: "餐厅"},
{Code: 5, Name: "Bathroom"}, {Code: 5, Name: "浴室"},
{Code: 6, Name: "Study Room"}, {Code: 6, Name: "书房"},
{Code: 7, Name: "Hallway"}, {Code: 7, Name: "走廊"},
} }
func findPatternByCode(code int) *PatternOption { func findPatternByCode(code int) *PatternOption {
@ -88,43 +92,109 @@ func findRoomByCode(code int) *RoomOption {
return nil return nil
} }
// loadFloorOptions loads floor options from the database, one per style_name per brand. // loadFloorOptions loads all products, deduplicated by brand+style_name. Variants
func loadFloorOptions(db *sql.DB, category string) ([]FloorOption, error) { // (different sizes of the same style) are attached to the representative entry.
prods, _, err := repository.QueryFilteredProducts(db, repository.FilterParams{ func loadFloorOptions(db *sql.DB) ([]FloorOption, error) {
Category: category, // Use raw query without DISTINCT ON so all size variants are returned
Limit: 200, 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 { if err != nil {
return nil, err return nil, err
} }
// Dedup by brand+style_name, take first as representative defer rows.Close()
seen := map[string]bool{} 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 var out []FloorOption
for _, p := range prods { for _, p := range prods {
key := p.Brand + "|" + p.StyleName k := key{p.Brand, p.StyleName}
if seen[key] { if idx, ok := seen[k]; ok {
continue // 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 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 { func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku) p, err := repository.GetProductBySKU(db, sku)
if err != nil { if err != nil {
return nil return nil
} }
return &FloorOption{ f := &FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category, ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, 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 ──────────────────────────── // ── Public accessors for worker ────────────────────────────
@ -145,6 +215,54 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st
return buildFloorPrompt(f, p, room) 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) ────────────────── // ── Floor Generate Handler (Redis queue) ──────────────────
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) { 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 { func FloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error all, _ := loadFloorOptions(db)
woodFloors2, _ := loadFloorOptions(db, "Engineered Wood") var allWood, allTile, vinylFloors []FloorOption
tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") for _, f := range all {
vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") switch {
laminateFloors, _ := loadFloorOptions(db, "Laminate") case isVinylProduct(f):
vinylFloors = append(vinylFloors, f)
allWood := append(woodFloors, woodFloors2...) case isTileProduct(f) || isLaminateProduct(f):
allTile := append(tileFloors, laminateFloors...) allTile = append(allTile, f)
default:
allWood = append(allWood, f)
}
}
writeJSON(w, 200, map[string]any{ writeJSON(w, 200, map[string]any{
"wood_floors": allWood, "wood_floors": allWood,
@ -279,10 +401,25 @@ func FloorOptions(db *sql.DB) http.HandlerFunc {
// ── Prompts ──────────────────────────────────────────────── // ── Prompts ────────────────────────────────────────────────
func buildMaskPrompt(room *RoomOption) string { func buildMaskPrompt(room *RoomOption) string {
base := `Create a pure black-and-white mask image for this room photo. base := `You are a precise floor segmentation tool. Create a pure black-and-white mask 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. STRICT DEFINITIONS:
Output ONLY the mask image. No text.` - 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 { if room != nil && room.Code != 0 {
base += fmt.Sprintf("\nContext: This is a %s.", room.Name) 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) roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name)
} }
matType := "wood plank" matType := "wood plank"
isTile := floor.Category == "Wood-Look Tile" || floor.Category == "Laminate" if isTileProduct(floor) || isLaminateProduct(floor) {
if isTile {
matType = "tile" matType = "tile"
} }
@ -305,12 +441,127 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption
materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel) 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( return fmt.Sprintf(
`Replace ONLY the white-masked floor area with %s (%s) %s flooring in a %s pattern.%s `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.
Material details: %s | Layout: %s
Style: photorealistic, soft natural matte finish, minimal reflections, no mirror-like gloss, seamless blend with room lighting.`, STRICT OUTPUT RULES:
floor.Name, matType, materialDesc, pattern.Name, roomCtx, 1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper.
materialDesc, pattern.Pattern, 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,
) )
} }

View File

@ -26,6 +26,7 @@ type ProductItem struct {
MainImageURL string `json:"main_image_url"` MainImageURL string `json:"main_image_url"`
SizeLabel string `json:"size_label"` SizeLabel string `json:"size_label"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
IsOfficialPrice bool `json:"is_official_price"`
IsFavorited bool `json:"is_favorited"` IsFavorited bool `json:"is_favorited"`
SpecCount int `json:"spec_count"` SpecCount int `json:"spec_count"`
} }
@ -152,6 +153,7 @@ func Products(db *sql.DB) http.HandlerFunc {
PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier,
MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel,
CoverageSqftPerBox: p.CoverageSqftPerBox, CoverageSqftPerBox: p.CoverageSqftPerBox,
IsOfficialPrice: p.IsOfficialPrice,
IsFavorited: favSKUs[p.SKU], IsFavorited: favSKUs[p.SKU],
SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName],
}) })

View File

@ -1,31 +1,32 @@
package model package model
type Product struct { type Product struct {
Brand string `json:"brand"` Brand string `json:"brand"`
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
SKU string `json:"sku"` SKU string `json:"sku"`
SeriesName string `json:"series_name"` SeriesName string `json:"series_name"`
StyleName string `json:"style_name"` StyleName string `json:"style_name"`
Category string `json:"category"` Category string `json:"category"`
Material string `json:"material"` Material string `json:"material"`
ColorTone string `json:"color_tone"` ColorTone string `json:"color_tone"`
Finish string `json:"finish"` Finish string `json:"finish"`
PricePerSqft float64 `json:"price_per_sqft"` PricePerSqft float64 `json:"price_per_sqft"`
PriceTier string `json:"price_tier"` PriceTier string `json:"price_tier"`
PriceSource string `json:"price_source"` PriceSource string `json:"price_source"`
MainImageURL string `json:"main_image_url"` IsOfficialPrice bool `json:"is_official_price"`
RoomImageURL string `json:"room_image_url"` MainImageURL string `json:"main_image_url"`
SourceURL string `json:"source_url"` RoomImageURL string `json:"room_image_url"`
Description string `json:"description"` SourceURL string `json:"source_url"`
Status string `json:"status"` Description string `json:"description"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` Status string `json:"status"`
WoodSpecies string `json:"wood_species"` CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
SizeLabel string `json:"size_label"` WoodSpecies string `json:"wood_species"`
WidthIn float64 `json:"width_in"` SizeLabel string `json:"size_label"`
LengthIn float64 `json:"length_in"` WidthIn float64 `json:"width_in"`
Variants []Product `json:"variants,omitempty"` LengthIn float64 `json:"length_in"`
Specs []ProductSpec `json:"specs,omitempty"` Variants []Product `json:"variants,omitempty"`
IsFavorited bool `json:"is_favorited"` Specs []ProductSpec `json:"specs,omitempty"`
IsFavorited bool `json:"is_favorited"`
} }
// ProductSpec is a single size/price variant of a product style. // ProductSpec is a single size/price variant of a product style.

View File

@ -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 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 { var supportedAspectRatios = []struct {
value string value string
width int width int
@ -51,6 +53,8 @@ type ImageGenerationRequest struct {
InputMimeType string InputMimeType string
MaskImagePath string MaskImagePath string
MaskMimeType string MaskMimeType string
ReferenceImagePath string // optional: reference image for floor material style
ReferenceMimeType string
AspectRatio string AspectRatio string
ImageSize string ImageSize string
TopP *float64 TopP *float64
@ -284,7 +288,7 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err
return nil, stderrors.New("openrouter: mask image requires input image") return nil, stderrors.New("openrouter: mask image requires input image")
} }
content := make([]ContentPart, 0, 4) content := make([]ContentPart, 0, 6)
if req.InputImagePath != "" { if req.InputImagePath != "" {
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType) dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
if err != nil { if err != nil {
@ -306,6 +310,17 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err
ImageURL: &ImageURL{URL: dataURI}, 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}) content = append(content, ContentPart{Type: "text", Text: req.Prompt})
return content, nil return content, nil
} }
@ -464,3 +479,42 @@ func decodeDataURL(dataURL string) (string, []byte, error) {
return mimeType, data, nil 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)
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@ -17,12 +18,14 @@ const (
// JobStatus represents the state of a generation job. // JobStatus represents the state of a generation job.
type JobStatus struct { type JobStatus struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
Status string `json:"status"` // pending | processing | done | error Status string `json:"status"` // pending | processing | done | error
Step string `json:"step"` Step string `json:"step"`
Message string `json:"message"` Message string `json:"message"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
Error string `json:"error,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. // 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 { func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error {
pipe := rdb.Pipeline() pipe := rdb.Pipeline()
pipe.RPush(ctx, queueKey, jobID) 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) pipe.Expire(ctx, jobKey(jobID), jobTTL)
_, err := pipe.Exec(ctx) _, err := pipe.Exec(ctx)
return err return err
@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) {
// SetJobStatus updates the job status field. // SetJobStatus updates the job status field.
func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error { 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. // SetJobProgress updates the step and message for a job.
func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error { 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. // SetJobResult marks a job as done with the result image URL.
func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error { 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. // SetJobError marks a job as errored with an error message.
func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error { 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. // 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") return nil, fmt.Errorf("job not found")
} }
js := &JobStatus{ js := &JobStatus{
JobID: jobID, JobID: jobID,
Status: m["status"], Status: m["status"],
Step: m["step"], Step: m["step"],
Message: m["message"], Message: m["message"],
Result: m["result"], Result: m["result"],
Error: m["error"], 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 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 { func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error {
data, _ := json.Marshal(payload) data, _ := json.Marshal(payload)
return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err() 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 { func jobKey(id string) string {
return jobKeyPrefix + id return jobKeyPrefix + id
} }
func nowMillis() int64 {
return time.Now().UnixMilli()
}
func parseInt64(raw string) int64 {
value, _ := strconv.ParseInt(raw, 10, 64)
return value
}

View File

@ -1,12 +1,17 @@
package queue package queue
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"floorvisualizer/internal/logger" "floorvisualizer/internal/logger"
"fmt" "fmt"
"io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/redis/go-redis/v9" "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) { func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) {
totalStart := time.Now()
SetJobStatus(ctx, rdb, jobID, "processing") SetJobStatus(ctx, rdb, jobID, "processing")
logger.Info("[%s] TIMER job start", jobID)
// Load payload // Load payload
payload, err := GetJobPayload(ctx, rdb, jobID) 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"] patternCode := payload["pattern_code"]
roomCode := payload["room_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) floor := handler.FindFloorBySKUPublic(db, floorID)
pattern := handler.FindPatternByCodePublic(patternCode) pattern := handler.FindPatternByCodePublic(patternCode)
room := handler.FindRoomByCodePublic(roomCode) 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") SetJobError(ctx, rdb, jobID, "Invalid floor or pattern")
return 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)) 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() defer cancel()
// Step 0: content check // Step 0: content check
SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...")
logger.Info("[%s] Content check", jobID) stepStart := time.Now()
passed, score := validateContent(client, jobCtx, inputPath) 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 { if score == 0 {
logger.Warn("[%s] Content check failed (score=0)", jobID) 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 return
} }
// Step 1: mask // Step 1+2 combined: identify floor + replace in one call (no separate 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
roomLabel := "" roomLabel := ""
if room != nil { if room != nil {
roomLabel = " · " + room.Name roomLabel = " · " + room.Name
} }
SetJobProgress(jobCtx, rdb, jobID, "inpaint", fmt.Sprintf("Applying %s %s%s...", floor.Name, pattern.Name, roomLabel)) SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel))
logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name) 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{ _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{
Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath, Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath,
MaskImagePath: maskPath, ImageSize: "2K", ImageSize: imageSize, ReferenceImagePath: refImagePath,
}) })
if err != nil { if err != nil {
logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err)
SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error())
return return
} }
logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "")
// Clean up intermediate files // 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 { if err := os.Remove(inputPath); err != nil {
logger.Warn("[%s] Failed to remove input: %v", jobID, err) logger.Warn("[%s] Failed to remove input: %v", jobID, err)
} }
if err := os.Remove(maskPath); err != nil { if workPath != inputPath {
logger.Warn("[%s] Failed to remove mask: %v", jobID, err) 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)) 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. // 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) imgData, err := os.ReadFile(imagePath)
if err != nil { if err != nil {
return false, 0 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) 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", Model: "google/gemini-2.5-flash",
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{ Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}}, {Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
@ -150,6 +206,31 @@ Return ONLY a number (1-10).`},
return score >= 6, score 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 { func parseScore(raw string) int {
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} { for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
if len(raw) >= len(s) && raw[:len(s)] == s { if len(raw) >= len(s) && raw[:len(s)] == s {
@ -187,3 +268,47 @@ func base64enc(data []byte) string {
} }
return string(b) 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
}

View File

@ -13,28 +13,27 @@ import (
const selectCols = `brand, group_name, sku, series_name, style_name, const selectCols = `brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish, 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, main_image_url, room_image_url, source_url, description, status,
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''), COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
size_label, COALESCE(width_in,0), COALESCE(length_in,0)` size_label, COALESCE(width_in,0), COALESCE(length_in,0)`
const insertCols = `brand, group_name, sku, series_name, style_name, const insertCols = `brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish, 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, main_image_url, room_image_url, source_url, description, status,
coverage_sqft_per_box, wood_species, coverage_sqft_per_box, wood_species,
size_label, width_in, length_in` size_label, width_in, length_in`
// ── Import ────────────────────────────────────────────────── // ── Import ──────────────────────────────────────────────────
func InsertProducts(d *sql.DB, products []model.Product) error { func InsertProducts(d *sql.DB, products []model.Product) error {
for _, p := range products { for _, p := range products {
_, err := d.Exec(` _, 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.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName,
p.Category, p.Material, p.ColorTone, p.Finish, 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.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status,
p.CoverageSqftPerBox, p.WoodSpecies, p.CoverageSqftPerBox, p.WoodSpecies,
p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn)) 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{} { func nf(v float64) interface{} {
if v == 0 { return nil } if v == 0 {
return nil
}
return v return v
} }
@ -54,12 +55,12 @@ func nf(v float64) interface{} {
type FilterParams struct { type FilterParams struct {
SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string
Page, Limit int Page, Limit int
} }
type FilterOptions struct { type FilterOptions struct {
Categories []string `json:"categories"` Categories []string `json:"categories"`
SeriesNames []string `json:"series_names"` SeriesNames []string `json:"series_names"`
Brands []model.BrandInfo `json:"brands"` 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) { func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) {
if p.Limit <= 0 { p.Limit = 20 } if p.Limit <= 0 {
if p.Page <= 0 { p.Page = 1 } p.Limit = 20
}
if p.Page <= 0 {
p.Page = 1
}
var conds []string var conds []string
var args []interface{} 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), fmt.Sprintf("brand ILIKE $%d", i+2),
} }
conds = append(conds, "("+strings.Join(clauses, " OR ")+")") 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 i += 3
} }
where := "" 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) // Count distinct styles (dedup same brand+style_name across different sizes)
var total int var total int
@ -118,18 +127,29 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err
args = append(args, p.Limit, offset) args = append(args, p.Limit, offset)
rows, err := d.Query(query, args...) rows, err := d.Query(query, args...)
if err != nil { return nil, 0, err } if err != nil {
return nil, 0, err
}
defer rows.Close() defer rows.Close()
prods, err := scanProducts(rows) prods, err := ScanProducts(rows)
return prods, total, err return prods, total, err
} }
func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) { func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) {
var p model.Product rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku)
if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil { if err != nil {
return nil, err 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. // 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 SELECT `+selectCols+` FROM products
WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) WHERE style_name = (SELECT style_name FROM products WHERE sku = $1)
AND brand = (SELECT brand 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 AND sku != $1
ORDER BY width_in, length_in ORDER BY width_in, length_in
`, sku) `, sku)
if err != nil { return nil, err } if err != nil {
return nil, err
}
defer rows.Close() defer rows.Close()
return scanProducts(rows) return ScanProducts(rows)
} }
func VariantCount(d *sql.DB, sku string) int { 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) { func GetFilterOptions(d *sql.DB) (*FilterOptions, error) {
opts := &FilterOptions{} 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 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}, {"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames},
} { } {
rows, err := d.Query(q.query) rows, err := d.Query(q.query)
if err != nil { return nil, err } if err != nil {
for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) } return nil, err
}
for rows.Next() {
var v string
rows.Scan(&v)
*q.out = append(*q.out, v)
}
rows.Close() rows.Close()
} }
opts.Brands = GetBrandInfos(d) opts.Brands = GetBrandInfos(d)
@ -254,18 +284,20 @@ func GetBrandInfos(d *sql.DB) []model.BrandInfo {
return out return out
} }
func scanProducts(rows *sql.Rows) ([]model.Product, error) { func ScanProducts(rows *sql.Rows) ([]model.Product, error) {
var out []model.Product var out []model.Product
for rows.Next() { for rows.Next() {
var p model.Product var p model.Product
if err := rows.Scan( if err := rows.Scan(
&p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName, &p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName,
&p.Category, &p.Material, &p.ColorTone, &p.Finish, &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.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status,
&p.CoverageSqftPerBox, &p.WoodSpecies, &p.CoverageSqftPerBox, &p.WoodSpecies,
&p.SizeLabel, &p.WidthIn, &p.LengthIn, &p.SizeLabel, &p.WidthIn, &p.LengthIn,
); err != nil { return nil, err } ); err != nil {
return nil, err
}
out = append(out, p) out = append(out, p)
} }
return out, rows.Err() return out, rows.Err()

44
main.go
View File

@ -11,6 +11,8 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/joho/godotenv"
"floorvisualizer/internal/api" "floorvisualizer/internal/api"
"floorvisualizer/internal/handler" "floorvisualizer/internal/handler"
"floorvisualizer/internal/logger" "floorvisualizer/internal/logger"
@ -37,6 +39,8 @@ func init() {
} }
func main() { func main() {
godotenv.Load()
logDir := "logs" logDir := "logs"
if v := os.Getenv("LOG_DIR"); v != "" { if v := os.Getenv("LOG_DIR"); v != "" {
logDir = v logDir = v
@ -85,6 +89,7 @@ func main() {
if v := os.Getenv("REDIS_ADDR"); v != "" { if v := os.Getenv("REDIS_ADDR"); v != "" {
redisAddr = v redisAddr = v
} }
var cancelWorkers context.CancelFunc
rdb, err := queue.NewRedisClient(redisAddr, "") rdb, err := queue.NewRedisClient(redisAddr, "")
if err != nil { if err != nil {
logger.Warn("Redis not available (%v), worker queue disabled", err) logger.Warn("Redis not available (%v), worker queue disabled", err)
@ -119,19 +124,11 @@ func main() {
workerCount = n workerCount = n
} }
} }
workerCtx, cancelWorkers := context.WithCancel(context.Background()) workerCtx, cancel := context.WithCancel(context.Background())
cancelWorkers = cancel
defer cancelWorkers() defer cancelWorkers()
queue.StartWorkers(workerCtx, workerCount, rdb, client, db) queue.StartWorkers(workerCtx, workerCount, rdb, client, db)
logger.Info("Worker pool started: %d workers", workerCount) 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 ──────────────────────────────── // ── Hot product cache ────────────────────────────────
@ -171,6 +168,31 @@ func main() {
if p := os.Getenv("PORT"); p != "" { if p := os.Getenv("PORT"); p != "" {
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) 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")
} }

View File

@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS products (
price_per_sqft NUMERIC(10,2) DEFAULT 0, price_per_sqft NUMERIC(10,2) DEFAULT 0,
price_tier TEXT DEFAULT '', price_tier TEXT DEFAULT '',
price_source TEXT DEFAULT '', price_source TEXT DEFAULT '',
is_official_price BOOLEAN DEFAULT false,
main_image_url TEXT DEFAULT '', main_image_url TEXT DEFAULT '',
room_image_url TEXT DEFAULT '', room_image_url TEXT DEFAULT '',
source_url TEXT DEFAULT '', source_url TEXT DEFAULT '',