src / registry.ts
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { FileHandle, type FilesNamespace, type PromptPreprocessorController } from "@lmstudio/sdk";
export interface RegistryVideo {
originalName: string;
frames: string[];
createdAt: string;
}
export interface VideoRegistry {
userFilesDir?: string;
identifierPrefix?: string;
videos: Record<string, RegistryVideo>;
}
export interface UserFilesTarget {
dir: string;
prefix: string;
}
const REGISTRY_FILE = "video-registry.json";
// 1x1 transparent PNG, used to probe where LM Studio stores uploaded files.
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
export async function loadRegistry(workDir: string): Promise<VideoRegistry> {
try {
const raw = await fs.readFile(path.join(workDir, REGISTRY_FILE), "utf8");
const parsed = JSON.parse(raw) as Partial<VideoRegistry>;
return { userFilesDir: parsed.userFilesDir, identifierPrefix: parsed.identifierPrefix, videos: parsed.videos ?? {} };
} catch {
return { videos: {} };
}
}
export async function saveRegistry(workDir: string, registry: VideoRegistry): Promise<void> {
await fs.mkdir(workDir, { recursive: true });
const tmp = path.join(workDir, `${REGISTRY_FILE}.tmp`);
await fs.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
await fs.rename(tmp, path.join(workDir, REGISTRY_FILE));
}
/**
* Uploads a tiny probe image through the SDK and asks where LM Studio stored it, which reveals
* the user-files directory. The probe itself is a temporary upload that LM Studio cleans up on its own.
*/
export async function discoverUserFilesDir(ctl: PromptPreprocessorController): Promise<UserFilesTarget | null> {
const workDir = ctl.getWorkingDirectory();
await fs.mkdir(workDir, { recursive: true });
const probePath = path.join(workDir, ".plugin-video-probe.png");
try {
await fs.writeFile(probePath, Buffer.from(TINY_PNG_BASE64, "base64"));
const handle = await ctl.client.files.prepareImage(probePath);
const filePath = await handle.getFilePath();
return { dir: path.dirname(filePath), prefix: handle.identifier.startsWith("local:") ? "local:" : "" };
} catch (err) {
ctl.debug("Could not discover LM Studio user-files directory:", err instanceof Error ? err.message : String(err));
return null;
} finally {
await fs.rm(probePath, { force: true }).catch(() => {});
}
}
export async function fallbackUserFilesDir(): Promise<string | null> {
const dir = path.join(os.homedir(), ".cache", "lm-studio", "user-files");
try {
await fs.access(dir);
return dir;
} catch {
return null;
}
}
export function frameFileName(videoKey: string, index: number): string {
return `plugin-video__${videoKey}__frame_${String(index).padStart(4, "0")}.jpg`;
}
export async function findFramesOnDisk(dir: string, videoKey: string): Promise<string[]> {
const all = await fs.readdir(dir).catch(() => [] as string[]);
return all.filter(f => f.startsWith(`plugin-video__${videoKey}__frame_`) && f.endsWith(".jpg")).sort();
}
/**
* Builds a FileHandle that points at a file we manage on disk ourselves (inside LM Studio's
* user-files directory). Direct construction is deprecated by the SDK, but it is exactly what is
* needed here: the reference stays valid across turns and restarts because the file persists.
*/
export function makePersistentHandle(
files: FilesNamespace,
prefix: string,
fileName: string,
sizeBytes: number,
): FileHandle {
return new FileHandle(files, `${prefix}${fileName}`, "image", sizeBytes, fileName);
}
src / registry.ts
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { FileHandle, type FilesNamespace, type PromptPreprocessorController } from "@lmstudio/sdk";
export interface RegistryVideo {
originalName: string;
frames: string[];
createdAt: string;
}
export interface VideoRegistry {
userFilesDir?: string;
identifierPrefix?: string;
videos: Record<string, RegistryVideo>;
}
export interface UserFilesTarget {
dir: string;
prefix: string;
}
const REGISTRY_FILE = "video-registry.json";
// 1x1 transparent PNG, used to probe where LM Studio stores uploaded files.
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
export async function loadRegistry(workDir: string): Promise<VideoRegistry> {
try {
const raw = await fs.readFile(path.join(workDir, REGISTRY_FILE), "utf8");
const parsed = JSON.parse(raw) as Partial<VideoRegistry>;
return { userFilesDir: parsed.userFilesDir, identifierPrefix: parsed.identifierPrefix, videos: parsed.videos ?? {} };
} catch {
return { videos: {} };
}
}
export async function saveRegistry(workDir: string, registry: VideoRegistry): Promise<void> {
await fs.mkdir(workDir, { recursive: true });
const tmp = path.join(workDir, `${REGISTRY_FILE}.tmp`);
await fs.writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
await fs.rename(tmp, path.join(workDir, REGISTRY_FILE));
}
/**
* Uploads a tiny probe image through the SDK and asks where LM Studio stored it, which reveals
* the user-files directory. The probe itself is a temporary upload that LM Studio cleans up on its own.
*/
export async function discoverUserFilesDir(ctl: PromptPreprocessorController): Promise<UserFilesTarget | null> {
const workDir = ctl.getWorkingDirectory();
await fs.mkdir(workDir, { recursive: true });
const probePath = path.join(workDir, ".plugin-video-probe.png");
try {
await fs.writeFile(probePath, Buffer.from(TINY_PNG_BASE64, "base64"));
const handle = await ctl.client.files.prepareImage(probePath);
const filePath = await handle.getFilePath();
return { dir: path.dirname(filePath), prefix: handle.identifier.startsWith("local:") ? "local:" : "" };
} catch (err) {
ctl.debug("Could not discover LM Studio user-files directory:", err instanceof Error ? err.message : String(err));
return null;
} finally {
await fs.rm(probePath, { force: true }).catch(() => {});
}
}
export async function fallbackUserFilesDir(): Promise<string | null> {
const dir = path.join(os.homedir(), ".cache", "lm-studio", "user-files");
try {
await fs.access(dir);
return dir;
} catch {
return null;
}
}
export function frameFileName(videoKey: string, index: number): string {
return `plugin-video__${videoKey}__frame_${String(index).padStart(4, "0")}.jpg`;
}
export async function findFramesOnDisk(dir: string, videoKey: string): Promise<string[]> {
const all = await fs.readdir(dir).catch(() => [] as string[]);
return all.filter(f => f.startsWith(`plugin-video__${videoKey}__frame_`) && f.endsWith(".jpg")).sort();
}
/**
* Builds a FileHandle that points at a file we manage on disk ourselves (inside LM Studio's
* user-files directory). Direct construction is deprecated by the SDK, but it is exactly what is
* needed here: the reference stays valid across turns and restarts because the file persists.
*/
export function makePersistentHandle(
files: FilesNamespace,
prefix: string,
fileName: string,
sizeBytes: number,
): FileHandle {
return new FileHandle(files, `${prefix}${fileName}`, "image", sizeBytes, fileName);
}