import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { openDb } from "../src/db.js"; import { loadConfig } from "../src/config.js"; import { createUser } from "../src/auth.js"; import { createProduct } from "../src/domain/product.js"; import { upsertFabric, upsertShade, ensureVariantMatrix } from "../src/domain/fabric.js"; import { setFact } from "../src/domain/facts.js"; import { assignShotRef } from "../src/domain/shotRefs.js"; import { ingestUpload } from "../src/media/ingest.js"; import { registerAsset } from "../src/domain/assets.js"; import { buildShotRecipe, roomScenePrompt, materialSwapPrompt } from "../src/ai/recipes.js"; import { CANVAS, composeFromHall, planStudioShot, enqueueStudioShot, setMaterialLink, setExclusion, STUDIO_ENGINE, StudioError, } from "../src/domain/studio.js"; function fixture(t: { after: (fn: () => void) => void }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-studio-")); const cfg = loadConfig({ env: "test", dataDir: dir, dbPath: path.join(dir, "test.db"), mediaDir: path.join(dir, "media"), originalsDir: path.join(dir, "media/originals"), derivedDir: path.join(dir, "media/derived") }); const db = openDb(cfg.dbPath); t.after(() => { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }); const actor = createUser(db, { login: "ap", password: "test-only", role: "approver", displayName: "Approver" }); return { db, cfg, dir, actor }; } /** Syntetyczne „zdjęcie hali”: szara posadzka z zielonkawą dominantą + beżowy prostokąt-mebel, i jego maska. */ async function hallFixture(dir: string) { const W = 800, H = 600; const sofa = { left: 200, top: 220, width: 400, height: 200 }; const img = await sharp({ create: { width: W, height: H, channels: 3, background: { r: 120, g: 132, b: 120 } } }) .composite([{ input: await sharp({ create: { width: sofa.width, height: sofa.height, channels: 3, background: { r: 200, g: 190, b: 175 } } }).png().toBuffer(), left: sofa.left, top: sofa.top }]) .jpeg({ quality: 95 }).toBuffer(); const matte = await sharp({ create: { width: W, height: H, channels: 3, background: "#000000" } }) .composite([{ input: await sharp({ create: { width: sofa.width, height: sofa.height, channels: 3, background: "#ffffff" } }).png().toBuffer(), left: sofa.left, top: sofa.top }]) .toColourspace("b-w").png().toBuffer(); const imgPath = path.join(dir, "hall.jpg"); const mattePath = path.join(dir, "matte.png"); fs.writeFileSync(imgPath, img); fs.writeFileSync(mattePath, matte); return { imgPath, mattePath, img }; } test("composeFromHall: kanwa 4:3, mebel z pikseli źródła, białe tło, cień tylko pod meblem, balans bieli z posadzki", async (t) => { const f = fixture(t); const h = await hallFixture(f.dir); const c = await composeFromHall(f.cfg, h.imgPath, h.mattePath); const meta = await sharp(c.canvas).metadata(); assert.equal(meta.width, CANVAS.w); assert.equal(meta.height, CANVAS.h); // posadzka miała dominantę zieleni → zysk G < 1, R/B > 1 (korekta w granicach) assert.ok(c.wb.gains[1] < 1 && c.wb.gains[0] > 1 && c.wb.gains[2] > 1, JSON.stringify(c.wb)); assert.ok(c.wb.floorPixels > 100); const { data, info } = await sharp(c.canvas).removeAlpha().raw().toBuffer({ resolveWithObject: true }); const px = (x: number, y: number) => { const i = (y * info.width + x) * 3; return [data[i]!, data[i + 1]!, data[i + 2]!]; }; const p = c.placement; // górny róg kanwy — czysta biel assert.deepEqual(px(20, 20), [255, 255, 255]); // nad meblem nie ma cienia (halo) assert.deepEqual(px(p.left + Math.round(p.width / 2), Math.max(0, p.top - 40)), [255, 255, 255]); // tuż pod meblem jest cień (ciemniej niż biel) const under = px(p.left + Math.round(p.width / 2), p.top + p.height + 6); assert.ok(under[0]! < 245, `cień pod meblem: ${under}`); // wnętrze mebla = piksele źródła po korekcie (beż), nie wygenerowane const mid = px(p.left + Math.round(p.width / 2), p.top + Math.round(p.height / 2)); const exp = [200 * c.wb.gains[0] * c.wb.exposure, 190 * c.wb.gains[1] * c.wb.exposure, 175 * c.wb.gains[2] * c.wb.exposure].map((v) => Math.min(255, v)); mid.forEach((v, i) => assert.ok(Math.abs(v - exp[i]!) <= 4, `kanał ${i}: ${v} vs ${exp[i]}`)); // mebel mieści się w kadrze z marginesem i stoi w dolnej części assert.ok(p.left > 0 && p.left + p.width < CANVAS.w && p.top > 0); assert.ok(Math.abs(p.top + p.height - Math.round(CANVAS.h * 0.88)) <= 1); // warstwa mebla: przezroczysta poza sylwetką, pełna w środku const la = await sharp(c.layer).extractChannel(3).raw().toBuffer(); assert.equal(la[20 * CANVAS.w + 20], 0); assert.equal(la[(p.top + Math.round(p.height / 2)) * CANVAS.w + p.left + Math.round(p.width / 2)], 255); }); test("planStudioShot: role hali bez kosztu, hero płatne, storage bez primary zablokowany; zamiana materiału z produktu bazowego", async (t) => { const f = fixture(t); const tilia = createProduct(f.db, { slug: "oskar-t", name: "OSKAR", kind: "corner_sofa" }); const lumo = createProduct(f.db, { slug: "oskar-l", name: "OSKAR", kind: "corner_sofa" }); const src = await ingestUpload(f.db, f.cfg, { buffer: await sharp({ create: { width: 64, height: 48, channels: 3, background: "#bbbbbb" } }).jpeg().toBuffer(), originalName: "hala.jpg", productId: tilia.id, role: "product_reference", note: null, uploadedBy: f.actor, sourceUrl: null, fetchedAt: null, }); for (const role of ["hero", "packshot_front", "detail"] as const) { assignShotRef(f.db, { productId: tilia.id, shotRole: role, sourceId: src.id, refKind: "primary", actor: f.actor }); } setExclusion(f.db, src.id, "p:0.1,0.1,0.5,0.1,0.5,0.2", f.actor, "folia"); assert.throws(() => setExclusion(f.db, src.id, "rm -rf /", f.actor)); const front = planStudioShot(f.db, tilia.id, "packshot_front"); assert.equal(front.method, "hall_isolation"); assert.equal(front.paid, false); assert.equal(front.exclude, "p:0.1,0.1,0.5,0.1,0.5,0.2"); assert.equal(front.inputs[0]?.id, src.id); assert.equal(planStudioShot(f.db, tilia.id, "hero").method, "room_lock"); assert.equal(planStudioShot(f.db, tilia.id, "hero").paid, true); assert.equal(planStudioShot(f.db, tilia.id, "detail").method, "hall_macro"); assert.match(planStudioShot(f.db, tilia.id, "storage").blocked ?? "", /Brak przypisanego zdjęcia/); assert.throws(() => enqueueStudioShot(f.db, { productId: tilia.id, role: "storage", actor: f.actor }), StudioError); // korekta promptu nie ma sensu dla ujęcia bez AI assert.throws( () => enqueueStudioShot(f.db, { productId: tilia.id, role: "packshot_front", actor: f.actor, overrides: { prompt_additions: "x", negative_prompt_additions: "", disabled_ref_ids: [], explanation_pl: "" } }), /bez AI/ ); const { job } = enqueueStudioShot(f.db, { productId: tilia.id, role: "packshot_front", actor: f.actor }); assert.equal(job.type, "studio_shot"); assert.equal(job.provider, null); // LUMO: link do konstrukcji TILIA, próbka odcienia-kotwicy const lumoFabric = upsertFabric(f.db, { code: "lumo", name: "LUMO", producer: "Davis" }); const swatch = await ingestUpload(f.db, f.cfg, { buffer: await sharp({ create: { width: 32, height: 32, channels: 3, background: "#998877" } }).jpeg().toBuffer(), originalName: "lumo-55.jpg", productId: null, role: "fabric_reference", note: null, uploadedBy: f.actor, sourceUrl: null, fetchedAt: null, }); upsertShade(f.db, lumoFabric.id, { code: "55", officialImagePath: swatch.id }); ensureVariantMatrix(f.db, { productId: lumo.id, fabricId: lumoFabric.id, sides: ["L"] }); setFact(f.db, lumo.id, { key: "fabric.photographed_shade", value: "55", status: "confirmed", sourceKind: "owner", changedBy: f.actor }); assert.throws(() => setMaterialLink(f.db, lumo.id, lumo.id, f.actor)); setMaterialLink(f.db, lumo.id, tilia.id, f.actor); const noBase = planStudioShot(f.db, lumo.id, "packshot_front"); assert.equal(noBase.method, "material_swap"); assert.match(noBase.blocked ?? "", /Brak mastera bazowego/); const master = registerAsset(f.db, { productId: tilia.id, kind: "image", role: "packshot_front", sha256: "a".repeat(64), mime: "image/png", bytes: 1, path: "assets/aa/x.png", origin: "derived", provenance: { engine: STUDIO_ENGINE, method: "hall_isolation" }, }); const withBase = planStudioShot(f.db, lumo.id, "packshot_front"); assert.equal(withBase.blocked, undefined); assert.equal(withBase.baseAssetId, master.id); assert.equal(withBase.swatchId, swatch.id); assert.equal(withBase.paid, true); assert.deepEqual(withBase.inputs.map((i) => i.id), [master.id, swatch.id]); }); test("receptury: krótkie, bez frazy kadrującej, bez sprzeczności z funkcją spania, Image N tylko dla wysłanych", () => { const ctx = { productName: "OSKAR", cushionCount: 5, legsNote: "", silhouetteNote: "softly rounded bolster armrests", slots: { geometryRefId: "g", fabricSwatchRefId: "s", fabricRealRefId: null, legRefId: null }, sentIds: ["g", "s"] }; for (const role of ["hero", "packshot_front", "packshot_34", "side", "back", "sleep", "storage", "detail"] as const) { const p = buildShotRecipe(role, ctx).prompt; assert.ok(p.length < 900, `${role}: ${p.length} znaków`); assert.doesNotMatch(p, /hero|centered/i, role); assert.doesNotMatch(p, /stationary sofa|recliner/i, role); assert.doesNotMatch(p, /folds, creases|real factory photo/i, role); assert.doesNotMatch(p, /Image 3/, role); assert.match(p, /Image 2 is the manufacturer's flat swatch/, role); assert.match(p, /no gloss/, role); // szablon jest neutralny wobec tkaniny — strukturę niosą wyłącznie // referencje obrazów (awaria „pasków" na bouclé ARENY wyszła z „crisp ribs" // zaszytego na sztywno pod sztruksową TILIĘ) assert.doesNotMatch(p, /\bribs?\b|corduroy|boucl|stripe|channel/i, role); // żadne zdanie nie powtarza się const sentences = p.split(/(?<=\.)\s+/).map((s) => s.trim()).filter(Boolean); assert.equal(new Set(sentences).size, sentences.length, `${role}: powtórzone zdanie`); } assert.match(buildShotRecipe("packshot_front", ctx).prompt, /exactly 5 loose back cushions/i); assert.doesNotMatch(buildShotRecipe("sleep", ctx).prompt, /5 loose back cushions/); assert.doesNotMatch(buildShotRecipe("packshot_front", { ...ctx, slots: { ...ctx.slots, fabricSwatchRefId: null }, sentIds: ["g"] }).prompt, /Image 2/); const full = buildShotRecipe("packshot_34", { ...ctx, slots: { geometryRefId: "g", fabricSwatchRefId: "s", fabricRealRefId: "r", legRefId: "l" }, sentIds: ["g", "s", "r", "l"] }); assert.match(full.prompt, /Image 1 defines the exact furniture geometry/); // kadr jest wyłącznie wzorcem bryły — jego tkanina i metki są ignorowane // (u FUJI zdjęcie vendora pokazuje inną tkaninę niż docelowa ARENA) assert.match(full.prompt, /camera angle only.*Ignore the fabric shown on it/s); assert.match(full.prompt, /labels or tags/); assert.match(full.prompt, /Image 2 is the manufacturer's flat swatch.*Image 3 shows the same fabric on furniture/); assert.match(full.prompt, /Re-upholster the furniture in this fabric/); assert.match(full.prompt, /Image 4 defines the exact legs/); // bez referencji tkaniny kadr niesie też tapicerkę (klauzula bez „only") const noFabric = buildShotRecipe("packshot_front", { ...ctx, slots: { ...ctx.slots, fabricSwatchRefId: null }, sentIds: ["g"] }); assert.match(noFabric.prompt, /camera angle and upholstery/); assert.match(full.negativePrompt, /distorted.*stitching.*blurred/); assert.match(full.negativePrompt, /stripes, ribs/); assert.doesNotMatch(roomScenePrompt(), /centered|hero/i); assert.match(materialSwapPrompt("side"), /Image 2/); });