/** * Recolor tkaniny — Etap D. * * Silnik produkcyjny: fabric_recolor v7 (services/recolor, tag * recolor-v7-stable). Jedno wywołanie CLI na wariant: * python -m fabric_recolor.cli [--anchor-swatch] * w cwd=services/recolor. Potok silnika: BiRefNet matte + GroundedSAM/SAM * (negatywy) + Depth Anything V2 (komora skrzyni, kotwica głębi) + transfer * CIELAB delta (kotwica = oficjalna próbka odcienia fotografowanego). * Raport RecolorReport (JSON na stdout) trafia do provenance. * * Wejście: WYŁĄCZNIE zatwierdzone ujęcie Master (variant_id IS NULL, * status approved, ważna decyzja dla SHA) — surowe kadry z hali nie mogą * być wsadem wariantów ofertowych. Lustro konstrukcji (strona przeciwna * do fotografowanej) wykonuje się flop-em na wyniku silnika. * * LEGACY_V1 (nieużywane w ścieżce produkcyjnej; zostaje dla weryfikacji * historycznych assetów — Maciuś/mediaReview liczą maskę rembg i model * delta dla wyników v1–v5): furnitureMask/ensureCutout (rembg), * applyLabMatch/deltaLab/transformL, resolveFullFrame oraz ochrona * wielokątowa storage (storageProtectionFor/storageProtectedPixels/ * excludeProtectedPixels). * * Uczciwie: wynik odwzorowuje barwę próbki w świetle sceny zdjęcia, * nie gwarantuje zgodności na każdym monitorze. */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; import { runPython } from "../pyRunner.js"; import sharp from "sharp"; import type { Db } from "../db.js"; import type { AppConfig } from "../config.js"; import { audit } from "./audit.js"; import { approvedAssetForRole, registerAsset, type AssetRow } from "./assets.js"; import type { ShotRole } from "./types.js"; export const RECOLOR_METHOD = "fabric_recolor_v7"; /** * @deprecated LEGACY_V1 — ochrona wnętrza/okucia (ujęcie storage) wielokątem * trzymanym jako DANE w tabeli asset_protections, wiązanym z SHA mastera. * W silniku v7 komorę i elementy nietkaninowe odcina segmentacja * (GroundedSAM negatywy + głębia Depth Anything); wielokąty zostają * wyłącznie dla weryfikacji assetów wygenerowanych metodą lab_match_v1–v5. */ export interface StorageProtection { version: string; masterSha256: string; width: number; height: number; points: number[][]; } /** @deprecated LEGACY_V1 — patrz StorageProtection. */ export function storageProtectionFor(db: Db, masterSha256: string): StorageProtection | null { const row = db .prepare("SELECT version, asset_sha256, width, height, points_json FROM asset_protections WHERE asset_sha256 = ?") .get(masterSha256) as | { version: string; asset_sha256: string; width: number; height: number; points_json: string } | undefined; if (!row) return null; return { version: row.version, masterSha256: row.asset_sha256, width: row.width, height: row.height, points: JSON.parse(row.points_json) as number[][], }; } /** @deprecated LEGACY_V1 — patrz StorageProtection. */ export async function storageProtectedPixels( db: Db, masterSha256: string, width: number, height: number ): Promise { const cfg = storageProtectionFor(db, masterSha256); if (!cfg) return null; const points = cfg.points.map((p) => p.join(",")).join(" "); const svg = ``; return sharp(Buffer.from(svg)).resize(width, height).removeAlpha().extractChannel(0).raw().toBuffer(); } /** @deprecated LEGACY_V1 — patrz StorageProtection. */ export function excludeProtectedPixels(alpha: Buffer, protectedPixels: Buffer): void { if (alpha.length !== protectedPixels.length) throw new Error("Rozmiar maski ochronnej nie odpowiada obrazowi."); for (let i = 0; i < alpha.length; i++) if (protectedPixels[i]! >= 128) alpha[i] = 0; } // ---------- CIELAB (D65) ---------- function srgbToLinear(c: number): number { const v = c / 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); } function linearToSrgb(v: number): number { const s = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.min(255, Math.max(0, Math.round(s * 255))); } export function rgbToLab(r: number, g: number, b: number): [number, number, number] { const rl = srgbToLinear(r); const gl = srgbToLinear(g); const bl = srgbToLinear(b); // XYZ (D65) const x = (rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375) / 0.95047; const y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.072175; const z = (rl * 0.0193339 + gl * 0.119192 + bl * 0.9503041) / 1.08883; const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); const fx = f(x); const fy = f(y); const fz = f(z); return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; } export function labToRgb(l: number, a: number, b: number): [number, number, number] { const fy = (l + 16) / 116; const fx = fy + a / 500; const fz = fy - b / 200; const inv = (t: number) => (t > 0.206893 ? t * t * t : (t - 16 / 116) / 7.787); const x = inv(fx) * 0.95047; const y = inv(fy); const z = inv(fz) * 1.08883; const rl = x * 3.2404542 + y * -1.5371385 + z * -0.4985314; const gl = x * -0.969266 + y * 1.8760108 + z * 0.041556; const bl = x * 0.0556434 + y * -0.2040259 + z * 1.0572252; return [linearToSrgb(rl), linearToSrgb(gl), linearToSrgb(bl)]; } // ---------- statystyka barwy ---------- export interface LabStats { mean: [number, number, number]; std: [number, number, number]; count: number; } /** Średnia + odchylenie standardowe kanałów L,a,b nad zadanym zbiorem pikseli. */ export function labStats(rgb: Uint8Array | Buffer, mask?: Uint8Array | Buffer, maskMin = 128): LabStats { const n = rgb.length / 3; const sum = [0, 0, 0]; const sum2 = [0, 0, 0]; let count = 0; for (let i = 0; i < n; i++) { if (mask && mask[i]! < maskMin) continue; const [l, a, b] = rgbToLab(rgb[i * 3]!, rgb[i * 3 + 1]!, rgb[i * 3 + 2]!); sum[0]! += l; sum[1]! += a; sum[2]! += b; sum2[0]! += l * l; sum2[1]! += a * a; sum2[2]! += b * b; count++; } if (count === 0) return { mean: [0, 0, 0], std: [1, 1, 1], count: 0 }; const mean = sum.map((s) => s / count) as [number, number, number]; const std = sum2.map( (s, c) => Math.sqrt(Math.max(1e-6, s / count - (mean[c] ?? 0) * (mean[c] ?? 0))) ) as [number, number, number]; return { mean, std, count }; } /** Statystyka próbki tkaniny — środkowe 84% kadru (bez krawędzi skanu). */ export async function swatchStats(absPath: string): Promise { const S = 320; const m = Math.round(S * 0.08); const { data } = await sharp(absPath) .resize(S, S, { fit: "cover" }) .extract({ left: m, top: m, width: S - 2 * m, height: S - 2 * m }) .raw() .toBuffer({ resolveWithObject: true }); return labStats(data); } // ---------- maska mebla (kanał alfa) — LEGACY_V1 ---------- /** * @deprecated LEGACY_V1 — maska produkcyjna pochodzi z silnika * fabric_recolor v7; ten typ zostaje dla weryfikacji starych assetów. */ export interface FurnitureMask { mask: Buffer; // u8 binarna: 255 gdy alpha > ALPHA_MIN alpha: Buffer; // u8 surowa alfa (0–255), wymiar jak obraz wejściowy bgRgb: [number, number, number]; // mediana RGB tła (piksele alpha <= ALPHA_MIN) bgField: Buffer; // u8 RGB per piksel — lokalna estymacja tła (normalizowana splotka) width: number; height: number; coverage: number; // udział pikseli alpha > ALPHA_MIN w kadrze (0..1) alphaPixels: number; // liczba pikseli alpha > ALPHA_MIN interiorPixels: number; // liczba pikseli alpha >= 200 (pełne wnętrze) maskMethod: string; // „source_alpha" | „rembg:" cutoutPath: string | null; // keszowany RGBA PNG (gdy źródło nie miało alfy) } const ALPHA_MIN = 10; // Dolna granica alfie przy odseparowaniu tła (unmix): dla a < UNMIX_FLOOR // dzielenie przez a amplifikowałoby szum JPEG >3x — korekcja jest wtedy // częściowa (reszta domieszki zostaje), bo i tak piksel wraca głównie do tła. const UNMIX_FLOOR = 0.3; const REMBG_SCRIPT = fileURLToPath(new URL("../../../scripts/rembg_mask.py", import.meta.url)); function sha256File(abs: string): string { return crypto.createHash("sha256").update(fs.readFileSync(abs)).digest("hex"); } /** * @deprecated LEGACY_V1 — wycięcie RGBA przez rembg (scripts/rembg_mask.py). * W v7 segmentację wykonuje silnik (BiRefNet+SAM+depth); rembg zostaje * tylko jako przybliżenie maski przy weryfikacji starych assetów. * * Wynik keszowany w /-.png (deterministycznie * dla danej treści). Domyślna sesja `birefnet-general`: rampa alfy ~4 px * (zgodna z rzeczywistą ostrością krawędzi zdjęcia) vs ~10 px u2net — * u2net rozmazywał sylwetkę. Błąd sesji innej niż u2net → fallback u2net. */ async function ensureCutout( absPath: string, opts: { cacheDir?: string; session?: string } ): Promise<{ cutoutPath: string; session: string }> { const session = opts.session ?? process.env.VILMAL_MASK_SESSION ?? "birefnet-general"; const cacheDir = opts.cacheDir ?? path.join(os.tmpdir(), "vilmal-masks"); fs.mkdirSync(cacheDir, { recursive: true }); const sha = sha256File(absPath); const out = path.join(cacheDir, `${sha}-${session}.png`); if (fs.existsSync(out)) return { cutoutPath: out, session }; const py = process.env.VILMAL_PYTHON ?? "python"; const tmp = `${out}.tmp-${process.pid}`; try { await runPython([REMBG_SCRIPT, absPath, tmp, session], { // birefnet-general na CPU: ~5 min dla 1184×880 (jednorazowo, keszowane) timeoutMs: 900_000, maxBuffer: 8 * 1024 * 1024, }); fs.renameSync(tmp, out); } catch (e) { fs.rmSync(tmp, { force: true }); if (session !== "u2net") { // fallback: lżejszy model — maskMethod w provenance odnotuje sesję return ensureCutout(absPath, { cacheDir, session: "u2net" }); } throw new Error( `rembg nie powiódł (${py} ${path.basename(REMBG_SCRIPT)}): ${e instanceof Error ? e.message : e}` ); } return { cutoutPath: out, session }; } /** * @deprecated LEGACY_V1 — maska „mebel" z kanału alfa / rembg. * Używana wyłącznie przez Maciusia do przybliżonej weryfikacji assetów * (dla v7 maska silnika nie jest dostępna z powrotem — rembg daje * konserwatywne przybliżenie wnętrza tapicerki). * * mask[i] = 255 gdy alpha > 10. Zwraca też surową alfę, medianę koloru * tła oraz LOKALNE pole tła (bgField): normalizowana splotka * bg = blur(rgb·w)/blur(w), w = 1−a. Dzięki temu krawędź rekompozytuje * się do tła z sąsiedztwa, a nie do jednej mediany całego kadru. */ export async function furnitureMask( absPath: string, opts: { cacheDir?: string; session?: string } = {} ): Promise { const meta = await sharp(absPath).metadata(); const w = meta.width ?? 0; const h = meta.height ?? 0; if (!w || !h) throw new Error(`nieczytelne wymiary obrazu: ${absPath}`); let src = absPath; let maskMethod = "source_alpha"; let cutoutPath: string | null = null; const readAlpha = (from: string) => sharp(from) .resize(w, h, { fit: "fill" }) .ensureAlpha() .extractChannel(3) .raw() .toBuffer({ resolveWithObject: true }); let hasRealAlpha = Boolean(meta.hasAlpha); let r = meta.hasAlpha ? await readAlpha(src) : null; if (r) { // Alfa pełna (~cały kadr nieprzezroczysty) nie niesie informacji o // wycięciu — tak zapisują silniki AI. Traktujemy obraz jak JPEG: // maska mebla z rembg, inaczej recolor pomalowałby także tło. let opaque = 0; for (let i = 0; i < r.data.length; i++) if ((r.data[i] ?? 0) > ALPHA_MIN) opaque++; if (opaque / (r.info.width * r.info.height) >= 0.985) { hasRealAlpha = false; r = null; } } if (!hasRealAlpha) { const cut = await ensureCutout(absPath, opts); src = cut.cutoutPath; cutoutPath = cut.cutoutPath; maskMethod = `rembg:${cut.session}`; r = await readAlpha(src); } const alpha = r!.data; const info = r!.info; const n = info.width * info.height; const mask = Buffer.alloc(n); let alphaPixels = 0; let interiorPixels = 0; for (let i = 0; i < n; i++) { const a = alpha[i] ?? 0; if (a > ALPHA_MIN) { mask[i] = 255; alphaPixels++; } if (a >= 200) interiorPixels++; } // kolor tła = mediana RGB pikseli poza meblem (alpha <= ALPHA_MIN) const { data: rgbSrc } = await sharp(absPath) .resize(info.width, info.height, { fit: "fill" }) .removeAlpha() .raw() .toBuffer({ resolveWithObject: true }); const hist = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)]; let bgCount = 0; for (let i = 0; i < n; i++) { if ((alpha[i] ?? 0) > ALPHA_MIN) continue; for (let c = 0; c < 3; c++) hist[c]![rgbSrc[i * 3 + c]!]!++; bgCount++; } const bgRgb = [0, 1, 2].map((c) => { let acc = 0; for (let v = 0; v < 256; v++) { acc += hist[c]![v]!; if (acc * 2 >= bgCount) return v; } return 255; }) as [number, number, number]; // Lokalne pole tła: normalizowana splotka. num = rgb·w i den = w // rozmywane tym samym gaussianem; bg = num/den. Waga w jest TWARDA // (tylko piksele alfa<=ALPHA_MIN) — piksele częściowej alfy niosą // domieszkę mebla i zaniżałyby estymację tła. σ celowo małe (~8px): // pole potrzebne tylko w wąskim paśmie krawędzi, a duże σ „przeciekałoby" // przez cienie pod meblem i gradienty — krawędź z powrotem do złego tła. const sigma = 8; const wBuf = Buffer.alloc(n); const numBuf = Buffer.alloc(n * 3); for (let i = 0; i < n; i++) { const w = (alpha[i] ?? 0) <= ALPHA_MIN ? 255 : 0; wBuf[i] = w; numBuf[i * 3] = Math.round((rgbSrc[i * 3]! * w) / 255); numBuf[i * 3 + 1] = Math.round((rgbSrc[i * 3 + 1]! * w) / 255); numBuf[i * 3 + 2] = Math.round((rgbSrc[i * 3 + 2]! * w) / 255); } const [numBlur, denBlur] = await Promise.all([ sharp(numBuf, { raw: { width: info.width, height: info.height, channels: 3 } }) .blur(sigma) .raw() .toBuffer({ resolveWithObject: true }), sharp(wBuf, { raw: { width: info.width, height: info.height, channels: 1 } }) .blur(sigma) .raw() .toBuffer({ resolveWithObject: true }), ]); const bgField = Buffer.alloc(n * 3); const denCh = denBlur.info.channels; // sharp może wyjść RGB mimo 1-ch wejścia for (let i = 0; i < n; i++) { const den = denBlur.data[i * denCh] ?? 0; for (let c = 0; c < 3; c++) { bgField[i * 3 + c] = den >= 10 ? Math.min(255, Math.max(0, Math.round(((numBlur.data[i * 3 + c] ?? 0) * 255) / den))) : bgRgb[c]!; } } return { mask, alpha: Buffer.from(alpha.buffer, alpha.byteOffset, n), bgRgb, bgField, width: info.width, height: info.height, coverage: alphaPixels / n, alphaPixels, interiorPixels, maskMethod, cutoutPath, }; } // ---------- transfer barwy — LEGACY_V1 ---------- /** * @deprecated LEGACY_V1 — model delta v1–v5; transfer wykonuje silnik v7. * Zostaje dla weryfikacji historycznych assetów (Maciuś liczy oczekiwaną * średnią tym samym wzorem). * * Transfer barwy na piksele mebla (alpha > ALPHA_MIN) — model delta: * out = foto + (próbka_docelowa − próbka_kotwicy) * Kotwica = oficjalna próbka odcienia fotografowanego. Różnica próbek to * zmiana albedo tkaniny; dodanie jej do zdjęcia zachowuje iluminant sceny * (fotografia w ciepłym świetle zostaje ciepła) i wariację barwy pikseli. * Wcześniejsza wersja (v2) nadpisywała a,b stałą próbki i L średnią próbki * → mebel tracił balans bieli sceny i wyglądał na wklejony. * - Piksele krawędzi (częściowa alfa): odseparowanie domieszki tła * (c_sofa = (c − (1−a)·bg)/a) wobec LOKALNEGO tła bgField, potem recolor, * potem rekompozycja (out = a·c' + (1−a)·bg) z tym samym lokalnym tłem. * Dla a < UNMIX_FLOOR unmix liczony jak dla a=UNMIX_FLOOR — bez dzielenia * przez ~0.1, czyli bez amplifikacji szumu JPEG na obrzeżach maski. */ /** Kurtyna świateł: powyżej progu t racjonalna kompresja do asymptoty t+s. */ export interface LumaKnee { t: number; s: number; } /** Zastosowana transformacja — zapisana w provenance, czytana przez Maciusia. */ export interface AppliedDelta { l: number; a: number; b: number; kneeT: number | null; kneeS: number | null; /** v4: mapa liniowa jasności — średnia wnętrza mastera (mapA) → cel (mapT). * null/null = czyste przesunięcie delta (ściemnianie i stare assety v3). */ mapA: number | null; mapT: number | null; } /** * Przesunięcie pojedynczego piksela Lab o deltę próbek. * Dodatnie ΔL dostaje kurtynę w światłach (knee): bez niej jasne odcienie * (np. ecru +25) wpinałyby tkaninę w L=100 i wypalały fakturę siedzisk. * Mapa racjonalna: x≤t → x; x>t → t + (x−t)·s/(s+x−t), asymptota t+s≈97.5. * Współdzielone z Maciusiem — oczekiwana średnia liczona tym samym wzorem. */ /** @deprecated LEGACY_V1 — patrz uwaga nad applyLabMatch. */ export function deltaLab( l: number, a: number, b: number, dL: number, dA: number, dB: number, knee: LumaKnee | null = null ): [number, number, number] { let nl = l + dL; if (knee && nl > knee.t) nl = knee.t + ((nl - knee.t) * knee.s) / (knee.s + (nl - knee.t)); return [nl, a + dA, b + dB]; } /** * Transformacja jasności piksela wg zapisanego w provenance modelu. * mapA/mapT (v4, rozjaśnianie): mapa liniowa kotwicząca średnią wnętrza * mastera na średnią próbki — rozjaśnianie kompresuje odległość od bieli * (faktura zostaje, nic nie wypala), ściemnianie przez mapę kompresuje * odległość od czerni. Bez mapy: czysta delta + ew. kurtyna z assetów v3. * Współdzielone z Maciusiem — oczekiwana średnia liczona tym samym wzorem. */ /** @deprecated LEGACY_V1 — patrz uwaga nad applyLabMatch. */ export function transformL( l: number, d: Pick ): number { if (d.mapA != null && d.mapT != null) { if (d.mapT >= d.mapA) { const k = (100 - d.mapT) / Math.max(1e-6, 100 - d.mapA); return 100 - (100 - l) * k; } return (l * d.mapT) / Math.max(1e-6, d.mapA); } let nl = l + d.l; if (d.kneeT != null && nl > d.kneeT) { const s = d.kneeS ?? 4.5; nl = d.kneeT + ((nl - d.kneeT) * s) / (s + (nl - d.kneeT)); } return nl; } /** @deprecated LEGACY_V1 — patrz uwaga nad applyLabMatch wyżej. */ export function applyLabMatch( rgb: Buffer, fm: Pick, anchor: LabStats, target: LabStats, lWeight = 1 ): AppliedDelta { const dLraw = (target.mean[0] - anchor.mean[0]) * lWeight; const dA = target.mean[1] - anchor.mean[1]; const dB = target.mean[2] - anchor.mean[2]; let mapA: number | null = null; let mapT: number | null = null; if (dLraw > 0) { // Rozjaśnianie addytywną deltą dziedziczy offset ekspozycji sceny i // wypala jasne odcienie (odc. 03: cel 82 → wynik 86, ~1/3 wnętrza >95). // Zamiast tego mapa liniowa: średnia L wnętrza maski (alfa ≥ 200 — // ta sama populacja, którą ocenia Maciuś) ląduje na średniej próbki. // Rozjaśnianie kompresuje odległość od bieli, więc prążki i cienie // zostają czytelne. Ściemnianie (dL ≤ 0) zostaje czystą deltą — // kompresja ku czerni miażdżyłaby fakturę ciemnych tkanin. let sum = 0; let c = 0; for (let i = 0; i < fm.alpha.length; i++) { if ((fm.alpha[i] ?? 0) < 200) continue; sum += rgbToLab(rgb[i * 3]!, rgb[i * 3 + 1]!, rgb[i * 3 + 2]!)[0]; c++; } if (c > 0) { const A = sum / c; const T = Math.min(99, Math.max(1, anchor.mean[0] + dLraw)); if (A > 1 && A < 99 && Math.abs(T - A) > 0.05) { mapA = A; mapT = T; } } } const applied: AppliedDelta = { l: dLraw, a: dA, b: dB, kneeT: null, kneeS: null, mapA, mapT }; const clamp255 = (v: number) => Math.min(255, Math.max(0, v)); const n = rgb.length / 3; for (let i = 0; i < n; i++) { const aByte = fm.alpha[i] ?? 0; if (aByte <= ALPHA_MIN) continue; const a = aByte / 255; const br = fm.bgField[i * 3] ?? fm.bgRgb[0]; const bg = fm.bgField[i * 3 + 1] ?? fm.bgRgb[1]; const bb = fm.bgField[i * 3 + 2] ?? fm.bgRgb[2]; // dla a < UNMIX_FLOOR liczymy unmix jak przy a=UNMIX_FLOOR: korekcja // częściowa (reszta domieszki tła zostaje w c_sofa), ale szum wejścia // jest amplifikowany najwyżej ~3x zamiast ~25x przy a≈0.04 const aEff = Math.max(a, UNMIX_FLOOR); // czysty kolor mebla = usunięcie domieszki lokalnego tła z piksela krawędzi const r = clamp255((rgb[i * 3]! - (1 - aEff) * br) / aEff); const g = clamp255((rgb[i * 3 + 1]! - (1 - aEff) * bg) / aEff); const b = clamp255((rgb[i * 3 + 2]! - (1 - aEff) * bb) / aEff); const [l, pa, pb] = rgbToLab(r, g, b); const [nr, ng, nb2] = labToRgb(transformL(l, applied), pa + dA, pb + dB); rgb[i * 3] = Math.round(a * nr + (1 - a) * br); rgb[i * 3 + 1] = Math.round(a * ng + (1 - a) * bg); rgb[i * 3 + 2] = Math.round(a * nb2 + (1 - a) * bb); } return applied; } // ---------- silnik fabric_recolor v7 (produkcyjna ścieżka) ---------- // v9: wypełnianie defektów otoczki wypukłej (fill_hull_defects) — matte // ucinało zacienione partie tapicerki (dolny pas frontu, brzegi kadru); // alfa = max(feather(mask), matte·ring) — pełne pokrycie wewnątrz maski. // Wyniki v8 nie mogą być serwowane z cache pod niezmienionym kluczem. const RECOLOR_ENGINE = "fabric_recolor_v10"; /** * Katalog mikroserwisu — CLI wymaga cwd=services/recolor (importy pakietu). * Priorytet: VILMAL_RECOLOR_SERVICE_DIR; potem ścieżka względem modułu * (server/src/domain → vilmal/services/recolor); na końcu cwd procesu * (produkcyjny build w dist/ — tam import.meta wskazuje na dist/). */ export function recolorServiceDir(): string { const env = process.env.VILMAL_RECOLOR_SERVICE_DIR; if (env) return env; const byModule = fileURLToPath(new URL("../../../services/recolor", import.meta.url)); if (fs.existsSync(path.join(byModule, "fabric_recolor"))) return byModule; return path.resolve("services/recolor"); } const RECOLOR_TIMEOUT_MS = Number(process.env.VILMAL_RECOLOR_TIMEOUT_MS ?? 900_000); /** Raport RecolorReport ze stdout CLI (json.dumps(rep.__dict__)). */ export interface FabricRecolorReport { ok: boolean; output_path: string; mask_coverage: number; backend: string; /** true = maska z pliku sidecar (.mask.png), bez segmentacji v7 */ cached?: boolean; mask_path?: string | null; delta?: { l?: number; a?: number; b?: number; map_a?: number | null; map_t?: number | null; anchor_mode?: string; c_ratio?: number | null; l_ceil?: number | null; flags?: Record; }; texture?: Record; elapsed_s?: number; error?: string | null; } /** Parsowanie raportu — stdout może mieć przedrostki spoza JSON. */ export function parseRecolorReport(stdout: string): FabricRecolorReport { const start = stdout.indexOf("{"); const end = stdout.lastIndexOf("}"); if (start < 0 || end <= start) throw new Error("fabric_recolor: brak raportu JSON na stdout"); const rep = JSON.parse(stdout.slice(start, end + 1)) as FabricRecolorReport; if (typeof rep.mask_coverage !== "number" || rep.mask_coverage <= 0 || rep.mask_coverage > 1) { throw new Error(`fabric_recolor: niepoprawne mask_coverage=${rep.mask_coverage}`); } return rep; } /** * Jedno wywołanie silnika v7: python -m fabric_recolor.cli * [--anchor-swatch ]. * Logi silnika idą na stderr, raport JSON na stdout. Sukces = exit 0; * RecolorError (np. maska poza zakresem) = traceback na stderr + exit 1. */ export async function runFabricRecolorCli(input: { baseAbs: string; swatchAbs: string; anchorAbs?: string; outAbs: string; /** role-makro: maska ~całego kadru jest poprawna (RECOLOR_MAX_COVERAGE) */ maxCoverage?: number; }): Promise { const args = ["-m", "fabric_recolor.cli", input.baseAbs, input.swatchAbs, input.outAbs]; if (input.anchorAbs) args.push("--anchor-swatch", input.anchorAbs); const env = { ...process.env }; if (input.maxCoverage !== undefined) env["RECOLOR_MAX_COVERAGE"] = String(input.maxCoverage); let stdout = ""; try { const res = await runPython(args, { cwd: recolorServiceDir(), env, timeoutMs: RECOLOR_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024, }); stdout = res.stdout; } catch (e) { const err = e as { stdout?: string; stderr?: string; message?: string }; stdout = err.stdout ?? ""; let detail = ""; try { detail = parseRecolorReport(stdout).error ?? ""; } catch { /* brak raportu JSON — silnik padł przed zapisem raportu */ } if (!detail) { detail = (err.stderr ?? "") .trim() .split("\n") .filter((l) => l.trim() && !l.trimStart().startsWith("Traceback")) .slice(-2) .join(" | "); } throw new Error(`fabric_recolor CLI nie powiódł się: ${detail || err.message || String(e)}`); } const report = parseRecolorReport(stdout); if (!report.ok) throw new Error(`fabric_recolor: ${report.error ?? "silnik zgłosił porażkę"}`); if (!fs.existsSync(input.outAbs)) { throw new Error("fabric_recolor: raport ok, ale brak pliku wynikowego"); } return report; } // ---------- budowa wariantu ---------- export interface VariantRowFull { id: string; product_id: string; side: string; fabric_id: string; shade_code: string; status: string; } export type RecolorOutcome = | { asset: AssetRow; cached: boolean } | { missing: string[] }; /** Role-makro: kadr to z założenia sam materiał — maska ~całego kadru jest poprawna. */ const MACRO_FRAME_ROLES = new Set(["detail"]); /** Przygotowanie roli — master + kotwica raz na partię wariantów. */ export interface PreparedRole { role: ShotRole; base: AssetRow; baseAbs: string; photographedSide: string | null; anchorShade: string; anchorSwatch: { srcId: string; absPath: string; sha256: string; stats: LabStats }; /** Hash maski sidecar — korekta maski musi unieważnić cache wyniku. */ maskKey: string; swatchCache: Map; } /** Kandydaci maski sidecar obok mastera — te same nazwy co w serwisie Python. */ export function existingSidecarMask(baseAbs: string): string | null { const stem = baseAbs.replace(/\.[^.]+$/, ""); for (const p of [stem + ".mask.png", stem + "_mask.png"]) if (fs.existsSync(p)) return p; return null; } /** Cykl życia maski mastera: brak → do sprawdzenia (wypieczona) → zatwierdzona przez człowieka. */ export type MaskState = "missing" | "review" | "confirmed"; export function masterMaskState(db: Db, baseAbs: string, assetId: string): MaskState { if (!existingSidecarMask(baseAbs)) return "missing"; const r = db.prepare("SELECT mask_key FROM master_mask_reviews WHERE asset_id = ?").get(assetId) as unknown as { mask_key: string } | undefined; return r?.mask_key === sidecarMaskKey(baseAbs) ? "confirmed" : "review"; } function sidecarMaskKey(baseAbs: string): string { const p = existingSidecarMask(baseAbs); return p ? crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex").slice(0, 16) : "nomask"; } export async function prepareRecolorRole( db: Db, cfg: AppConfig, input: { productId: string; role: ShotRole; anchorShade?: string; requireMask?: boolean } ): Promise { const missing: string[] = []; const role = input.role; const sideRow = db .prepare( "SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'sides.photographed' AND status = 'confirmed'" ) .get(input.productId) as { value_json: string } | undefined; const photographedSide = sideRow ? String(JSON.parse(sideRow.value_json)) : null; const base = approvedAssetForRole(db, input.productId, role, null); if (!base) missing.push(`brak zatwierdzonego ujęcia „${role}" jako mastera recoloru`); // anchorShade z faktu potwierdzonego; override wyłącznie do podglądów/skryptów const anchorRow = db .prepare( "SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'fabric.photographed_shade' AND status = 'confirmed'" ) .get(input.productId) as { value_json: string } | undefined; const anchorShade = input.anchorShade ?? (anchorRow ? String(JSON.parse(anchorRow.value_json)) : null); if (!anchorShade) missing.push("brak potwierdzonego odcienia fotografowanego (fabric.photographed_shade)"); const anchor = anchorShade ? (db .prepare( "SELECT s.official_image_path, s.verification AS shade_verification, f.verification AS fabric_verification FROM fabric_shades s JOIN variants v ON v.fabric_id = s.fabric_id JOIN fabrics f ON f.id = s.fabric_id WHERE v.product_id = ? AND s.code = ? LIMIT 1" ) .get(input.productId, anchorShade) as { official_image_path: string | null; shade_verification: string; fabric_verification: string } | undefined) : undefined; if (anchorShade && !anchor?.official_image_path) { missing.push(`kotwica ${anchorShade}: brak oficjalnej próbki`); } if (anchor?.shade_verification !== undefined && (anchor.shade_verification !== "confirmed" || anchor.fabric_verification !== "confirmed")) { missing.push(`kotwica ${anchorShade}: odcień lub tkanina niepotwierdzone — recolor tylko dla zweryfikowanych próbek`); } if (missing.length) return { missing }; const anchorSrc = anchor?.official_image_path ? (db .prepare("SELECT path, sha256 FROM source_assets WHERE id = ?") .get(anchor.official_image_path) as { path: string; sha256: string } | undefined) : undefined; const anchorAbs = anchorSrc ? path.join(cfg.originalsDir, anchorSrc.path) : null; if (anchorShade && anchor?.official_image_path && (!anchorAbs || !fs.existsSync(anchorAbs))) { missing.push(`kotwica ${anchorShade}: próbka nie istnieje na dysku`); } const baseAbs = base ? path.join(cfg.mediaDir, base.path) : null; if (base && (!baseAbs || !fs.existsSync(baseAbs))) missing.push("plik mastera nie istnieje na dysku"); if (missing.length) return { missing }; if (input.requireMask) { const st = masterMaskState(db, baseAbs!, base!.id); if (st === "missing") return { missing: [`maska mastera „${role}" nie jest wypieczona — wypiecz ją w Studio Wariantów`] }; if (st === "review") return { missing: [`maska mastera „${role}" czeka na Twoje zatwierdzenie w Studio Wariantów`] }; } return { role, base: base!, baseAbs: baseAbs!, photographedSide, anchorShade: anchorShade!, anchorSwatch: { srcId: anchor!.official_image_path!, absPath: anchorAbs!, sha256: anchorSrc!.sha256, stats: await swatchStats(anchorAbs!), }, maskKey: sidecarMaskKey(baseAbs!), swatchCache: new Map(), }; } interface ShadeRow { id: string; code: string; name: string | null; official_image_path: string | null; verification: string; fabric_name?: string | null; fabric_verification?: string; } export interface RenderedVariant { jpeg: Buffer; mirrored: boolean; report: FabricRecolorReport; tgtStats: LabStats; shade: ShadeRow; } /** * Render jednego wariantu na przygotowanej roli (bez zapisu do DB/dysku). * Silnik v7 wywoływany raz na unikalną parę (master, próbka, kotwica) — * wynik PNG + raport keszowane w derived/recolor-v7/ adresowane treścią, * więc strona L i P wariantu oraz retry joba nie liczą silnika dwa razy. */ export async function renderVariantRecolor( db: Db, cfg: AppConfig, prep: PreparedRole, variant: VariantRowFull ): Promise { if (variant.side !== "S" && !prep.photographedSide) { return { missing: ["brak potwierdzonego faktu sides.photographed — bez niego strona przeciwna nie dostanie lustra"] }; } const shade = db .prepare("SELECT s.*, f.name AS fabric_name, f.verification AS fabric_verification FROM fabric_shades s JOIN fabrics f ON f.id = s.fabric_id WHERE s.fabric_id = ? AND s.code = ?") .get(variant.fabric_id, variant.shade_code) as ShadeRow | undefined; if (!shade?.official_image_path) { return { missing: [`odcień ${variant.shade_code}: brak oficjalnej próbki producenta`] }; } if (shade.verification !== "confirmed" || shade.fabric_verification !== "confirmed") { return { missing: [`odcień ${variant.shade_code}: tkanina lub próbka niepotwierdzona — recolor tylko dla zweryfikowanych`] }; } let tgt = prep.swatchCache.get(shade.official_image_path); if (tgt === undefined) { const row = db.prepare("SELECT path, sha256 FROM source_assets WHERE id = ?").get(shade.official_image_path) as | { path: string; sha256: string } | undefined; const abs = row ? path.join(cfg.originalsDir, row.path) : null; tgt = abs && fs.existsSync(abs) ? { absPath: abs, sha256: row!.sha256, stats: await swatchStats(abs) } : null; prep.swatchCache.set(shade.official_image_path, tgt); } if (!tgt) return { missing: [`odcień ${variant.shade_code}: próbka nie istnieje na dysku`] }; // Strona przeciwna do fotografowanej: lustro konstrukcji na WYNIKU silnika // (decyzja właściciela z historii — dozwolone dla ujęć bez asymetrycznych // napisów/aranżacji). Provenance odnotowuje mirrored. const mirrored = Boolean(prep.photographedSide && variant.side !== "S" && variant.side !== prep.photographedSide); const cacheKey = crypto .createHash("sha256") .update( [ RECOLOR_ENGINE, prep.base.sha256, prep.maskKey, tgt.sha256, prep.anchorSwatch.sha256, MACRO_FRAME_ROLES.has(prep.role) ? "macro" : "std", ].join("|") ) .digest("hex") .slice(0, 32); const cacheDir = path.join(cfg.derivedDir, "recolor-v10"); const cachePng = path.join(cacheDir, `${cacheKey}.png`); const cacheJson = path.join(cacheDir, `${cacheKey}.json`); let report: FabricRecolorReport; if (fs.existsSync(cachePng) && fs.existsSync(cacheJson)) { report = JSON.parse(fs.readFileSync(cacheJson, "utf8")) as FabricRecolorReport; } else { fs.mkdirSync(cacheDir, { recursive: true }); const tmpOut = path.join(cacheDir, `${cacheKey}.tmp-${process.pid}.png`); report = await runFabricRecolorCli({ baseAbs: prep.baseAbs, swatchAbs: tgt.absPath, anchorAbs: prep.anchorSwatch.absPath, outAbs: tmpOut, ...(MACRO_FRAME_ROLES.has(prep.role) ? { maxCoverage: 1.0 } : {}), }); fs.renameSync(tmpOut, cachePng); fs.writeFileSync(cacheJson, JSON.stringify(report)); } let img = sharp(cachePng); if (mirrored) img = img.flop(); const jpeg = await img.jpeg({ quality: 90, mozjpeg: true }).toBuffer(); return { jpeg, mirrored, report, tgtStats: tgt.stats, shade }; } async function persistRecolorAsset( db: Db, cfg: AppConfig, input: { productId: string; variant: VariantRowFull; prep: PreparedRole; rendered: RenderedVariant; jobId?: string; beforePersist?: () => void } ): Promise<{ asset: AssetRow; cached: boolean }> { const { variant, prep, rendered } = input; const { jpeg, mirrored, report, tgtStats, shade } = rendered; const sha = crypto.createHash("sha256").update(jpeg).digest("hex"); // idempotencja: ta sama treść wyniku dla tego wariantu i roli → zwrot istniejącego const existing = db .prepare("SELECT * FROM assets WHERE variant_id = ? AND role = ? AND sha256 = ?") .get(variant.id, prep.role, sha) as AssetRow | undefined; if (existing) return { asset: existing, cached: true }; const meta = await sharp(jpeg).metadata(); const rel = path.join("assets", sha.slice(0, 2), `${sha}.jpg`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); // adresowanie treścią: istniejący plik o tym sha = ta sama treść if (!fs.existsSync(abs)) fs.writeFileSync(abs, jpeg, { flag: "wx" }); const thumbName = `${sha}-w480.webp`; const thumbAbs = path.join(cfg.derivedDir, sha.slice(0, 2), thumbName); fs.mkdirSync(path.dirname(thumbAbs), { recursive: true }); await sharp(jpeg).resize({ width: 480, withoutEnlargement: true }).webp({ quality: 82 }).toFile(thumbAbs); const thumbRel = path.relative(cfg.derivedDir, thumbAbs); const d = report.delta ?? {}; input.beforePersist?.(); const asset = registerAsset(db, { productId: input.productId, ...(input.jobId ? { jobId: input.jobId } : {}), variantId: variant.id, kind: "image", role: prep.role, parentAssetId: prep.base.id, sha256: sha, mime: "image/jpeg", bytes: jpeg.length, width: meta.width ?? null, height: meta.height ?? null, path: rel, thumbPath: thumbRel, origin: "derived", provenance: { method: RECOLOR_METHOD, recolor_engine: RECOLOR_ENGINE, mask_backend: report.backend, coverage: report.mask_coverage, baseAsset: { id: prep.base.id, sha256: prep.base.sha256, role: prep.role }, variant: { side: variant.side, shade: variant.shade_code, shadeName: shade.name }, anchorShade: prep.anchorShade, mirrored, swatches: { anchor: prep.anchorSwatch.srcId, target: shade.official_image_path }, stats: { anchorSwatch: prep.anchorSwatch.stats, targetSwatch: tgtStats }, delta: { l: d.l ?? 0, a: d.a ?? 0, b: d.b ?? 0, kneeT: null, kneeS: null, mapA: d.map_a ?? null, mapT: d.map_t ?? null, anchorMode: d.anchor_mode ?? null, cRatio: d.c_ratio ?? null, lCeil: d.l_ceil ?? null, }, mask: { method: `fabric_recolor:${report.backend}`, backend: report.backend, engine: RECOLOR_ENGINE, coverage: report.mask_coverage, fullFrame: report.mask_coverage >= 0.97, elapsedS: report.elapsed_s ?? null, texture: report.texture ?? {}, }, disclaimer: "Odwzorowanie barwy próbki w świetle sceny — nie gwarancja zgodności koloru na każdym monitorze.", }, note: `Recolor ${prep.role} → ${shade.fabric_name ?? "TKANINA"} ${variant.shade_code}${shade.name ? ` (${shade.name})` : ""} — zatwierdzenie wymagane.`, }); audit(db, { actor: "system", action: "variant.recolor", entity: "assets", entityId: asset.id, detail: { productId: input.productId, variantId: variant.id, role: prep.role, shade: variant.shade_code, baseSha: prep.base.sha256, sha256: sha, method: RECOLOR_METHOD, }, }); return { asset, cached: false }; } /** * Generuje kandydata recoloru dla wariantu z zatwierdzonego mastera. * Darmowe, synchroniczne, idempotentne: ta sama treść bazowa + odcień * + wersja metody → ta sama treść wyniku → zwrot istniejącego assetu. */ export async function buildRecolor( db: Db, cfg: AppConfig, input: { productId: string; variantId: string; role?: ShotRole; jobId?: string; beforePersist?: () => void; requireMask?: boolean } ): Promise { const role = input.role ?? "packshot_34"; const variant = db .prepare("SELECT * FROM variants WHERE id = ? AND product_id = ?") .get(input.variantId, input.productId) as VariantRowFull | undefined; if (!variant) return { missing: ["wariant nie istnieje"] }; const prep = await prepareRecolorRole(db, cfg, { productId: input.productId, role, ...(input.requireMask ? { requireMask: true } : {}) }); if ("missing" in prep) return { missing: prep.missing }; const rendered = await renderVariantRecolor(db, cfg, prep, variant); if ("missing" in rendered) return { missing: rendered.missing }; input.beforePersist?.(); return persistRecolorAsset(db, cfg, { productId: input.productId, variant, prep, rendered, ...(input.jobId ? { jobId: input.jobId } : {}), ...(input.beforePersist ? { beforePersist: input.beforePersist } : {}) }); } export interface RecolorBatchItemResult { assetId?: string; cached?: boolean; status?: string; missing?: string[]; /** wyjątek silnika/IO dla tej pozycji — nie przerywa reszty partii */ error?: string; } /** * Wsadowy recolor jednej roli: master + maska + pole tła przygotowane RAZ * (wcześniej dekodowane ~34× na wariant — redundantna praca CPU). * onItem wołany po każdym wariancie — do postępu zadania w workerze. */ export async function buildRecolorRoleBatch( db: Db, cfg: AppConfig, input: { productId: string; role: ShotRole; variantIds: string[]; onItem?: (variantId: string, result: RecolorBatchItemResult) => void | Promise; /** wstrzykiwany render — testy bez Pythona */ render?: typeof renderVariantRecolor; /** wymaga zatwierdzonej maski mastera (ciężka segmentacja nie startuje po cichu w środku wsadu) */ requireMask?: boolean; } ): Promise<{ prepared: true } | { missing: string[] }> { const render = input.render ?? renderVariantRecolor; let prep: PreparedRole | { missing: string[] }; try { prep = await prepareRecolorRole(db, cfg, { productId: input.productId, role: input.role, ...(input.requireMask ? { requireMask: true } : {}) }); } catch (e) { prep = { missing: [`przygotowanie roli nie powiodło się: ${e instanceof Error ? e.message : String(e)}`] }; } if ("missing" in prep) { for (const variantId of input.variantIds) { await input.onItem?.(variantId, { missing: prep.missing }); } return { missing: prep.missing }; } const stmt = db.prepare("SELECT * FROM variants WHERE id = ? AND product_id = ?"); for (const variantId of input.variantIds) { const variant = stmt.get(variantId, input.productId) as VariantRowFull | undefined; if (!variant) { await input.onItem?.(variantId, { missing: ["wariant nie istnieje"] }); continue; } // Błąd jednej pozycji (silnik, maska poza zakresem, IO) nie może zabić reszty partii. try { const rendered = await render(db, cfg, prep, variant); if ("missing" in rendered) { await input.onItem?.(variantId, { missing: rendered.missing }); continue; } const out = await persistRecolorAsset(db, cfg, { productId: input.productId, variant, prep, rendered }); await input.onItem?.(variantId, { assetId: out.asset.id, cached: out.cached, status: out.asset.status }); } catch (e) { await input.onItem?.(variantId, { error: e instanceof Error ? e.message : String(e) }); } } return { prepared: true }; } // ---------- gotowość Kroku 3, maska mastera ---------- const READINESS_ROLES: readonly ShotRole[] = ["hero", "packshot_front", "packshot_34", "side", "back", "sleep", "storage", "detail"]; export interface ReadinessAction { kind: "goto_master" | "set_anchor" | "set_side" | "verify_shades" | "bake_mask" | "review_mask"; role?: ShotRole; } export interface ReadinessCheck { key: "masters" | "anchor" | "side" | "shades" | "masks"; label: string; ok: boolean; /** true = brak uniemożliwia generowanie; false = zalecenie */ blocking: boolean; reason: string | null; action: ReadinessAction | null; } export interface RecolorReadiness { engine: string; canGenerate: boolean; checks: ReadinessCheck[]; roles: Array<{ role: ShotRole; masterId: string | null; masterVersion: number | null; ready: boolean; mask: MaskState; /** liczba wariantów (kandydaci/zatwierdzone) powstałych z INNEGO mastera niż obecny */ outdatedByMaster: number; }>; shades: Array<{ code: string; ok: boolean; reason: string | null }>; /** kandydaci/zatwierdzone media wariantów wygenerowane na nieaktualnych wejściach */ stale: Array<{ assetId: string; variantId: string; role: string; reasons: string[] }>; } function factJson(db: Db, productId: string, key: string): string | null { const r = db .prepare("SELECT value_json FROM product_facts WHERE product_id = ? AND key = ? AND status = 'confirmed'") .get(productId, key) as unknown as { value_json: string } | undefined; if (!r) return null; const v = JSON.parse(r.value_json) as unknown; return v == null || v === "" ? null : String(v); } /** * Wspólne źródło prawdy o tym, czy warianty da się wygenerować — ten sam * zestaw warunków co prepareRecolorRole/renderVariantRecolor, ale z jawnym * powodem i akcją naprawczą dla każdego braku (bez uruchamiania silnika). */ export function recolorReadiness(db: Db, cfg: AppConfig, productId: string): RecolorReadiness { const variants = db .prepare("SELECT side, fabric_id, shade_code FROM variants WHERE product_id = ?") .all(productId) as unknown as Array<{ side: string; fabric_id: string; shade_code: string }>; const roles = READINESS_ROLES.map((role) => { const master = approvedAssetForRole(db, productId, role, null); const abs = master ? path.join(cfg.mediaDir, master.path) : null; const present = Boolean(master && abs && fs.existsSync(abs)); return { role, masterId: present ? master!.id : null, masterVersion: present ? master!.version : null, ready: present, mask: (present ? masterMaskState(db, abs!, master!.id) : "missing") as MaskState, outdatedByMaster: 0, }; }); const readyRoles = roles.filter((r) => r.ready); const anchorShade = factJson(db, productId, "fabric.photographed_shade"); let anchorReason: string | null = null; if (!anchorShade) anchorReason = "Odcień fotografowanego egzemplarza nie jest potwierdzony."; else { const a = db .prepare( "SELECT s.official_image_path AS p, s.verification AS sv, f.verification AS fv FROM fabric_shades s JOIN variants v ON v.fabric_id = s.fabric_id JOIN fabrics f ON f.id = s.fabric_id WHERE v.product_id = ? AND s.code = ? LIMIT 1" ) .get(productId, anchorShade) as unknown as { p: string | null; sv: string; fv: string } | undefined; if (!a) anchorReason = `Odcień ${anchorShade} nie należy do tkaniny produktu.`; else if (!a.p) anchorReason = `Kotwica ${anchorShade}: brak oficjalnej próbki producenta.`; else if (a.sv !== "confirmed" || a.fv !== "confirmed") anchorReason = `Kotwica ${anchorShade}: odcień lub tkanina niepotwierdzone.`; else { const src = db.prepare("SELECT path FROM source_assets WHERE id = ?").get(a.p) as unknown as { path: string } | undefined; if (!src || !fs.existsSync(path.join(cfg.originalsDir, src.path))) anchorReason = `Kotwica ${anchorShade}: plik próbki nie istnieje na dysku.`; } } const needsSide = variants.some((v) => v.side !== "S"); const side = factJson(db, productId, "sides.photographed"); const shadeCodes = [...new Set(variants.map((v) => v.shade_code))]; const shades = shadeCodes.map((code) => { const fabricId = variants.find((v) => v.shade_code === code)!.fabric_id; const r = db .prepare("SELECT s.official_image_path AS p, s.verification AS sv, f.verification AS fv FROM fabric_shades s JOIN fabrics f ON f.id = s.fabric_id WHERE s.fabric_id = ? AND s.code = ?") .get(fabricId, code) as unknown as { p: string | null; sv: string; fv: string } | undefined; let reason: string | null = null; if (!r?.p) reason = "brak oficjalnej próbki producenta"; else if (r.sv !== "confirmed" || r.fv !== "confirmed") reason = "odcień lub tkanina niepotwierdzone"; else { const src = db.prepare("SELECT path FROM source_assets WHERE id = ?").get(r.p) as unknown as { path: string } | undefined; if (!src || !fs.existsSync(path.join(cfg.originalsDir, src.path))) reason = "plik próbki nie istnieje na dysku"; } return { code, ok: reason === null, reason }; }); const badShades = shades.filter((s) => !s.ok); const needMask = readyRoles.filter((r) => r.mask !== "confirmed"); const confirmedMasks = readyRoles.filter((r) => r.mask === "confirmed"); const checks: ReadinessCheck[] = [ { key: "masters", label: "Mastery ujęć zatwierdzone", ok: readyRoles.length > 0, blocking: true, reason: readyRoles.length === roles.length ? null : readyRoles.length === 0 ? "Brak zatwierdzonego mastera — wariant powstaje z mastera (Krok 2)." : `Zatwierdzone ${readyRoles.length}/${roles.length} ról; pozostałe zostaną pominięte: ${roles.filter((r) => !r.ready).map((r) => r.role).join(", ")}.`, action: readyRoles.length === roles.length ? null : { kind: "goto_master" }, }, { key: "anchor", label: "Odcień-kotwica potwierdzony", ok: anchorReason === null, blocking: true, reason: anchorReason, action: anchorReason ? { kind: "set_anchor" } : null, }, { key: "side", label: "Strona fotografowana (L/P) potwierdzona", ok: !needsSide || side !== null, blocking: needsSide, reason: needsSide && side === null ? "Bez tej strony nie da się odbić konstrukcji dla strony przeciwnej." : null, action: needsSide && side === null ? { kind: "set_side" } : null, }, { key: "shades", label: "Odcienie tkaniny zweryfikowane", ok: badShades.length === 0, blocking: shades.length > 0 && badShades.length === shades.length, reason: badShades.length ? `${badShades.length}/${shades.length} odcieni nie przejdzie: ${badShades.slice(0, 6).map((s) => s.code).join(", ")}${badShades.length > 6 ? "…" : ""}.` : null, action: badShades.length ? { kind: "verify_shades" } : null, }, { key: "masks", label: "Maski mastera zatwierdzone", ok: needMask.length === 0, blocking: readyRoles.length > 0 && confirmedMasks.length === 0, reason: needMask.length ? `Warianty powstają tylko dla ról z zatwierdzoną maską. ${needMask.map((r) => `${r.role}: ${r.mask === "missing" ? "do wypieczenia" : "do sprawdzenia"}`).join("; ")}.` : null, action: needMask.length ? (needMask.find((r) => r.mask === "review") ? { kind: "review_mask", role: needMask.find((r) => r.mask === "review")!.role } : { kind: "bake_mask", role: needMask[0]!.role }) : null, }, ]; // nieaktualne media wariantów: silnik, master albo kotwica zmienione po wygenerowaniu const masterByRole = new Map(roles.map((r) => [r.role as string, r.masterId])); const stale: RecolorReadiness["stale"] = []; const rows = db .prepare( "SELECT id, variant_id, role, provenance_json FROM assets WHERE product_id = ? AND variant_id IS NOT NULL AND status IN ('candidate','approved') AND json_extract(provenance_json, '$.method') LIKE 'fabric_recolor%'" ) .all(productId) as unknown as Array<{ id: string; variant_id: string; role: string; provenance_json: string }>; for (const r of rows) { let prov: { recolor_engine?: string; baseAsset?: { id?: string }; anchorShade?: string } = {}; try { prov = JSON.parse(r.provenance_json); } catch { /* uszkodzony provenance = nieaktualny */ } const reasons: string[] = []; if (prov.recolor_engine !== RECOLOR_ENGINE) reasons.push("starszy silnik"); if (masterByRole.has(r.role) && prov.baseAsset?.id !== masterByRole.get(r.role)) { reasons.push("master zmieniony"); const ro = roles.find((x) => x.role === r.role); if (ro) ro.outdatedByMaster++; } if (anchorShade && prov.anchorShade !== anchorShade) reasons.push("inna kotwica"); if (reasons.length) stale.push({ assetId: r.id, variantId: r.variant_id, role: r.role, reasons }); } return { engine: RECOLOR_ENGINE, canGenerate: checks.filter((c) => c.blocking).every((c) => c.ok), checks, roles, shades, stale, }; } /** Plik zatwierdzonego mastera roli — jedyne dopuszczalne wejście maski/podglądu. */ function approvedMasterFile(db: Db, cfg: AppConfig, productId: string, role: ShotRole): { asset: AssetRow; abs: string } | { error: string } { const asset = approvedAssetForRole(db, productId, role, null); if (!asset) return { error: `Brak zatwierdzonego mastera „${role}".` }; const abs = path.join(cfg.mediaDir, asset.path); if (!fs.existsSync(abs)) return { error: "Plik mastera nie istnieje na dysku." }; return { asset, abs }; } /** * Wypiekanie maski segmentacji dla zatwierdzonego mastera (sidecar * `{master}.mask.png`) — python -m fabric_recolor.cli --bake-mask. Ciężkie * (modele), więc wywoływane wyłącznie przez zadanie w kolejce. */ export async function bakeMasterMask( db: Db, cfg: AppConfig, input: { productId: string; role: ShotRole; force?: boolean } ): Promise<{ coverage: number; backend: string }> { const m = approvedMasterFile(db, cfg, input.productId, input.role); if ("error" in m) throw new Error(m.error); const args = ["-m", "fabric_recolor.cli", "--bake-mask", m.abs, ...(input.force ? ["--force"] : [])]; const env = { ...process.env }; if (MACRO_FRAME_ROLES.has(input.role)) env["RECOLOR_MAX_COVERAGE"] = "1.0"; let stdout = ""; try { stdout = (await runPython(args, { cwd: recolorServiceDir(), env, timeoutMs: RECOLOR_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 })).stdout; } catch (e) { const err = e as { stdout?: string; stderr?: string; message?: string }; let detail = ""; try { const out = err.stdout ?? ""; detail = (JSON.parse(out.slice(out.indexOf("{"))) as { error?: string }).error ?? ""; } catch { /* brak raportu JSON */ } if (!detail) detail = (err.stderr ?? "").trim().split("\n").filter((l) => l.trim() && !l.trimStart().startsWith("Traceback")).slice(-2).join(" | "); throw new Error(`Wypiekanie maski nie powiodło się: ${detail || err.message || String(e)}`); } const rep = JSON.parse(stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1)) as { ok?: boolean; mask_coverage?: number; backend?: string; error?: string | null }; if (!rep.ok) throw new Error(`Wypiekanie maski: ${rep.error ?? "silnik zgłosił porażkę"}`); audit(db, { actor: "system", action: "master.mask_baked", entity: "assets", entityId: m.asset.id, detail: { productId: input.productId, role: input.role, coverage: rep.mask_coverage ?? null, backend: rep.backend ?? null } }); return { coverage: rep.mask_coverage ?? 0, backend: rep.backend ?? "?" }; } /** * Człowiek potwierdza, że maska obejmuje tapicerkę (bez nóg i tła). * Zapis wiąże asset mastera z hashem pliku maski — nowy master albo * przeliczona maska wymagają ponownego zatwierdzenia. */ export function confirmMasterMask(db: Db, cfg: AppConfig, input: { productId: string; role: ShotRole; actor: string }): { maskKey: string } | { error: string } { const m = approvedMasterFile(db, cfg, input.productId, input.role); if ("error" in m) return { error: m.error }; if (!existingSidecarMask(m.abs)) return { error: "Maska tego mastera nie jest jeszcze wypieczona." }; const maskKey = sidecarMaskKey(m.abs); db.prepare("INSERT OR REPLACE INTO master_mask_reviews (asset_id, mask_key, reviewed_by, created_at) VALUES (?, ?, ?, ?)") .run(m.asset.id, maskKey, input.actor, new Date().toISOString()); audit(db, { actor: input.actor, action: "master.mask_confirmed", entity: "assets", entityId: m.asset.id, detail: { productId: input.productId, role: input.role, maskKey } }); return { maskKey }; } /** Nakładka PNG: master z zaznaczoną (czerwonym półprzezroczystym kolorem) maską tapicerki. */ export async function maskOverlayPng( db: Db, cfg: AppConfig, input: { productId: string; role: ShotRole } ): Promise<{ png: Buffer; coverage: number } | { error: string; code: 404 | 409 }> { const m = approvedMasterFile(db, cfg, input.productId, input.role); if ("error" in m) return { error: m.error, code: 404 }; const maskPath = existingSidecarMask(m.abs); if (!maskPath) return { error: "Maska tego mastera nie jest jeszcze wypieczona.", code: 409 }; const MAX = 1400; const { data: rgb, info } = await sharp(m.abs) .rotate() .resize({ width: MAX, height: MAX, fit: "inside", withoutEnlargement: true }) .removeAlpha() .raw() .toBuffer({ resolveWithObject: true }); const maskRaw = await sharp(maskPath).resize(info.width, info.height, { fit: "fill" }).greyscale().raw().toBuffer(); const out = Buffer.from(rgb); let covered = 0; for (let i = 0; i < maskRaw.length; i++) { const a = maskRaw[i]! / 255; if (a > 0.5) covered++; if (a <= 0.02) continue; const o = i * 3; const k = a * 0.5; out[o] = Math.round(out[o]! * (1 - k) + 255 * k); out[o + 1] = Math.round(out[o + 1]! * (1 - k) + 40 * k); out[o + 2] = Math.round(out[o + 2]! * (1 - k) + 60 * k); } const png = await sharp(out, { raw: { width: info.width, height: info.height, channels: 3 } }).png({ compressionLevel: 6 }).toBuffer(); return { png, coverage: covered / maskRaw.length }; }