package service import ( "context" "fmt" "image" "net/http" "os" "path/filepath" "strings" "time" "materialanalyzer/internal/embedding" "materialanalyzer/internal/imageproc" "materialanalyzer/internal/model" "materialanalyzer/internal/output" "materialanalyzer/internal/validation" ) const ( SchemaVersion = "material_asset_v2" AnalyzerVersion = "2.0.0" FeatureSchema = "2026.07" Generator = "Material Analyzer" ) type ProcessorConfig struct { OutputDir string CacheDir string Force bool AllowFallback bool EmbeddingProvider embedding.Provider SemanticProvider SemanticProvider } type Processor struct { cfg ProcessorConfig client *http.Client } type ProcessResult struct { SKU string AssetDir string Skipped bool Warnings []string Width int Height int } func NewProcessor(cfg ProcessorConfig, client *http.Client) *Processor { if cfg.OutputDir == "" { cfg.OutputDir = "MaterialAssets" } if cfg.CacheDir == "" { cfg.CacheDir = filepath.Join("cache", "images") } if cfg.EmbeddingProvider == nil { cfg.EmbeddingProvider = embedding.LocalProvider{} } if cfg.SemanticProvider == nil { cfg.SemanticProvider = RuleBasedSemanticProvider{} } return &Processor{cfg: cfg, client: client} } func (p *Processor) Process(ctx context.Context, product model.Product) (ProcessResult, error) { result := ProcessResult{SKU: product.SKU} assetDir := output.AssetDir(p.cfg.OutputDir, product.SKU) result.AssetDir = assetDir if !p.cfg.Force && output.AssetExists(assetDir) { result.Skipped = true return result, nil } source, loaded, warnings, err := p.loadFirstImage(ctx, product) if err != nil { return result, err } analysis := imageproc.PrepareAnalysisImage(loaded.Image, source) result.Width = analysis.OriginalWidth result.Height = analysis.OriginalHeight if analysis.RoomLike { warnings = append(warnings, "main material image looks room-like; analyzed lower floor crop and flagged for cleanup") } visual, hist := imageproc.ExtractVisual(analysis.Image) texture := imageproc.ExtractTexture(analysis.Image) canonical := imageproc.ExtractCanonicalStatistics(analysis.Image, visual, texture) if err := output.EnsureAssetDir(assetDir); err != nil { return result, fmt.Errorf("create asset dir: %w", err) } if err := output.WriteImages(assetDir, analysis.Image); err != nil { return result, fmt.Errorf("write preview images: %w", err) } previewPath := filepath.Join(assetDir, "preview.jpg") semantic, err := p.cfg.SemanticProvider.Analyze(ctx, previewPath, product, visual, texture) if err != nil { if !p.cfg.AllowFallback { return result, fmt.Errorf("semantic provider %s: %w", p.cfg.SemanticProvider.Name(), err) } warnings = append(warnings, fmt.Sprintf("semantic provider %s failed; used local fallback: %v", p.cfg.SemanticProvider.Name(), err)) semantic = BuildSemantic(product, visual, texture) } specific := BuildMaterialSpecific(product, visual, texture, semantic) if err := output.WriteHistogram(assetDir, hist); err != nil { return result, fmt.Errorf("write histogram: %w", err) } embeddingResult, err := p.cfg.EmbeddingProvider.Embed(ctx, embedding.Request{ ImagePath: previewPath, Product: product, Histogram: hist, Visual: visual, Texture: texture, Semantic: semantic, }) if err != nil { if !p.cfg.AllowFallback { return result, fmt.Errorf("embedding provider %s: %w", p.cfg.EmbeddingProvider.Name(), err) } warnings = append(warnings, fmt.Sprintf("embedding provider %s failed; used local fallback: %v", p.cfg.EmbeddingProvider.Name(), err)) embeddingResult, err = (embedding.LocalProvider{}).Embed(ctx, embedding.Request{ ImagePath: previewPath, Product: product, Histogram: hist, Visual: visual, Texture: texture, Semantic: semantic, }) if err != nil { return result, fmt.Errorf("local embedding fallback: %w", err) } } manifest, err := embedding.WriteBinary(filepath.Join(assetDir, "embedding.bin"), embeddingResult) if err != nil { return result, fmt.Errorf("write embeddings: %w", err) } generatedAt := time.Now().UTC().Format(time.RFC3339) asset := model.MaterialAsset{ SchemaVersion: SchemaVersion, AnalyzerVersion: AnalyzerVersion, FeatureSchema: FeatureSchema, Generator: Generator, GeneratedAt: generatedAt, SKU: product.SKU, Category: product.Category, Material: product.Material, Product: model.NewProductSnapshot(product), Input: model.InputInfo{ ImageURL: source, ImageCachePath: loaded.SourcePath, OriginalWidth: analysis.OriginalWidth, OriginalHeight: analysis.OriginalHeight, AnalysisStrategy: analysis.Strategy, AnalysisRegion: regionString(analysis.Region), RoomLike: analysis.RoomLike, RoomLikeConfidence: analysis.RoomLikeConfidence, RoomLikeReason: analysis.RoomLikeReason, SourceImageFormat: loaded.Format, GeneratedPreview: "preview.jpg", GeneratedThumbnail: "thumbnail.jpg", GeneratedHistogram: "histogram.json", GeneratedEmbeddings: "embedding.bin", }, Visual: visual, Texture: texture, Canonical: canonical, Semantic: semantic, MaterialSpecific: specific, Embeddings: manifest, Confidence: map[string]float64{ "visual": 0.95, "texture": 0.90, "semantic": semantic.Confidence, "embedding": embeddingConfidence(manifest), }, Warnings: warnings, } asset.Validation = validation.ValidateAsset(asset, hist) if asset.Validation.Status != "valid" { return result, fmt.Errorf("asset validation failed: %s", strings.Join(asset.Validation.Errors, "; ")) } if err := output.WriteSemantic(assetDir, semantic); err != nil { return result, fmt.Errorf("write semantic json: %w", err) } if err := output.WriteMaterial(assetDir, asset); err != nil { return result, fmt.Errorf("write material json: %w", err) } if err := output.WriteManifest(assetDir, model.AssetManifest{ SKU: product.SKU, AnalyzerVersion: AnalyzerVersion, FeatureSchema: FeatureSchema, GeneratedAt: generatedAt, Files: []string{"preview.jpg", "thumbnail.jpg", "material.json", "histogram.json", "embedding.bin", "semantic.json", "manifest.json"}, Status: "complete", Validation: asset.Validation, Warnings: warnings, }); err != nil { return result, fmt.Errorf("write manifest: %w", err) } result.Warnings = warnings return result, nil } func embeddingConfidence(manifest model.EmbeddingManifest) float64 { for _, vector := range manifest.Vectors { if vector.Provider != "local" && vector.Provider != "" { return 0.92 } } return 0.70 } func (p *Processor) loadFirstImage(ctx context.Context, product model.Product) (string, *imageproc.LoadedImage, []string, error) { var warnings []string var lastErr error for i, source := range imageSources(product) { loaded, err := imageproc.LoadImage(ctx, source, p.cfg.CacheDir, p.client) if err == nil { if i > 0 { warnings = append(warnings, "primary image failed; used fallback image source") } return source, loaded, warnings, nil } lastErr = err } if lastErr == nil { lastErr = fmt.Errorf("no image source available") } return "", nil, warnings, fmt.Errorf("load image for %s: %w", product.SKU, lastErr) } func imageSources(product model.Product) []string { var out []string seen := map[string]bool{} add := func(v string) { v = strings.TrimSpace(v) if v == "" || seen[v] { return } seen[v] = true out = append(out, v) } add(product.MainImageURL) return out } func regionString(rect image.Rectangle) string { return fmt.Sprintf("x=%d,y=%d,w=%d,h=%d", rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy()) } func RemoveIncompleteAsset(assetDir string) { if strings.TrimSpace(assetDir) == "" { return } _ = os.Remove(filepath.Join(assetDir, "material.json")) }