rspace-online/modules/rsocials/lib/image-gen.ts

105 lines
3.2 KiB
TypeScript

/**
* Server-side image generation and file upload helpers.
*
* These functions require filesystem access and FAL_KEY,
* so they stay server-only (not bundled into web components).
*/
import { resolve } from "node:path";
import { mkdir, writeFile, unlink } from "node:fs/promises";
const GEN_DIR = resolve(process.env.FILES_DIR || "./data/files", "generated");
async function ensureGenDir(): Promise<string> {
await mkdir(GEN_DIR, { recursive: true });
return GEN_DIR;
}
// ── fal.ai image generation ──
export async function generateImageFromPrompt(prompt: string): Promise<string | null> {
const FAL_KEY = process.env.FAL_KEY || "";
if (!FAL_KEY) return null;
let falRes: Response;
try {
falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", {
method: "POST",
headers: {
Authorization: `Key ${FAL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
image_size: "landscape_4_3",
num_images: 1,
safety_tolerance: "2",
}),
});
} catch (e: any) {
console.error("[image-gen] fal.ai network error:", e.message);
return null;
}
if (!falRes.ok) {
const errText = await falRes.text().catch(() => "unknown");
console.error(`[image-gen] fal.ai error (${falRes.status}):`, errText);
return null;
}
const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } };
return falData.images?.[0]?.url || falData.output?.url || null;
}
export async function downloadAndSaveImage(cdnUrl: string, filename: string): Promise<string | null> {
const imgRes = await fetch(cdnUrl);
if (!imgRes.ok) return null;
const imgBuffer = await imgRes.arrayBuffer();
const dir = await ensureGenDir();
await writeFile(resolve(dir, filename), Buffer.from(imgBuffer));
return `/data/files/generated/${filename}`;
}
// ── File upload handling ──
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export function validateImageFile(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) {
return "Invalid file type. Allowed: png, jpg, webp, gif";
}
if (file.size > MAX_SIZE) {
return "File too large. Maximum 5MB";
}
return null;
}
export function safeExtension(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "png";
return ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png";
}
export async function saveUploadedFile(buffer: Buffer, filename: string): Promise<string> {
const dir = await ensureGenDir();
await writeFile(resolve(dir, filename), buffer);
return `/data/files/generated/${filename}`;
}
// ── Cleanup helpers ──
export async function deleteImageFile(imageUrl: string): Promise<void> {
const fname = imageUrl.split("/").pop();
if (!fname) return;
try { await unlink(resolve(GEN_DIR, fname)); } catch { /* ignore */ }
}
export async function deleteOldImage(oldUrl: string | undefined, newFilename: string): Promise<void> {
if (!oldUrl) return;
const oldFilename = oldUrl.split("/").pop();
if (oldFilename && oldFilename !== newFilename) {
try { await unlink(resolve(GEN_DIR, oldFilename)); } catch { /* ignore */ }
}
}