2026-07-16 01:47:11 +00:00
|
|
|
|
package queue
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-22 07:23:40 +00:00
|
|
|
|
"bytes"
|
2026-07-16 01:47:11 +00:00
|
|
|
|
"context"
|
|
|
|
|
|
"database/sql"
|
2026-07-22 07:23:40 +00:00
|
|
|
|
"encoding/json"
|
2026-07-16 01:47:11 +00:00
|
|
|
|
"floorvisualizer/internal/logger"
|
2026-07-16 02:32:30 +00:00
|
|
|
|
"fmt"
|
2026-07-22 07:23:40 +00:00
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
2026-07-16 01:47:11 +00:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-07-22 07:23:40 +00:00
|
|
|
|
"strings"
|
2026-07-16 01:47:11 +00:00
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
|
|
|
|
|
|
|
|
"floorvisualizer/internal/handler"
|
|
|
|
|
|
"floorvisualizer/internal/openrouter"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// StartWorkers launches n goroutines that consume jobs from the Redis queue.
|
|
|
|
|
|
func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrouter.Client, db *sql.DB) {
|
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
|
go func(workerID int) {
|
|
|
|
|
|
logger.Info("[worker %d] started", workerID)
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
logger.Info("[worker %d] shutting down", workerID)
|
|
|
|
|
|
return
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
jobID, err := DequeueJob(ctx, rdb)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Error("[worker %d] dequeue error: %v", workerID, err)
|
|
|
|
|
|
time.Sleep(time.Second)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Info("[worker %d] picked job %s", workerID, jobID)
|
|
|
|
|
|
processJob(ctx, rdb, client, db, jobID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}(i + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) {
|
2026-07-22 07:23:40 +00:00
|
|
|
|
totalStart := time.Now()
|
2026-07-16 01:47:11 +00:00
|
|
|
|
SetJobStatus(ctx, rdb, jobID, "processing")
|
2026-07-22 07:23:40 +00:00
|
|
|
|
logger.Info("[%s] TIMER job start", jobID)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
|
|
|
|
|
// Load payload
|
|
|
|
|
|
payload, err := GetJobPayload(ctx, rdb, jobID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
SetJobError(ctx, rdb, jobID, "Failed to load job payload")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inputPath := payload["input_path"]
|
|
|
|
|
|
floorID := payload["floor_id"]
|
|
|
|
|
|
patternCode := payload["pattern_code"]
|
|
|
|
|
|
roomCode := payload["room_code"]
|
|
|
|
|
|
|
2026-07-22 07:23:40 +00:00
|
|
|
|
// Look up floor and pattern from DB
|
2026-07-16 01:47:11 +00:00
|
|
|
|
floor := handler.FindFloorBySKUPublic(db, floorID)
|
|
|
|
|
|
pattern := handler.FindPatternByCodePublic(patternCode)
|
|
|
|
|
|
room := handler.FindRoomByCodePublic(roomCode)
|
2026-07-27 09:06:06 +00:00
|
|
|
|
groutColor := strings.ToLower(strings.TrimSpace(payload["grout_lines"]))
|
|
|
|
|
|
baseboardColor := strings.ToLower(strings.TrimSpace(payload["white_baseboard"]))
|
2026-07-27 06:39:45 +00:00
|
|
|
|
materialSource := payload["material_source"] // "image", "json", or "both"
|
|
|
|
|
|
if materialSource == "" {
|
|
|
|
|
|
materialSource = "image"
|
|
|
|
|
|
}
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
|
|
|
|
|
if floor == nil || pattern == nil {
|
|
|
|
|
|
SetJobError(ctx, rdb, jobID, "Invalid floor or pattern")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
|
|
|
|
|
outputPath := filepath.Join("outputs", fmt.Sprintf("result_%s.png", jobID))
|
|
|
|
|
|
|
2026-07-22 07:23:40 +00:00
|
|
|
|
jobCtx, cancel := context.WithTimeout(ctx, 600*time.Second)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
// Step 0: content check
|
|
|
|
|
|
SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...")
|
2026-07-22 07:23:40 +00:00
|
|
|
|
stepStart := time.Now()
|
|
|
|
|
|
logger.Info("[%s] TIMER step=check start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond))
|
|
|
|
|
|
passed, score := validateContent(client, jobCtx, workPath)
|
|
|
|
|
|
logStepDone(jobCtx, rdb, jobID, "check", stepStart, totalStart, "score=%d passed=%t", score, passed)
|
2026-07-16 02:32:30 +00:00
|
|
|
|
if score == 0 {
|
|
|
|
|
|
logger.Warn("[%s] Content check failed (score=0)", jobID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !passed {
|
2026-07-16 01:47:11 +00:00
|
|
|
|
SetJobError(jobCtx, rdb, jobID, "Content check failed: Please upload an indoor room photo")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 06:39:45 +00:00
|
|
|
|
// Step 1: generate floor (single combined prompt — AI identifies floor + replaces it)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
roomLabel := ""
|
|
|
|
|
|
if room != nil {
|
|
|
|
|
|
roomLabel = " · " + room.Name
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel))
|
|
|
|
|
|
stepStart = time.Now()
|
|
|
|
|
|
logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s",
|
|
|
|
|
|
jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond))
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
2026-07-27 06:39:45 +00:00
|
|
|
|
// Material source: image / json / both
|
|
|
|
|
|
if materialSource == "json" || materialSource == "both" {
|
|
|
|
|
|
if desc := handler.LookupMaterialDesc(floor.SKU); desc != "" {
|
|
|
|
|
|
if floor.Description != "" {
|
|
|
|
|
|
floor.Description = desc + " | " + floor.Description
|
|
|
|
|
|
} else {
|
|
|
|
|
|
floor.Description = desc
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
refImagePath := ""
|
|
|
|
|
|
if materialSource != "json" {
|
|
|
|
|
|
refImagePath = downloadReferenceImage(jobCtx, floor.ImageURL, jobID)
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
|
|
|
|
|
|
imageSize := floorImageSize()
|
2026-07-27 06:39:45 +00:00
|
|
|
|
logger.Info("[%s] TIMER generate request material=%s image_size=%s input=%s output=%s ref=%s",
|
|
|
|
|
|
jobID, materialSource, imageSize, workPath, outputPath, refImagePath)
|
2026-07-27 09:06:06 +00:00
|
|
|
|
floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutColor, baseboardColor)
|
2026-07-16 02:32:30 +00:00
|
|
|
|
_, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{
|
2026-07-22 07:23:40 +00:00
|
|
|
|
Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath,
|
|
|
|
|
|
ImageSize: imageSize, ReferenceImagePath: refImagePath,
|
2026-07-16 01:47:11 +00:00
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
2026-07-22 07:23:40 +00:00
|
|
|
|
logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-27 06:39:45 +00:00
|
|
|
|
if err := openrouter.RepairNearBlackBorder(outputPath); err != nil {
|
|
|
|
|
|
logger.Warn("[%s] Failed to repair output border: %v", jobID, err)
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "")
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
|
|
|
|
|
// Clean up intermediate files
|
2026-07-22 07:23:40 +00:00
|
|
|
|
stepStart = time.Now()
|
|
|
|
|
|
logger.Info("[%s] TIMER step=cleanup start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond))
|
2026-07-16 02:32:30 +00:00
|
|
|
|
if err := os.Remove(inputPath); err != nil {
|
|
|
|
|
|
logger.Warn("[%s] Failed to remove input: %v", jobID, err)
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
if workPath != inputPath {
|
|
|
|
|
|
if err := os.Remove(workPath); err != nil {
|
|
|
|
|
|
logger.Warn("[%s] Failed to remove resized input: %v", jobID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if refImagePath != "" {
|
|
|
|
|
|
if err := os.Remove(refImagePath); err != nil {
|
|
|
|
|
|
logger.Warn("[%s] Failed to remove reference image: %v", jobID, err)
|
|
|
|
|
|
}
|
2026-07-16 02:32:30 +00:00
|
|
|
|
}
|
2026-07-16 01:47:11 +00:00
|
|
|
|
|
2026-07-22 07:23:40 +00:00
|
|
|
|
totalDuration := time.Since(totalStart)
|
|
|
|
|
|
_ = SetJobTiming(jobCtx, rdb, jobID, "total", totalDuration, totalDuration)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath))
|
2026-07-22 07:23:40 +00:00
|
|
|
|
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)
|
2026-07-16 01:47:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// validateContent checks if the image is an indoor room photo.
|
2026-07-22 07:23:40 +00:00
|
|
|
|
// 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) {
|
2026-07-16 01:47:11 +00:00
|
|
|
|
imgData, err := os.ReadFile(imagePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, 0
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
// 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()
|
2026-07-16 01:47:11 +00:00
|
|
|
|
dataURI := "data:image/png;base64," + base64enc(imgData)
|
2026-07-22 07:23:40 +00:00
|
|
|
|
resp, err := geminiClient.Chat(checkCtx, openrouter.ChatRequest{
|
2026-07-16 01:47:11 +00:00
|
|
|
|
Model: "google/gemini-2.5-flash",
|
|
|
|
|
|
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
|
|
|
|
|
|
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
|
2026-07-27 06:39:45 +00:00
|
|
|
|
{Type: "text", Text: `请按 1-10 分评估这张图片是否适合做室内地面替换。
|
|
|
|
|
|
8-10 分:清晰室内房间,地面可见。
|
|
|
|
|
|
5-7 分:室内图片,但角度或地面可见度一般。
|
|
|
|
|
|
1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。
|
|
|
|
|
|
只返回一个数字(1-10)。`},
|
2026-07-16 01:47:11 +00:00
|
|
|
|
}}},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil || len(resp.Choices) == 0 {
|
|
|
|
|
|
return true, 0
|
|
|
|
|
|
}
|
|
|
|
|
|
score := parseScore(resp.Choices[0].Message.Content)
|
|
|
|
|
|
return score >= 6, score
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 07:23:40 +00:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 01:47:11 +00:00
|
|
|
|
func parseScore(raw string) int {
|
|
|
|
|
|
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
|
|
|
|
|
|
if len(raw) >= len(s) && raw[:len(s)] == s {
|
|
|
|
|
|
v := 0
|
|
|
|
|
|
fmt.Sscanf(s, "%d", &v)
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func base64enc(data []byte) string {
|
|
|
|
|
|
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
|
|
|
|
var b []byte
|
|
|
|
|
|
for i := 0; i < len(data); i += 3 {
|
|
|
|
|
|
b0, b1, b2 := data[i], byte(0), byte(0)
|
|
|
|
|
|
if i+1 < len(data) {
|
|
|
|
|
|
b1 = data[i+1]
|
|
|
|
|
|
}
|
|
|
|
|
|
if i+2 < len(data) {
|
|
|
|
|
|
b2 = data[i+2]
|
|
|
|
|
|
}
|
|
|
|
|
|
b = append(b, tbl[b0>>2])
|
|
|
|
|
|
b = append(b, tbl[((b0&3)<<4)|(b1>>4)])
|
|
|
|
|
|
if i+1 < len(data) {
|
|
|
|
|
|
b = append(b, tbl[((b1&15)<<2)|(b2>>6)])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
b = append(b, '=')
|
|
|
|
|
|
}
|
|
|
|
|
|
if i+2 < len(data) {
|
|
|
|
|
|
b = append(b, tbl[b2&63])
|
|
|
|
|
|
} else {
|
|
|
|
|
|
b = append(b, '=')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(b)
|
|
|
|
|
|
}
|
2026-07-22 07:23:40 +00:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 06:39:45 +00:00
|
|
|
|
func payloadBool(payload map[string]string, key string) bool {
|
|
|
|
|
|
switch payload[key] {
|
|
|
|
|
|
case "1", "true", "yes", "y", "on":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 07:23:40 +00:00
|
|
|
|
func floorImageSize() string {
|
|
|
|
|
|
value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE"))
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return "2K"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|