dev #2
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,3 +28,6 @@ __MACOSX/
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|||||||
@ -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_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型",
|
"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": {
|
"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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
7
go.mod
@ -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
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 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=
|
||||||
|
|||||||
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/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"))))
|
||||||
|
|||||||
@ -25,8 +25,12 @@ type FloorOption struct {
|
|||||||
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"`
|
||||||
|
WidthIn float64 `json:"width_in"`
|
||||||
|
LengthIn float64 `json:"length_in"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
SKU string `json:"sku"`
|
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{
|
||||||
seen[key] = true
|
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
|
||||||
|
SKU: p.SKU,
|
||||||
|
})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
seen[k] = len(out)
|
||||||
out = append(out, FloorOption{
|
out = append(out, 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,6 +13,7 @@ type Product struct {
|
|||||||
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"`
|
||||||
|
IsOfficialPrice bool `json:"is_official_price"`
|
||||||
MainImageURL string `json:"main_image_url"`
|
MainImageURL string `json:"main_image_url"`
|
||||||
RoomImageURL string `json:"room_image_url"`
|
RoomImageURL string `json:"room_image_url"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
@ -23,6 +24,8 @@ type JobStatus struct {
|
|||||||
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.
|
||||||
@ -96,11 +112,21 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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
44
main.go
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 '',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user