feat(floor): 优化地板样式数据结构和AI换地板功能
- 添加 FloorOption 结构体字段支持尺寸、描述和变体信息 - 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体 - 区分木材、瓷砖、乙烯基和层压板产品,并分类返回 - 更新室内铺装纹理选项,完善图案描述和名称本地化 - 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段 - 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例 - 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求 - 改进地板识别蒙版生成逻辑,确保精准分割地板区域 - Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息 - 更新go.mod依赖,新增redis和gorm相关包支持
This commit is contained in:
parent
a28c7ba963
commit
1524a3215c
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,3 +28,6 @@ __MACOSX/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
7
go.mod
7
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
|
||||
)
|
||||
|
||||
23
go.sum
23
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=
|
||||
|
||||
13
internal/api/index_handler.go
Normal file
13
internal/api/index_handler.go
Normal 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))
|
||||
}
|
||||
6
internal/api/index_html.go
Normal file
6
internal/api/index_html.go
Normal file
@ -0,0 +1,6 @@
|
||||
package api
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML string
|
||||
@ -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"))))
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
44
main.go
44
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")
|
||||
}
|
||||
|
||||
@ -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 '',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user