import fs from 'fs'; import os from 'os'; import path from 'path'; /** * HuggingFace Spacesの/tmpディレクトリを活用した画像キャッシュ * メモリ制限を回避し、より大容量の画像を扱える */ export class TempStorageCache { private static instance: TempStorageCache; private cacheDir: string; private metaData = new Map(); private maxAge = 24 * 60 * 60 * 1000; // 24時間 private constructor() { // /tmp内に専用ディレクトリを作成 this.cacheDir = path.join(os.tmpdir(), 'image-cache'); this.ensureCacheDir(); // 定期的にクリーンアップ setInterval(() => this.cleanup(), 6 * 60 * 60 * 1000); // 6時間ごと } static getInstance(): TempStorageCache { if (!TempStorageCache.instance) { TempStorageCache.instance = new TempStorageCache(); } return TempStorageCache.instance; } /** * キャッシュディレクトリを確保 */ private ensureCacheDir(): void { try { if (!fs.existsSync(this.cacheDir)) { fs.mkdirSync(this.cacheDir, { recursive: true }); } } catch (error) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.error(`[${timestamp}] [TempStorageCache] Failed to create cache directory:`, error); // フォールバック:メモリキャッシュモードに切り替え } } /** * 画像をストレージに保存 */ store(id: string, buffer: Buffer, contentType: string = 'image/webp'): string | null { try { const fileName = `${id}.webp`; const filePath = path.join(this.cacheDir, fileName); // ファイルに書き込み fs.writeFileSync(filePath, buffer); // メタデータを保存 this.metaData.set(id, { path: filePath, contentType, createdAt: Date.now(), }); // APIエンドポイント経由のURL返却(シンプルなパス) return `/api/temp-images/${id}`; } catch (error) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.error(`[${timestamp}] [TempStorageCache] Failed to store ${id}:`, error); return null; } } /** * 画像を取得 */ get(id: string): { buffer: Buffer; contentType: string } | null { const meta = this.metaData.get(id); if (!meta) return null; // 有効期限チェック if (Date.now() - meta.createdAt > this.maxAge) { this.remove(id); return null; } try { // ファイルから読み込み if (fs.existsSync(meta.path)) { const buffer = fs.readFileSync(meta.path); return { buffer, contentType: meta.contentType, }; } } catch (error) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.error(`[${timestamp}] [TempStorageCache] Failed to read ${id}:`, error); } // ファイルが見つからない場合 this.metaData.delete(id); return null; } /** * 画像を削除 */ private remove(id: string): void { const meta = this.metaData.get(id); if (meta) { try { if (fs.existsSync(meta.path)) { fs.unlinkSync(meta.path); } } catch (error) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.error(`[${timestamp}] [TempStorageCache] Failed to delete ${id}:`, error); } this.metaData.delete(id); } } /** * 期限切れファイルをクリーンアップ */ private cleanup(): void { const now = Date.now(); const expired: string[] = []; for (const [id, meta] of this.metaData.entries()) { if (now - meta.createdAt > this.maxAge) { expired.push(id); } } if (expired.length > 0) { expired.forEach((id) => this.remove(id)); } // ディスク容量チェック(オプション) this.checkDiskUsage(); } /** * ディスク使用量をチェック */ private checkDiskUsage(): void { try { const files = fs.readdirSync(this.cacheDir); let totalSize = 0; for (const file of files) { const filePath = path.join(this.cacheDir, file); const stat = fs.statSync(filePath); totalSize += stat.size; } // 1GB超えたら古いファイルを削除 if (totalSize > 1024 * 1024 * 1024) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.warn(`[${timestamp}] [TempStorageCache] Disk usage exceeded 1GB, forcing cleanup`); this.forceCleanup(); } } catch (error) { const timestamp = new Date().toLocaleString('ja-JP', { hour12: false }); console.error(`[${timestamp}] [TempStorageCache] Failed to check disk usage:`, error); } } /** * 強制的に古いファイルを削除 */ private forceCleanup(): void { // 作成時刻でソートして古い順に削除 const sorted = Array.from(this.metaData.entries()).sort(([, a], [, b]) => a.createdAt - b.createdAt); // 半分を削除 const toDelete = sorted.slice(0, Math.floor(sorted.length / 2)); toDelete.forEach(([id]) => this.remove(id)); } /** * キャッシュ状態を取得 */ getStats() { let totalSize = 0; let fileCount = 0; try { const files = fs.readdirSync(this.cacheDir); fileCount = files.length; for (const file of files) { const filePath = path.join(this.cacheDir, file); const stat = fs.statSync(filePath); totalSize += stat.size; } } catch (error) { // エラー時は0を返す } return { entries: this.metaData.size, files: fileCount, sizeBytes: totalSize, sizeMB: (totalSize / 1024 / 1024).toFixed(2), cacheDir: this.cacheDir, }; } }