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 { nowIso } from "../db.js"; import { newId, ID_PREFIX } from "./ids.js"; import { getAsset, type AssetRow } from "./assets.js"; import { buildMediaReview, OFFER_ROLES, type MediaReview } from "./mediaReview.js"; export const BASE_RENDITION_PROFILE = "base_v1"; const BASE_MAX_EDGE = 1500; const BASE_JPEG_QUALITY = 80; export class RenditionConflict extends Error {} export interface RenditionRow { id: string; product_id: string; source_asset_id: string; source_sha256: string; variant_id: string | null; role: string; profile: string; sha256: string; mime: string; bytes: number; width: number | null; height: number | null; path: string; created_at: string; } interface RenditionInput { productId: string; asset: AssetRow; } function safeJoin(base: string, rel: string): string | null { const abs = path.resolve(base, rel); if (!abs.startsWith(path.resolve(base) + path.sep)) return null; return abs; } function hashFile(abs: string): { sha256: string; bytes: number } | null { try { const buffer = fs.readFileSync(abs); return { sha256: crypto.createHash("sha256").update(buffer).digest("hex"), bytes: buffer.length }; } catch { return null; } } function publicRendition(row: RenditionRow, valid: boolean) { return { id: row.id, sourceAssetId: row.source_asset_id, sourceSha256: row.source_sha256, variantId: row.variant_id, role: row.role, profile: row.profile, sha256: row.sha256, mime: row.mime, bytes: row.bytes, width: row.width, height: row.height, url: `/api/renditions/${row.id}`, valid, }; } function storedFile(cfg: AppConfig, row: RenditionRow): string | null { return safeJoin(cfg.derivedDir, row.path); } function validStoredFile(cfg: AppConfig, row: RenditionRow): boolean { const abs = storedFile(cfg, row); if (!abs) return false; const file = hashFile(abs); return file?.sha256 === row.sha256 && file.bytes === row.bytes; } /** * Deterministyczny, lokalny rendition pod Base/marketplace: EXIF rotate, * maks. 1500 px, spłaszczenie alfy i JPEG q80. Nie jest to asset galerii — * nie dostaje osobnej akceptacji i nie zmienia zatwierdzonego źródła. */ export async function ensureBaseRendition(db: Db, cfg: AppConfig, input: RenditionInput): Promise { const existing = db .prepare("SELECT * FROM channel_renditions WHERE product_id = ? AND source_asset_id = ? AND profile = ?") .get(input.productId, input.asset.id, BASE_RENDITION_PROFILE) as unknown as RenditionRow | undefined; if (existing) { if (existing.source_sha256 !== input.asset.sha256) { throw new RenditionConflict("Rendition wskazuje inną treść źródłową niż zatwierdzony asset."); } if (validStoredFile(cfg, existing)) return existing; } const sourceAbs = safeJoin(cfg.mediaDir, input.asset.path); const source = sourceAbs ? hashFile(sourceAbs) : null; if (!sourceAbs || !source || source.sha256 !== input.asset.sha256 || source.bytes !== input.asset.bytes) { throw new RenditionConflict("Źródło renditionu nie istnieje albo nie odpowiada SHA-256 assetu."); } const out = await sharp(fs.readFileSync(sourceAbs)) .rotate() .resize({ width: BASE_MAX_EDGE, height: BASE_MAX_EDGE, fit: "inside", withoutEnlargement: true }) .flatten({ background: "#ffffff" }) .jpeg({ quality: BASE_JPEG_QUALITY }) .toBuffer(); const sha256 = crypto.createHash("sha256").update(out).digest("hex"); const meta = await sharp(out).metadata(); const rel = path.join("renditions", BASE_RENDITION_PROFILE, `${sha256}.jpg`); const abs = safeJoin(cfg.derivedDir, rel); if (!abs) throw new RenditionConflict("Nieprawidłowa ścieżka renditionu."); fs.mkdirSync(path.dirname(abs), { recursive: true }); if (!fs.existsSync(abs)) fs.writeFileSync(abs, out, { flag: "wx" }); const now = nowIso(); const id = existing?.id ?? newId(ID_PREFIX.rendition); db.prepare( `INSERT INTO channel_renditions (id, product_id, source_asset_id, source_sha256, variant_id, role, profile, sha256, mime, bytes, width, height, path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(product_id, source_asset_id, profile) DO UPDATE SET source_sha256 = excluded.source_sha256, variant_id = excluded.variant_id, role = excluded.role, sha256 = excluded.sha256, mime = excluded.mime, bytes = excluded.bytes, width = excluded.width, height = excluded.height, path = excluded.path` ).run( id, input.productId, input.asset.id, input.asset.sha256, input.asset.variant_id, input.asset.role, BASE_RENDITION_PROFILE, sha256, "image/jpeg", out.length, meta.width ?? null, meta.height ?? null, rel, existing?.created_at ?? now ); return db.prepare("SELECT * FROM channel_renditions WHERE id = ?").get(id) as unknown as RenditionRow; } export function getRendition(db: Db, id: string): RenditionRow | null { const row = db.prepare("SELECT * FROM channel_renditions WHERE id = ?").get(id) as unknown as RenditionRow | undefined; return row ?? null; } export function renditionFile(cfg: AppConfig, row: RenditionRow): { abs: string; mime: string } | null { const abs = storedFile(cfg, row); return abs && validStoredFile(cfg, row) ? { abs, mime: row.mime } : null; } export function buildRenditionManifest(db: Db, cfg: AppConfig, review: MediaReview, profile = BASE_RENDITION_PROFILE) { const items = review.slots.map((slot) => { const approved = slot.asset?.approved && slot.asset.blockers.length === 0 ? getAsset(db, slot.asset.id) : null; const row = approved ? (db .prepare("SELECT * FROM channel_renditions WHERE product_id = ? AND source_asset_id = ? AND profile = ?") .get(review.productId, approved.id, profile) as unknown as RenditionRow | undefined) : undefined; const status = !approved ? "source_missing" : !row ? "missing" : row.source_sha256 !== approved.sha256 ? "stale" : validStoredFile(cfg, row) ? "ready" : "invalid"; return { variantId: slot.variantId, side: slot.side, shade: slot.shade, role: slot.role, sourceAssetId: approved?.id ?? null, sourceSha256: approved?.sha256 ?? null, status, rendition: row ? publicRendition(row, status === "ready") : null, }; }); return { profile, expected: items.length, ready: items.filter((i) => i.status === "ready").length, sourceReady: items.filter((i) => i.status !== "source_missing").length, missing: items.filter((i) => i.status !== "ready").length, items, }; } /** Uzupełnia brakujące/uszkodzone renditions dla aktualnie gotowych slotów. */ export async function buildMissingRenditions( db: Db, cfg: AppConfig, productId: string, onProgress?: (result: Record) => void, profile = BASE_RENDITION_PROFILE ) { if (profile !== BASE_RENDITION_PROFILE) throw new RenditionConflict(`Nieobsługiwany profil renditionu: ${profile}.`); const review = buildMediaReview(db, cfg, productId); const result: Record = { total: review.slots.length, ready: 0, reused: 0, created: 0, skipped: 0, errors: {} as Record }; for (const slot of review.slots) { const key = `${slot.variantId}:${slot.role}`; if (!slot.asset?.approved || slot.asset.blockers.length > 0) { result.skipped = Number(result.skipped) + 1; (result.errors as Record)[key] = slot.asset ? "Asset nie jest gotowy do paczki." : "Brak zatwierdzonego assetu."; } else { const asset = getAsset(db, slot.asset.id); if (!asset) { result.skipped = Number(result.skipped) + 1; (result.errors as Record)[key] = "Asset zniknął w trakcie pracy."; } else { const before = db .prepare("SELECT id FROM channel_renditions WHERE product_id = ? AND source_asset_id = ? AND profile = ?") .get(productId, asset.id, profile) as { id: string } | undefined; try { const row = await ensureBaseRendition(db, cfg, { productId, asset }); if (before?.id === row.id) result.reused = Number(result.reused) + 1; else result.created = Number(result.created) + 1; result.ready = Number(result.ready) + 1; } catch (err) { result.skipped = Number(result.skipped) + 1; (result.errors as Record)[key] = err instanceof Error ? err.message : String(err); } } } onProgress?.(result); } return result; } export { OFFER_ROLES };