Files
folx-tv/server/focal-point.ts
T

178 lines
6.8 KiB
TypeScript

import OpenAI from "openai";
import fs from "fs";
import path from "path";
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
// Zaris iz zaznave obrazov (OpenCV) — /opt/folxtv-uploads/focal_points.json,
// ki ga polni /opt/folx-fix/focal_calc.py na openclaw1. LLM je zaris ugibal in
// je pri portretnih slikah rezal glave, zato ima ta datoteka prednost.
const DETECTED_PATH = path.join(process.cwd(), "client/public/uploads/focal_points.json");
let detected: Record<string, { x: number; y: number }> = {};
let detectedMtime = 0;
function loadDetected() {
try {
const st = fs.statSync(DETECTED_PATH);
if (st.mtimeMs === detectedMtime) return;
detected = JSON.parse(fs.readFileSync(DETECTED_PATH, "utf-8"));
detectedMtime = st.mtimeMs;
console.log("[focal-point] zaznanih zarisov:", Object.keys(detected).length);
} catch {}
}
const cache = new Map<string, { x: number; y: number }>();
// Trajni predpomnilnik LLM zarisov. Lezi na /opt/folxtv-uploads (bind mount),
// zato prezivi deploy in restart — brez tega je vsak deploy pomenil ponovno
// analizo vseh naslovnih slik na gpt-4o-mini.
const CACHE_PATH = path.join(process.cwd(), "client/public/uploads/focal_cache.json");
let cacheLoaded = false;
let cacheDirty = false;
function loadCache() {
if (cacheLoaded) return;
cacheLoaded = true;
try {
const raw = JSON.parse(fs.readFileSync(CACHE_PATH, "utf-8")) as Record<string, { x: number; y: number }>;
for (const [k, v] of Object.entries(raw)) {
if (v && typeof v.x === "number" && typeof v.y === "number") cache.set(k, v);
}
console.log("[focal-point] iz predpomnilnika nalozenih:", cache.size);
} catch {
console.log("[focal-point] predpomnilnika se ni, zacenjam prazen");
}
}
function saveCache() {
if (!cacheDirty) return;
try {
const obj: Record<string, { x: number; y: number }> = {};
for (const [k, v] of cache.entries()) obj[k] = v;
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
fs.writeFileSync(CACHE_PATH + ".tmp", JSON.stringify(obj));
fs.renameSync(CACHE_PATH + ".tmp", CACHE_PATH);
cacheDirty = false;
console.log("[focal-point] predpomnilnik shranjen, vnosov:", cache.size);
} catch (e) {
console.log("[focal-point] predpomnilnika ni bilo mogoce shraniti:", (e as Error).message);
}
}
const manualOverrides: Record<string, { x: number; y: number }> = {
"/uploads/70-jahre-oberkrainer.webp": { x: 50, y: 55 },
};
const SYSTEM_PROMPT = `You are an image analysis tool that detects where faces and people are located in photographs.
Analyze the image and find the PRIMARY person or group of people. Report the CENTER of their face(s) as x,y percentages.
- x=0 means far left edge, x=100 means far right edge
- y=0 means very top edge, y=100 means very bottom edge
- Think carefully: divide the image into a 10x10 grid and locate faces precisely
- For a typical portrait where a person is standing, faces are usually at y=25-40
- For a group photo with people standing, the faces are usually at y=30-45
- For a close-up headshot, the face center is usually at y=40-55
- For people sitting at a table, faces are usually at y=35-50
- Do NOT assume faces are at the very top. Faces are rarely below y=20 unless it's a very tight crop.
- Be PRECISE. Actually look at where the eyes/faces are in the image.
- If there are multiple people, find the center point of ALL their faces.
Return ONLY a JSON object like {"x":42,"y":38} with no other text.`;
export async function analyzeFocalPoint(imagePath: string): Promise<{ x: number; y: number }> {
const originalPath = imagePath;
loadCache();
if (manualOverrides[originalPath]) {
cache.set(originalPath, manualOverrides[originalPath]);
return manualOverrides[originalPath];
}
loadDetected();
if (detected[originalPath]) {
cache.set(originalPath, detected[originalPath]);
return detected[originalPath];
}
if (cache.has(originalPath)) {
return cache.get(originalPath)!;
}
try {
let imageContent: { type: "image_url"; image_url: { url: string; detail: "auto" | "low" | "high" } };
if (imagePath.startsWith("/uploads/")) {
const localPath = path.join(process.cwd(), "client/public", imagePath);
if (!fs.existsSync(localPath)) {
throw new Error(`File not found: ${localPath}`);
}
const buffer = fs.readFileSync(localPath);
const imageData = buffer.toString("base64");
let mimeType = "image/webp";
if (localPath.endsWith(".jpg") || localPath.endsWith(".jpeg")) mimeType = "image/jpeg";
else if (localPath.endsWith(".png")) mimeType = "image/png";
imageContent = { type: "image_url", image_url: { url: `data:${mimeType};base64,${imageData}`, detail: "auto" } };
} else if (imagePath.startsWith("http")) {
imageContent = { type: "image_url", image_url: { url: imagePath, detail: "auto" } };
} else {
throw new Error(`Unsupported path: ${imagePath}`);
}
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: [
{ type: "text", text: "Where exactly are the faces/people in this image? Be precise with coordinates. Return only JSON." },
imageContent
]
}
],
max_completion_tokens: 60,
});
const text = response.choices[0]?.message?.content?.trim() || "";
console.log(`[focal-point] ${imagePath}: AI response = ${text}`);
const match = text.match(/\{[^}]+\}/);
if (match) {
const parsed = JSON.parse(match[0]);
const point = {
x: Math.max(0, Math.min(100, Number(parsed.x) || 50)),
y: Math.max(0, Math.min(100, Number(parsed.y) || 30)),
};
cache.set(originalPath, point);
cacheDirty = true;
return point;
}
} catch (e) {
console.log("[focal-point] AI analysis failed for", imagePath, ":", (e as Error).message);
}
const fallback = { x: 50, y: 30 };
cache.set(originalPath, fallback);
return fallback;
}
export async function analyzeAllArticleImages(articles: Array<{ coverImage: string | null }>) {
for (const article of articles) {
if (!article.coverImage) continue;
await analyzeFocalPoint(article.coverImage);
}
saveCache();
console.log("[focal-point] Analyzed", cache.size, "images");
}
export function getCachedFocalPoints(): Record<string, { x: number; y: number }> {
loadCache();
loadDetected();
const result: Record<string, { x: number; y: number }> = {};
for (const [key, value] of cache.entries()) {
result[key] = value;
}
// zaznani zaris ima prednost pred LLM ugibanjem
for (const [key, value] of Object.entries(detected)) {
result[key] = value;
}
return result;
}