retrotoon-studio/server/servicesConfig.ts

207 lines
5.9 KiB
TypeScript

/**
* 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<ServicesConfig> {
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<string, unknown>
): Promise<unknown> {
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<string, unknown>
): Promise<unknown> {
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(" ");
}