import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import sharp from "sharp"; import type { Db } from "./db.js"; import type { AppConfig } from "./config.js"; import { claimNextJob, finishJob, setJobRequestId, getJob, deferJob } from "./domain/jobs.js"; import { registerAsset, getAsset } from "./domain/assets.js"; import { reviewAsset, MACIUS_PROMPT_VERSION } from "./domain/macius.js"; import { buildShotRecipe, applyShotOverrides, slotRefOrder, type ShotRefSlots } from "./ai/recipes.js"; import type { ShotOverridesT } from "./ai/shotTuner.js"; import { confirmedDims, dimensionStripSvg, composedSheetSvg, composedOverlaySvg, composedLayout, planBlueprintViews, blueprintPromptCtx, buildBlueprintViewPrompt, resolveViewRefs, aiSheetSvg, AI_ILLUST_BOX, type BlueprintCtx, type BlueprintView, type PlacedRects, } from "./domain/blueprint.js"; import type { ImageProvider } from "./ai/provider.js"; import type { ShotRole } from "./domain/types.js"; import { buildMissingRenditions, BASE_RENDITION_PROFILE } from "./domain/renditions.js"; import { originKindForSources } from "./domain/shotRefs.js"; import { buildRecolorRoleBatch, bakeMasterMask, type RecolorBatchItemResult } from "./domain/recolor.js"; import { runSourceImport } from "./domain/sourceImports.js"; import { runProductImport } from "./domain/productImport.js"; import { runFabricImport } from "./domain/fabricImport.js"; import { fetchSource, type SourceFetcher } from "./media/sourceFetch.js"; import { monitorProducts, runOrchestratorJob } from "./domain/orchestrator.js"; import { runExportBatch } from "./domain/exportOutbox.js"; import { runStudioShot, PROVIDER_MAX_WAIT_MIN } from "./domain/studio.js"; import { disabledBaseAdapter, type BaseAdapter } from "./integration/baseAdapter.js"; /** * Jeden worker w procesie serwera. Zadanie zapisane przed pracą zewnętrzną; * po restarcie zadania z provider_request_id wracają do pollingu * (wznowienie po request id, nie nowa generacja = brak podwójnych kosztów). */ export function startWorker(db: Db, cfg: AppConfig, provider: ImageProvider | null, sourceFetcher: SourceFetcher = fetchSource, baseAdapter: BaseAdapter = disabledBaseAdapter): { stop: () => Promise } { let stopped = false; let timer: NodeJS.Timeout | null = null; const importStop = new AbortController(); let currentImport: Promise | null = null; let currentOperation: Promise | null = null; let currentExport: Promise | null = null; const monitor = () => { if (stopped) return; try { monitorProducts(db, cfg); } catch { console.error("[orchestrator] Monitoring nie został ukończony; odczyt produktu zweryfikuje stan ponownie."); } }; const monitorTimer = setInterval(monitor, 30_000); const initialMonitor = setTimeout(monitor, 1000); // Zadania przerwane w połowie (restart procesu) wracają do kolejki. db.prepare( `UPDATE jobs SET status = CASE WHEN provider_request_id IS NOT NULL THEN 'waiting' ELSE 'queued' END, run_after = NULL, locked_at = NULL WHERE status = 'running'` ).run(); async function tick(): Promise { if (stopped) return; const job = claimNextJob(db); if (!job) { timer = setTimeout(tick, 1000); return; } try { if (job.type === "orchestrator_action") { currentOperation = runOrchestratorJob(db, cfg, job.id, () => stopped); try { await currentOperation; } finally { currentOperation = null; } } else if (job.type === "review_media_batch") { const input = JSON.parse(job.payload_json) as { productId: string; entries: Array<{ id: string; sha256: string }>; actor: string }; const progress = JSON.parse(job.result_json ?? "{}") as Record; for (const entry of input.entries) { if (stopped) break; if (progress[entry.id]) continue; const asset = getAsset(db, entry.id); if (!asset || asset.product_id !== input.productId || asset.sha256 !== entry.sha256 || !["approved", "candidate"].includes(asset.status)) { progress[entry.id] = { error: "Asset zmieniony lub wycofany — pominięto." }; } else { const existing = db.prepare("SELECT id FROM macius_verdicts WHERE asset_id = ? AND asset_sha256 = ? AND model = 'macius-rules' AND prompt_version = ? ORDER BY rowid DESC LIMIT 1").get(entry.id, entry.sha256, MACIUS_PROMPT_VERSION) as { id: string } | undefined; try { const verdict = existing ?? await reviewAsset(db, cfg, { assetId: entry.id, createdBy: input.actor, provider: "rules" }); progress[entry.id] = { verdictId: verdict.id }; } catch { progress[entry.id] = { error: "Nie udało się ocenić pliku — wymaga inspekcji." }; } } db.prepare("UPDATE jobs SET result_json = ? WHERE id = ?").run(JSON.stringify(progress), job.id); } if (stopped) deferJob(db, job.id, 0); else finishJob(db, job.id, { ok: true, result: { entries: progress } }); } else if (job.type === "generate_shot") { await runGenerateShot(db, cfg, job.id, provider); } else if (job.type === "studio_shot") { await runStudioShot(db, cfg, job.id, provider); } else if (job.type === "generate_blueprint") { const p = JSON.parse(job.payload_json) as GeneratePayload; if (p.blueprintMode === "composed") { await runBlueprintComposed(db, cfg, job.id, provider); } else { await runGenerateShot(db, cfg, job.id, provider); } } else if (job.type === "build_renditions") { await runBuildRenditions(db, cfg, job.id); } else if (job.type === "recolor_batch") { await runRecolorBatch(db, cfg, job.id, () => stopped); } else if (job.type === "bake_mask") { await runBakeMask(db, cfg, job.id); } else if (job.type === "import_source") { currentImport = runSourceImport(db, cfg, job.id, sourceFetcher, importStop.signal); try { await currentImport; } finally { currentImport = null; } } else if (job.type === "import_product") { currentImport = runProductImport(db, cfg, job.id, sourceFetcher, importStop.signal); try { await currentImport; } finally { currentImport = null; } } else if (job.type === "import_fabric") { currentImport = runFabricImport(db, cfg, job.id, sourceFetcher, importStop.signal); try { await currentImport; } finally { currentImport = null; } } else if (job.type === "export_dispatch") { currentExport = runExportBatch(db, cfg, baseAdapter, job.id, () => stopped); try { await currentExport; } finally { currentExport = null; } } else { finishJob(db, job.id, { ok: false, error: `Nieznany typ zadania: ${job.type}` }); } } catch (err) { finishJob(db, job.id, { ok: false, error: err instanceof Error ? err.message : String(err), retryable: true, }); } if (!stopped) timer = setTimeout(tick, 200); } timer = setTimeout(tick, 200); return { stop: async () => { stopped = true; if (timer) clearTimeout(timer); clearInterval(monitorTimer); clearTimeout(initialMonitor); importStop.abort(); await currentImport; await currentOperation; await currentExport; }, }; } async function runBuildRenditions(db: Db, cfg: AppConfig, jobId: string): Promise { const job = getJob(db, jobId)!; const payload = JSON.parse(job.payload_json) as { productId: string; profile?: string }; const result = await buildMissingRenditions( db, cfg, payload.productId, (progress) => db.prepare("UPDATE jobs SET result_json = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(progress), new Date().toISOString(), jobId), payload.profile ?? BASE_RENDITION_PROFILE ); finishJob(db, jobId, { ok: true, result }); } /** * Wsad recolorów wariantów — lokalna, deterministyczna praca CPU. * Zadania grupowane po roli: master+maska+pole tła dekodowane raz na rolę * (wcześniej każdy wariant dekodował je na nowo — ~34× redundantna praca, * która przy ~500 synchronicznych wywołaniach HTTP zamrażała maszynę). * Postęp zapisywany po każdym wariancie w result_json; przerwanie * (restart/stop) odkłada zadanie — wykonane pozycje są pomijane. */ async function runRecolorBatch( db: Db, cfg: AppConfig, jobId: string, isStopped: () => boolean ): Promise { const job = getJob(db, jobId)!; const payload = JSON.parse(job.payload_json) as { productId: string; tasks: Array<{ variantId: string; role: ShotRole }>; }; type Progress = { done: number; total: number; items: Record; }; const progress = (JSON.parse(job.result_json ?? "null") as Progress | null) ?? { done: 0, total: payload.tasks.length, items: {}, }; progress.total = payload.tasks.length; const save = () => db .prepare("UPDATE jobs SET result_json = ?, updated_at = ? WHERE id = ?") .run(JSON.stringify(progress), new Date().toISOString(), jobId); const byRole = new Map(); for (const t of payload.tasks) { const key = `${t.variantId}|${t.role}`; if (progress.items[key]) continue; if (!byRole.has(t.role)) byRole.set(t.role, []); byRole.get(t.role)!.push(t.variantId); } for (const [role, variantIds] of byRole) { if (isStopped()) break; await buildRecolorRoleBatch(db, cfg, { productId: payload.productId, role, variantIds, requireMask: true, onItem: async (variantId, result) => { progress.items[`${variantId}|${role}`] = result; progress.done++; save(); }, }); } if (isStopped()) { deferJob(db, jobId, 0); return; } const created = Object.values(progress.items).filter((r) => r.assetId && !r.cached && r.status !== "rejected").length; const cached = Object.values(progress.items).filter((r) => r.cached && r.status !== "rejected").length; const rejected = Object.values(progress.items).filter((r) => r.status === "rejected").length; const failed = Object.values(progress.items).filter((r) => r.missing || r.error).length; // Niepowodzenia pojedynczych pozycji nie kończą zadania błędem — zostają // na liście z powodem, żeby operator mógł ponowić tylko je. const failures = Object.entries(progress.items).flatMap(([key, r]) => { if (!r.missing && !r.error) return []; const [variantId = "", role = ""] = key.split("|"); return [{ variantId, role, reason: r.error ?? r.missing!.join("; ") }]; }); finishJob(db, jobId, { ok: true, result: { ...progress, created, cached, rejected, failed, failures } }); } /** Wypiekanie maski segmentacji mastera (lokalnie, ciężkie) — jedno zadanie na rolę. */ async function runBakeMask(db: Db, cfg: AppConfig, jobId: string): Promise { const job = getJob(db, jobId)!; const p = JSON.parse(job.payload_json) as { productId: string; role: ShotRole; force?: boolean }; const res = await bakeMasterMask(db, cfg, { productId: p.productId, role: p.role, ...(p.force ? { force: true } : {}) }); finishJob(db, jobId, { ok: true, result: res }); } /** Kontekst nagłówka/tabelki karty rysunku — produkt, tkanina+odcień, data. */ function blueprintCtx(db: Db, productId: string): BlueprintCtx { const product = db .prepare("SELECT name FROM products WHERE id = ?") .get(productId) as unknown as { name: string }; const fabric = db .prepare( "SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? LIMIT 1" ) .get(productId) as unknown as { name: string } | undefined; const shade = db .prepare( "SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'fabric.photographed_shade' AND status = 'confirmed'" ) .get(productId) as unknown as { value_json: string } | undefined; return { productName: product.name, fabricLabel: [fabric?.name, shade ? String(JSON.parse(shade.value_json)) : ""] .filter(Boolean) .join(" ") || "—", date: new Date().toISOString().slice(0, 10), }; } interface GeneratePayload { productId: string; role: ShotRole; sourceIds: string[]; /** primary ze shot_refs — referencja-wzorzec stanu (do provenance i Maciusia) */ primarySourceId?: string | null; /** skąd wzięto sourceIds: przypisania ról czy jawny override operatora */ resolvedFrom?: "shot_refs" | "explicit"; refKinds?: Record; prompt?: string; resolvedPrompt?: string; blueprintMode?: "ai" | "hybrid" | "composed"; /** blueprint ai: "high" — ostrzejsze etykiety tekstu na rysunku */ quality?: "low" | "medium" | "high"; /** rozstrzygnięte referencje multi-image (primary → tkanina → nóżka → reszta); utrwalane dla retry/provenance */ resolvedRefIds?: string[]; /** ostrzeżenia o brakujących fizycznych referencjach (np. nóżka bez zdjęcia) */ refWarnings?: string[]; /** * Nakładka Asystenta Korekty (Groq) — żyje WYŁĄCZNIE w tym zadaniu. * Baza (products/product_facts/shot_refs) i receptury pozostają * nienaruszone; następna generacja startuje z czystego szablonu. */ ephemeral_overrides?: ShotOverridesT; submittedAt?: string; providerStatus?: { status: string; queuePosition: number | null; polledAt: string }; } /** * Sloty 2 i 3 — referencje tkaniny z odcienia fotografowanego egzemplarza. * Kotwica = fakt fabric.photographed_shade → odcień tkaniny powiązanej * wariantami produktu. Slot 2 = „Próbka producenta" * (fabric_shades.official_image_path), slot 3 = „Tkanina na meblu" * (fabric_shades.photo_path — zdjęcie Vilmax z hali). * * Kotwica obowiązuje także przed potwierdzeniem faktu (unverified) — to * jedyna wartość, którą operator wpisał; status trafia do ostrzeżeń. * Bez kotwicy NIE ma cichego fallbacku na inny odcień kolekcji — cichy * wybór pierwszego odcienia zmieniał tkaninę bez wiedzy operatora * (FUJI dostawał Ivory zamiast Beige). Brak referencji = puste sloty + * jawne ostrzeżenie; wtedy tapicerkę niesie zdjęcie kadru (slot 1). */ export function resolveFabricRefs(db: Db, productId: string): { fabricSwatchRefId: string | null; fabricRealRefId: string | null; warnings: string[]; } { type ShadeRefs = { official_image_path: string | null; photo_path: string | null }; const warnings: string[] = []; const none = { fabricSwatchRefId: null, fabricRealRefId: null }; const fabricRow = db .prepare( `SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? ORDER BY v.created_at LIMIT 1` ) .get(productId) as unknown as { name: string } | undefined; if (!fabricRow) { warnings.push("Produkt nie ma powiązanej tkaniny — sloty 2 i 3 puste; model przeniesie tapicerkę ze zdjęcia kadru (slot 1)."); return { ...none, warnings }; } const fact = db .prepare("SELECT value_json, status FROM product_facts WHERE product_id = ? AND key = 'fabric.photographed_shade'") .get(productId) as unknown as { value_json: string; status: string } | undefined; const shadeCode = fact ? String(JSON.parse(fact.value_json) ?? "").trim() : ""; if (!shadeCode) { warnings.push(`Nie wybrano odcienia fotografowanego egzemplarza (${fabricRow.name}) — sloty 2 i 3 puste; model przeniesie tapicerkę ze zdjęcia kadru. Wybierz odcień w Kroku 1.`); return { ...none, warnings }; } const row = db .prepare( `SELECT s.photo_path, s.official_image_path FROM fabric_shades s JOIN variants v ON v.fabric_id = s.fabric_id WHERE v.product_id = ? AND s.code = ? LIMIT 1` ) .get(productId, shadeCode) as unknown as ShadeRefs | undefined; if (!row) { warnings.push(`Kolekcja ${fabricRow.name} nie zawiera odcienia „${shadeCode}" — popraw fakt fabric.photographed_shade; model przeniesie tapicerkę ze zdjęcia kadru.`); return { ...none, warnings }; } if (!row.official_image_path && !row.photo_path) { warnings.push(`Odcień ${fabricRow.name} ${shadeCode} nie ma próbki producenta ani „Zdjęcia Vilmax" — sloty 2 i 3 puste; model przeniesie tapicerkę ze zdjęcia kadru. Uzupełnij kartę tkaniny w katalogu.`); return { ...none, warnings }; } if (fact!.status !== "confirmed") { warnings.push(`Odcień fotografowanego egzemplarza (${fabricRow.name} ${shadeCode}) nie jest potwierdzony — referencje tkaniny działają roboczo.`); } if (!row.photo_path) { warnings.push(`Odcień ${fabricRow.name} ${shadeCode} nie ma „Zdjęcia Vilmax" (tkanina na meblu) — slot 3 pusty, struktura tylko z próbki producenta.`); } return { fabricSwatchRefId: row.official_image_path, fabricRealRefId: row.photo_path, warnings }; } /** Kontekst receptury ujęcia z faktów produktu i katalogów (tkanina, nóżki). */ export interface ShotGenContext { productName: string; sleepingSurface: string; /** liczba luźnych poduszek — tylko fakt confirmed */ cushionCount: number | null; silhouetteNote: string; legsNote: string; fabricPrompt: string; fabricSwatchRefId: string | null; fabricRealRefId: string | null; legRefId: string | null; refWarnings: string[]; } /** * Wspólny kontekst generacji ujęcia — worker i podgląd Asystenta Korekty * czytają te same fakty/katalogi, więc LLM widzi dokładnie te referencje * i klauzule, które realnie trafią do promptu. */ export function shotGenContext(db: Db, productId: string): ShotGenContext { const factVal = (key: string): unknown => { const r = db .prepare("SELECT value_json FROM product_facts WHERE product_id = ? AND key = ?") .get(productId, key) as unknown as { value_json: string } | undefined; return r ? JSON.parse(r.value_json) : undefined; }; // Tylko fakt potwierdzony może wpłynąć na twarde fakty w prompcie — // brak faktu = brak klauzuli (nigdy domyślna wartość innego produktu). const confirmedFactVal = (key: string): unknown => { 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; return r ? JSON.parse(r.value_json) : undefined; }; const refWarnings: string[] = []; const { fabricSwatchRefId, fabricRealRefId, warnings: fabricWarnings } = resolveFabricRefs(db, productId); refWarnings.push(...fabricWarnings); const legRefId = (() => { const legId = factVal("legs.furniture_leg_id"); if (!legId) return null; const row = db .prepare("SELECT image_asset_id FROM furniture_legs WHERE id = ?") .get(String(legId)) as unknown as { image_asset_id: string | null } | undefined; if (!row?.image_asset_id) { refWarnings.push(`Nóżka ${legId} nie ma zdjęcia referencyjnego — slot 4 (nóżki) nie zostanie przekazany, opis tylko tekstem.`); } return row?.image_asset_id ?? null; })(); const product = db .prepare("SELECT name FROM products WHERE id = ?") .get(productId) as unknown as { name: string }; // Struktura tkaniny bazowej — ai_prompt tkaniny przypisanej do produktu // (pierwsza z matrycy wariantów; tkanina fotografowana = baza masterów). const fabricPrompt = ( db .prepare( `SELECT f.ai_prompt FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? AND f.ai_prompt IS NOT NULL AND f.ai_prompt <> '' ORDER BY v.created_at LIMIT 1` ) .get(productId) as unknown as { ai_prompt: string } | undefined )?.ai_prompt ?? ""; // Wybrana nóżka — fakt legs.furniture_leg_id wskazuje wiersz // furniture_legs; jego ai_prompt trafia do promptu jako podmiana nóg. const legId = factVal("legs.furniture_leg_id"); const legsNote = legId ? (( db .prepare("SELECT ai_prompt FROM furniture_legs WHERE id = ?") .get(String(legId)) as unknown as { ai_prompt: string | null } | undefined )?.ai_prompt ?? "") : ""; const cushionFact = confirmedFactVal("cushions.loose_back_count"); return { productName: product.name, sleepingSurface: `${factVal("sleeping.width_cm") ?? "?"} x ${factVal("sleeping.length_cm") ?? "?"} cm`, cushionCount: cushionFact == null ? null : Number(cushionFact), silhouetteNote: String(factVal("geometry.silhouette_note") ?? ""), legsNote, fabricPrompt, fabricSwatchRefId, fabricRealRefId, legRefId, refWarnings, }; } /** * Sloty referencji ujęcia: geometria z primary (fallback: pierwszy jawny * sourceId), tkanina i nóżki z kontekstu produktu. */ export function shotRefSlots(gen: ShotGenContext, geometryRefId: string | null): ShotRefSlots { return { geometryRefId, fabricSwatchRefId: gen.fabricSwatchRefId, fabricRealRefId: gen.fabricRealRefId, legRefId: gen.legRefId, }; } /** Kolejność wysyłki: sloty 1–4, potem referencje pomocnicze (bez duplikatów). */ export function orderedShotRefIds(slots: ShotRefSlots, supportingIds: string[]): string[] { return [...new Set([...slotRefOrder(slots), ...supportingIds])]; } /** * Prompt receptury dla konkretnego zestawu wysłanych referencji — * `sentIds` to realna kolejność `images` w payloadzie providera (numery * „Image N" muszą odpowiadać faktycznie wysłanym obrazom). Provider nie * ma kanału negatywnego, więc zakazy trafiają na koniec jako „Strictly no". */ export function shotRecipePrompt(role: ShotRole, gen: ShotGenContext, sentIds: string[], geometryRefId: string | null): string { const r = buildShotRecipe(role, { productName: gen.productName, cushionCount: gen.cushionCount, silhouetteNote: gen.silhouetteNote, legsNote: gen.legsNote, fabricPrompt: gen.fabricPrompt, slots: shotRefSlots(gen, geometryRefId), sentIds, }); return `${r.prompt} Strictly no: ${r.negativePrompt}.`; } /** * Referencja → data URI dla providera. Obsługuje src_* (materiały źródłowe, * originalsDir) i ast_* (assety wynikowe — mediaDir). RunComfy Model API ma * twardy limit 10 MiB na body requestu — surowe pliki (do 25 MB) w base64 * dawały HTTP 413, więc obraz jest spłaszczany, skalowany i rekompresowany * (EXIF/metadane usuwane przy kodowaniu JPEG). Oryginał zostaje nietknięty. */ const REF_TIERS = [ { maxEdge: 1568, quality: 82 }, { maxEdge: 1024, quality: 70 }, { maxEdge: 768, quality: 60 }, ] as const; /** Budżet na sumę data URI obrazów w jednym submicie (limit API = 10 MiB body). */ const PROVIDER_IMAGES_BUDGET = 8 * 1024 * 1024; export async function refToDataUri( db: Db, cfg: AppConfig, refId: string, tier = 0 ): Promise { const src = db .prepare("SELECT path, mime FROM source_assets WHERE id = ?") .get(refId) as unknown as { path: string; mime: string } | undefined; const ast = src ? undefined : (db .prepare("SELECT path, mime FROM assets WHERE id = ?") .get(refId) as unknown as { path: string; mime: string } | undefined); const row = src ?? ast; if (!row) return null; const base = src ? cfg.originalsDir : cfg.mediaDir; const abs = path.resolve(base, row.path); if (!abs.startsWith(path.resolve(base) + path.sep) || !fs.existsSync(abs)) return null; const t = REF_TIERS[Math.min(tier, REF_TIERS.length - 1)]!; try { const buf = await sharp(fs.readFileSync(abs)) .rotate() .resize({ width: t.maxEdge, height: t.maxEdge, fit: "inside", withoutEnlargement: true }) .flatten({ background: "#ffffff" }) .jpeg({ quality: t.quality }) .toBuffer(); return `data:image/jpeg;base64,${buf.toString("base64")}`; } catch { return null; } } /** * Rozstrzyga referencje do data URI mieszczących się w budżecie providera. * Priorytet = kolejność ids (primary pierwszy). Degradacja: najpierw niższy * tier jakości wszystkich refów, potem odrzucanie od końca — nigdy primary. */ export async function resolveProviderImages( db: Db, cfg: AppConfig, ids: string[], maxRefs: number, budgetBytes = PROVIDER_IMAGES_BUDGET ): Promise<{ pairs: Array; dropped: string[] }> { const kept = ids.slice(0, maxRefs); let pairs: Array = []; const total = () => pairs.reduce((n, p) => n + p[1].length, 0); for (let tier = 0; tier < REF_TIERS.length; tier++) { const resolved = await Promise.all( kept.map(async (id) => [id, await refToDataUri(db, cfg, id, tier)] as const) ); pairs = resolved.filter((p): p is readonly [string, string] => Boolean(p[1])); if (total() <= budgetBytes) break; } const dropped: string[] = []; while (pairs.length > 1 && total() > budgetBytes) dropped.unshift(pairs.pop()![0]); return { pairs, dropped }; } async function downloadOutputs( db: Db, cfg: AppConfig, job: { id: string; provider_request_id: string | null }, payload: GeneratePayload, urls: string[], providerName: string, transform?: (buf: Buffer) => Promise ): Promise { const assetIds: string[] = []; for (const url of urls) { const res = await fetch(url); if (!res.ok) throw new Error(`Pobranie wyniku: HTTP ${res.status}`); let buf: Buffer = Buffer.from(await res.arrayBuffer()); if (transform) buf = await transform(buf); const sha = crypto.createHash("sha256").update(buf).digest("hex"); const meta = await sharp(buf).metadata().catch(() => ({ width: null, height: null })); const ext = res.headers.get("content-type")?.includes("png") ? "png" : "jpg"; const rel = path.join("assets", sha.slice(0, 2), `${sha}.${ext}`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, buf, { flag: "wx" }); const asset = registerAsset(db, { productId: payload.productId, kind: "image", role: payload.role, jobId: job.id, sha256: sha, mime: ext === "png" ? "image/png" : "image/jpeg", bytes: buf.length, width: meta.width, height: meta.height, path: rel, origin: providerName === "test" ? "derived" : "ai_generated", provenance: { provider: providerName, requestId: job.id, providerRequestId: job.provider_request_id, sourceIds: payload.sourceIds, referenceIds: payload.resolvedRefIds ?? null, primarySourceId: payload.primarySourceId ?? null, resolvedFrom: payload.resolvedFrom ?? null, refKinds: payload.refKinds ?? null, originKind: originKindForSources(db, payload.sourceIds), prompt: payload.prompt ?? payload.resolvedPrompt, recipe: payload.role, ...(payload.blueprintMode ? { mode: payload.blueprintMode } : {}), // Audyt: nakładka tunera utrwalana przy kandydacie — jedyny ślad // korekty; konfiguracja mebla i receptura zostają bez zmian. ...(payload.ephemeral_overrides ? { shotTuner: payload.ephemeral_overrides } : {}), note: providerName === "test" ? "Wynik adaptera testowego — NIE jest generacją AI" : undefined, }, note: [ providerName === "test" ? "Wynik adaptera testowego — NIE jest generacją AI" : null, payload.ephemeral_overrides ? `Korekta asystenta: ${payload.ephemeral_overrides.explanation_pl || "nakładka na pojedyncze zadanie"}` : null, ].filter(Boolean).join(" ") || null, }); assetIds.push(asset.id); } return assetIds; } async function runGenerateShot( db: Db, cfg: AppConfig, jobId: string, provider: ImageProvider | null ): Promise { const job = getJob(db, jobId)!; const payload = JSON.parse(job.payload_json) as GeneratePayload; if (!provider) { finishJob(db, jobId, { ok: false, error: "Brak skonfigurowanego providera AI (RUNCOMFY_API_KEY). Zadanie nie zlecone.", }); return; } // Jeśli request już złożony (retry/restart) — tylko poll, NIE nowy submit. if (!job.provider_request_id) { // Multi-image conditioning — 4 sloty w stałej kolejności (puste pomijane): // 1 = kadr mebla (primary), 2 = próbka producenta (official_image_path), // 3 = tkanina na meblu (photo_path), 4 = nóżki (furniture_legs.image_asset_id) // Tekstowe ai_prompt/legsNote zostają uzupełnieniem, nie zastępstwem. const gen = shotGenContext(db, payload.productId); const refWarnings = [...gen.refWarnings]; const overrides = payload.ephemeral_overrides ?? null; // Wykluczenia referencji z nakładki tunera dotyczą wyłącznie tego // zadania — shot_refs w bazie zostają nietknięte. Primary jest kotwicą // geometrii i nigdy nie może zostać wyłączone. const disabledRefs = new Set(overrides?.disabled_ref_ids ?? []); if (payload.primarySourceId && disabledRefs.has(payload.primarySourceId)) { disabledRefs.delete(payload.primarySourceId); refWarnings.push("Asystent zaproponował wyłączenie referencji głównej — zignorowano: Image 1 jest kotwicą geometrii."); } // Primary jako pierwsza referencja — model traktuje ją jako wzorzec // geometrii; potem tkanina i nóżka, na końcu referencje pomocnicze. const geometryRefId = payload.primarySourceId ?? payload.sourceIds[0] ?? null; const orderedIds = orderedShotRefIds(shotRefSlots(gen, geometryRefId), payload.sourceIds).filter((id) => !disabledRefs.has(id)); const { pairs: resolvedPairs, dropped } = await resolveProviderImages( db, cfg, orderedIds, provider.capabilities.maxReferenceImages ); if (dropped.length) { refWarnings.push(`Limit payloadu providera: pominięto referencje ${dropped.join(", ")}.`); } const images = resolvedPairs.map((p) => p[1]); const sentIds = resolvedPairs.map((p) => p[0]); if (images.length === 0) { finishJob(db, jobId, { ok: false, error: "Brak użytecznych referencji dla ujęcia." }); return; } payload.resolvedRefIds = sentIds; if (refWarnings.length) payload.refWarnings = refWarnings; // Nakładka tunera dokleja się do promptu bazowego (receptury lub // jawnego promptu operatora) — tylko na potrzeby tego zadania. let resolved = payload.prompt ?? shotRecipePrompt(payload.role, gen, sentIds, geometryRefId); resolved = applyShotOverrides(resolved, overrides); payload.resolvedPrompt = resolved; db.prepare("UPDATE jobs SET payload_json = ? WHERE id = ?").run( JSON.stringify(payload), jobId ); const { requestId } = await provider.submitImageEdit({ prompt: payload.resolvedPrompt, images, ...(payload.quality ? { quality: payload.quality } : {}), }); payload.submittedAt = new Date().toISOString(); db.prepare("UPDATE jobs SET payload_json = ? WHERE id = ?").run(JSON.stringify(payload), jobId); setJobRequestId(db, jobId, requestId); deferJob(db, jobId, 5); return; } const poll = await provider.poll(job.provider_request_id); if (poll.status === "pending") { // Twardy limit oczekiwania: zlecenie opłacone raz — nie zlecamy ponownie, // tylko jawnie kończymy, żeby UI nie wisiało w nieskończonym „w toku”. const since = Date.parse(payload.submittedAt ?? job.created_at); if ((Date.now() - since) / 60_000 > PROVIDER_MAX_WAIT_MIN) { finishJob(db, jobId, { ok: false, error: `Provider nie zwrócił wyniku w ${PROVIDER_MAX_WAIT_MIN} min (request ${job.provider_request_id}). Nie zlecono ponownie.` }); return; } payload.providerStatus = { status: poll.providerStatus ?? "pending", queuePosition: poll.queuePosition ?? null, polledAt: new Date().toISOString() }; db.prepare("UPDATE jobs SET payload_json = ? WHERE id = ?").run(JSON.stringify(payload), jobId); deferJob(db, jobId, 8); return; } if (poll.status === "failed") { finishJob(db, jobId, { ok: false, error: `${poll.error ?? "provider failed"} (request ${job.provider_request_id})` }); return; } // Etap C: // - hybrid: czysta kreska AI + programowy pasek wymiarów z faktów // - ai: AI rysuje całość (linie + etykiety), my dokładamy ramę karty // i programową tabelkę (gwarantowane liczby, także bodyHeight). const transform = payload.blueprintMode === "hybrid" ? async (buf: Buffer): Promise => { const meta = await sharp(buf).metadata(); const w = meta.width ?? 1024; const h0 = meta.height ?? 1024; const strip = Buffer.from(dimensionStripSvg(w, confirmedDims(db, payload.productId))); return sharp(buf) .extend({ bottom: 132, background: "#ffffff" }) .composite([{ input: strip, top: h0, left: 0 }]) .png() .toBuffer(); } : payload.blueprintMode === "ai" ? async (buf: Buffer): Promise => { const dims = confirmedDims(db, payload.productId); const ctx = blueprintCtx(db, payload.productId); const box = AI_ILLUST_BOX; const trimmed = await sharp(buf).trim({ threshold: 14 }).toBuffer(); const fitted = await sharp(trimmed) .resize({ width: box.w, height: box.h, fit: "inside", background: "#ffffff" }) .png() .toBuffer(); const m = await sharp(fitted).metadata(); const left = Math.round(box.x + (box.w - (m.width ?? box.w)) / 2); const top = Math.round(box.y + (box.h - (m.height ?? box.h)) / 2); return sharp(Buffer.from(aiSheetSvg(dims, ctx))) .composite([{ input: fitted, left, top }]) .png() .toBuffer(); } : undefined; const assetIds = await downloadOutputs( db, cfg, job, payload, poll.outputUrls ?? [], provider.name, transform ); finishJob(db, jobId, { ok: true, result: { assetIds, ...(payload.refWarnings?.length ? { refWarnings: payload.refWarnings } : {}) }, }); } // ================= Etap C: blueprint composed (3 ilustracje AI + karta) ================= interface ComposedPayload extends Omit { blueprintMode: "composed"; viewRefs: Partial>; viewPrompts?: Partial>; viewRequestIds?: Partial>; /** deterministyczna korekta orientacji ilustracji (np. flop dla strony P) */ viewTransforms?: Partial>; } async function runBlueprintComposed( db: Db, cfg: AppConfig, jobId: string, provider: ImageProvider | null ): Promise { const job = getJob(db, jobId)!; const payload = JSON.parse(job.payload_json) as ComposedPayload; if (!provider) { finishJob(db, jobId, { ok: false, error: "Brak providera AI (RUNCOMFY_API_KEY)." }); return; } const dims = confirmedDims(db, payload.productId); const views = planBlueprintViews(dims); // Faza 1: zlecenie ilustracji kreskowych — tylko brakujących widoków // (przy refineView pozostałe requestId są przeniesione z poprzedniego zadania). payload.viewRequestIds ??= {}; const missing = views.filter((v) => !payload.viewRequestIds![v]); if (missing.length > 0) { const promptCtx = blueprintPromptCtx(db, payload.productId, dims); payload.viewPrompts ??= {}; for (const view of missing) { const refIds = payload.viewRefs[view] ?? resolveViewRefs(db, payload.productId, view); const { pairs } = await resolveProviderImages( db, cfg, refIds, provider.capabilities.maxReferenceImages ); payload.viewRefs[view] = pairs.map((p) => p[0]); const images = pairs.map((p) => p[1]); if (images.length === 0) { finishJob(db, jobId, { ok: false, error: `Brak referencji dla widoku ${view}.` }); return; } const prompt = payload.viewPrompts[view] ?? buildBlueprintViewPrompt(view, promptCtx); payload.viewPrompts[view] = prompt; const { requestId } = await provider.submitImageEdit({ prompt, images }); payload.viewRequestIds[view] = requestId; // Utrwalenie zaraz po submicie — błąd na kolejnym widoku (np. 413) // nie zleca ponownie już opłaconych kadrów przy retry. db.prepare("UPDATE jobs SET payload_json = ? WHERE id = ?").run( JSON.stringify(payload), jobId ); } db.prepare("UPDATE jobs SET payload_json = ? WHERE id = ?").run( JSON.stringify(payload), jobId ); setJobRequestId(db, jobId, `composed:${payload.viewRequestIds[views[0]!]}`); deferJob(db, jobId, 6); return; } // Faza 2: polling wszystkich trzech. const outputs: Partial> = {}; for (const view of views) { const reqId = payload.viewRequestIds[view]; if (!reqId) { finishJob(db, jobId, { ok: false, error: `Brak request id dla widoku ${view}.` }); return; } const poll = await provider.poll(reqId); if (poll.status === "pending") { deferJob(db, jobId, 8); return; } if (poll.status === "failed") { finishJob(db, jobId, { ok: false, error: `Widok ${view}: ${poll.error ?? "provider failed"}` }); return; } outputs[view] = poll.outputUrls ?? []; } // Faza 3: pobranie, przycięcie marginesów, wkomponowanie w kartę. const viewBufs = {} as Record; for (const view of views) { const url = outputs[view]?.[0]; if (!url) { finishJob(db, jobId, { ok: false, error: `Widok ${view}: brak obrazu w wyniku.` }); return; } const res = await fetch(url); if (!res.ok) throw new Error(`Pobranie widoku ${view}: HTTP ${res.status}`); let buf = Buffer.from(await res.arrayBuffer()); if (payload.viewTransforms?.[view] === "flop") buf = await sharp(buf).flop().toBuffer(); viewBufs[view] = buf; } const ctx = blueprintCtx(db, payload.productId); // ilustracje do regionów karty: trim białych marginesów + fit inside. // placed[view] = realny prostokąt treści — kotwica wszystkich wymiarów. const layout = composedLayout(views); const placed: PlacedRects = {}; const layers: Array<{ input: Buffer; left: number; top: number }> = []; for (const view of views) { const box = layout.boxes[view]!; const trimmed = await sharp(viewBufs[view]).trim({ threshold: 14 }).toBuffer(); const fitted = await sharp(trimmed) .resize({ width: box.w - 30, height: box.h - 30, fit: "inside", background: "#ffffff" }) .png() .toBuffer(); const m = await sharp(fitted).metadata(); const left = Math.round(box.x + (box.w - (m.width ?? box.w)) / 2); const top = Math.round(box.y + (box.h - (m.height ?? box.h)) / 2); layers.push({ input: fitted, left, top }); placed[view] = { x: left, y: top, w: m.width ?? box.w, h: m.height ?? box.h }; } const buf = await sharp(Buffer.from(composedSheetSvg(dims, ctx, views))) .composite([...layers, { input: Buffer.from(composedOverlaySvg(dims, placed)), left: 0, top: 0 }]) .png() .toBuffer(); const sha = crypto.createHash("sha256").update(buf).digest("hex"); const meta = await sharp(buf).metadata(); const rel = path.join("assets", sha.slice(0, 2), `${sha}.png`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, buf, { flag: "wx" }); const asset = registerAsset(db, { productId: payload.productId, kind: "image", role: "blueprint", jobId, sha256: sha, mime: "image/png", bytes: buf.length, width: meta.width, height: meta.height, path: rel, origin: "ai_generated", provenance: { provider: provider.name, mode: "composed", viewRequestIds: payload.viewRequestIds, viewRefs: payload.viewRefs, viewPrompts: payload.viewPrompts, viewTransforms: payload.viewTransforms ?? null, dimsSource: "product_facts.confirmed", dims, }, note: "Ilustrowany rysunek wymiarowy: 3 widoki AI + programowe wymiary z faktów.", }); finishJob(db, jobId, { ok: true, result: { assetIds: [asset.id] } }); }