/** * Services Configuration Module * Reads service configuration from the database (appConfig table) * and provides runtime mode switching for FFmpeg, SAM 2, ControlNet, and LoRA * * The admin panel persists configuration under key "services_config" as JSON. * This module reads that config and provides typed accessors for each service. */ import { getDb } from "./db"; export interface ServicesConfig { ffmpegMode: "simulated" | "external"; ffmpegEndpoint: string; sam2Mode: "simulated" | "external"; sam2Endpoint: string; } const DEFAULT_CONFIG: ServicesConfig = { ffmpegMode: "simulated", ffmpegEndpoint: "", sam2Mode: "simulated", sam2Endpoint: "", }; let cachedConfig: ServicesConfig | null = null; let cacheTimestamp = 0; const CACHE_TTL_MS = 30_000; // 30 seconds cache /** * Load services configuration from the database * Uses a 30-second cache to avoid excessive DB reads */ export async function getServicesConfig(): Promise { const now = Date.now(); if (cachedConfig && (now - cacheTimestamp) < CACHE_TTL_MS) { return cachedConfig; } try { const db = await getDb(); if (db) { const { appConfig } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const result = await db.select().from(appConfig).where(eq(appConfig.key, "services_config")).limit(1); if (result[0]?.value) { const parsed = JSON.parse(result[0].value); cachedConfig = { ffmpegMode: parsed.ffmpegMode || "simulated", ffmpegEndpoint: parsed.ffmpegEndpoint || "", sam2Mode: parsed.sam2Mode || "simulated", sam2Endpoint: parsed.sam2Endpoint || "", }; cacheTimestamp = now; return cachedConfig; } } } catch (error) { console.warn("[ServicesConfig] Failed to load config from DB:", error); } return DEFAULT_CONFIG; } /** * Invalidate the cached config (called after admin saves new config) */ export function invalidateServicesConfigCache(): void { cachedConfig = null; cacheTimestamp = 0; } /** * Call an external FFmpeg service for frame extraction * Only used when ffmpegMode === "external" */ export async function callExternalFFmpeg( endpoint: string, action: "extract-frames" | "extract-audio" | "encode-video", params: Record ): Promise { const url = `${endpoint}/${action}`; console.log(`[FFmpeg External] Calling ${url}`); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); if (!response.ok) { throw new Error(`FFmpeg service error: ${response.status} ${response.statusText}`); } return response.json(); } /** * Call an external SAM 2 service for segmentation * Only used when sam2Mode === "external" */ export async function callExternalSAM2( endpoint: string, action: "segment" | "propagate", params: Record ): Promise { const url = `${endpoint}/${action}`; console.log(`[SAM2 External] Calling ${url}`); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); if (!response.ok) { throw new Error(`SAM 2 service error: ${response.status} ${response.statusText}`); } return response.json(); } /** * Determine the generation strategy for a character based on their modelType * This selects the appropriate generation approach: * - "none": Standard generateImage with prompt engineering * - "lora": Pass LoRA model identifier in the prompt/params for consistent style * - "ip_adapter": Use the reference sheet as IP-Adapter input for style transfer */ export function getCharacterGenerationStrategy( modelType: "lora" | "ip_adapter" | "none", characterName: string, referenceSheetUrl?: string | null ): { promptPrefix: string; referenceImages: Array<{ url: string; mimeType: "image/png" | "image/jpeg" }>; strategy: string; } { switch (modelType) { case "lora": return { promptPrefix: `[LoRA:${characterName}] `, referenceImages: referenceSheetUrl ? [{ url: referenceSheetUrl, mimeType: "image/png" }] : [], strategy: "lora", }; case "ip_adapter": return { promptPrefix: `Character "${characterName}" in exact same style as the reference image. `, referenceImages: referenceSheetUrl ? [{ url: referenceSheetUrl, mimeType: "image/png" }] : [], strategy: "ip_adapter", }; default: return { promptPrefix: `Character "${characterName}". `, referenceImages: referenceSheetUrl ? [{ url: referenceSheetUrl, mimeType: "image/png" }] : [], strategy: "standard", }; } } /** * Build ControlNet-aware prompt constraints for pose preservation * Adds explicit instructions to maintain pose, proportions, and position */ export function buildPoseConstraints( basePrompt: string, options: { preservePose?: boolean; preserveProportions?: boolean; preservePosition?: boolean; poseDescription?: string; } = {} ): string { const { preservePose = true, preserveProportions = true, preservePosition = true, poseDescription, } = options; const constraints: string[] = [basePrompt]; if (preservePose) { constraints.push("Maintain the exact same body pose and gesture as the reference."); } if (preserveProportions) { constraints.push("Keep identical proportions, scale, and aspect ratio."); } if (preservePosition) { constraints.push("Position the subject at the exact same coordinates in the frame."); } if (poseDescription) { constraints.push(`Pose reference: ${poseDescription}`); } constraints.push( "This is for animation frame consistency - any deviation in pose or position will break continuity." ); return constraints.join(" "); }