98 lines
2.9 KiB
TypeScript
98 lines
2.9 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;
|
|
|
|
const 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",
|
|
}),
|
|
});
|
|
|
|
if (!falRes.ok) {
|
|
console.error("[image-gen] fal.ai error:", await falRes.text());
|
|
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 */ }
|
|
}
|
|
}
|