feat: implement automatic format inference service with candidate generation and prediction model
- Add formatCandidateRules.ts to define rules for generating format candidates based on file features. - Introduce formatPredictionModel.ts to predict the most likely target format based on user behavior and file features. - Create inferenceService.ts to integrate all inference modules and provide a unified inference API. - Develop inference.tsx as an API endpoint for frontend calls to predict formats and engines based on file extensions. - Implement logging for conversion events and dismissals, along with user profile retrieval. - Ensure warmup management for engine predictions and provide status checks.
This commit is contained in:
parent
1173d505f7
commit
6d948b0873
18 changed files with 3710 additions and 14 deletions
|
|
@ -80,6 +80,7 @@ export const BaseHtml = ({
|
|||
<link rel="manifest" href={`${webroot}/site.webmanifest`} />
|
||||
<script>{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}</script>
|
||||
<script src={`${webroot}/i18n.js`} defer />
|
||||
<script src={`${webroot}/inference.js`} defer />
|
||||
<script src={`${webroot}/theme.js`} />
|
||||
</head>
|
||||
<body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { upload } from "./pages/upload";
|
|||
import { uploadChunk, uploadInfo } from "./pages/uploadChunk";
|
||||
import { user } from "./pages/user";
|
||||
import { healthcheck } from "./pages/healthcheck";
|
||||
import { inferenceApi } from "./pages/inference";
|
||||
import { inferenceService } from "./inference";
|
||||
|
||||
export const uploadsDir = "./data/uploads/";
|
||||
export const outputDir = "./data/output/";
|
||||
|
|
@ -55,6 +57,7 @@ const app = new Elysia({
|
|||
.use(listConverters)
|
||||
.use(chooseConverter)
|
||||
.use(healthcheck)
|
||||
.use(inferenceApi)
|
||||
.onError(({ error }) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
|
@ -74,6 +77,11 @@ app.listen(3000);
|
|||
|
||||
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
|
||||
|
||||
// 初始化推斷服務
|
||||
inferenceService.initialize().catch((error) => {
|
||||
console.error("Failed to initialize inference service:", error);
|
||||
});
|
||||
|
||||
const clearJobs = () => {
|
||||
const jobs = db
|
||||
.query("SELECT * FROM jobs WHERE date_created < ?")
|
||||
|
|
|
|||
484
src/inference/behaviorStore.ts
Normal file
484
src/inference/behaviorStore.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
/**
|
||||
* 使用者行為資料儲存模組
|
||||
*
|
||||
* 記錄使用者轉檔行為,用於訓練預測模型
|
||||
*/
|
||||
|
||||
import db from "../db/db";
|
||||
|
||||
// ==================== 資料型別定義 ====================
|
||||
|
||||
/**
|
||||
* 轉檔完成事件
|
||||
*/
|
||||
export interface ConversionEvent {
|
||||
/** 事件類型 */
|
||||
event: "conversion_completed";
|
||||
/** 使用者 ID */
|
||||
user_id: number;
|
||||
/** 輸入副檔名 */
|
||||
input_ext: string;
|
||||
/** 使用者搜尋的格式 */
|
||||
searched_format: string;
|
||||
/** 選擇的轉換引擎 */
|
||||
selected_engine: string;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 轉檔耗時 (毫秒) */
|
||||
duration_ms: number;
|
||||
/** 檔案大小 (KB) */
|
||||
file_size_kb?: number;
|
||||
/** 圖片百萬像素 (如適用) */
|
||||
megapixels?: number;
|
||||
/** 時間戳 */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推薦拒絕事件
|
||||
*/
|
||||
export interface DismissEvent {
|
||||
/** 事件類型 */
|
||||
event: "recommendation_dismissed";
|
||||
/** 使用者 ID */
|
||||
user_id: number;
|
||||
/** 輸入副檔名 */
|
||||
input_ext: string;
|
||||
/** 被拒絕的推薦格式 */
|
||||
dismissed_format: string;
|
||||
/** 被拒絕的推薦引擎 */
|
||||
dismissed_engine?: string;
|
||||
/** 時間戳 */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用者 Profile
|
||||
*/
|
||||
export interface UserProfile {
|
||||
/** 使用者 ID */
|
||||
user_id: number;
|
||||
/** 格式偏好 (key: "input->output", value: 使用頻率 0-1) */
|
||||
format_preferences: Record<string, number>;
|
||||
/** 引擎偏好 (key: 輸出格式, value: {引擎: 使用頻率}) */
|
||||
engine_preferences: Record<string, Record<string, number>>;
|
||||
/** 最近使用的格式 (最新在前) */
|
||||
recent_formats: string[];
|
||||
/** 總轉檔次數 */
|
||||
total_conversions: number;
|
||||
/** 最後更新時間 */
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全域格式轉換統計
|
||||
*/
|
||||
export interface FormatConversionStats {
|
||||
/** 格式流行度 (key: 格式, value: 使用率 0-1) */
|
||||
format_popularity: Record<string, number>;
|
||||
/** 轉換路徑統計 (key: "input->output", value: 次數) */
|
||||
conversion_paths: Record<string, number>;
|
||||
/** 引擎成功率 (key: 引擎, value: 成功率 0-1) */
|
||||
engine_success_rates: Record<string, number>;
|
||||
/** 統計時間範圍 */
|
||||
stats_period: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 資料庫初始化 ====================
|
||||
|
||||
/**
|
||||
* 初始化行為資料表
|
||||
*/
|
||||
export function initBehaviorTables(): void {
|
||||
// 轉檔事件表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS conversion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
input_ext TEXT NOT NULL,
|
||||
searched_format TEXT NOT NULL,
|
||||
selected_engine TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
file_size_kb INTEGER,
|
||||
megapixels REAL,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// 推薦拒絕事件表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dismiss_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
input_ext TEXT NOT NULL,
|
||||
dismissed_format TEXT NOT NULL,
|
||||
dismissed_engine TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// 使用者 Profile 快取表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
format_preferences TEXT NOT NULL,
|
||||
engine_preferences TEXT NOT NULL,
|
||||
recent_formats TEXT NOT NULL,
|
||||
total_conversions INTEGER NOT NULL,
|
||||
last_updated TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// 建立索引以加速查詢
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_conversion_events_user
|
||||
ON conversion_events(user_id, timestamp DESC);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dismiss_events_user
|
||||
ON dismiss_events(user_id, timestamp DESC);
|
||||
`);
|
||||
|
||||
console.log("✅ Behavior tables initialized");
|
||||
}
|
||||
|
||||
// ==================== 事件記錄 ====================
|
||||
|
||||
/**
|
||||
* 記錄轉檔完成事件
|
||||
*/
|
||||
export function logConversionEvent(event: Omit<ConversionEvent, "event" | "timestamp">): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
db.query(
|
||||
`
|
||||
INSERT INTO conversion_events
|
||||
(user_id, input_ext, searched_format, selected_engine, success, duration_ms, file_size_kb, megapixels, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
event.user_id,
|
||||
event.input_ext,
|
||||
event.searched_format,
|
||||
event.selected_engine,
|
||||
event.success ? 1 : 0,
|
||||
event.duration_ms,
|
||||
event.file_size_kb ?? null,
|
||||
event.megapixels ?? null,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
// 異步更新使用者 Profile
|
||||
updateUserProfileAsync(event.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄推薦拒絕事件
|
||||
*/
|
||||
export function logDismissEvent(event: Omit<DismissEvent, "event" | "timestamp">): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
db.query(
|
||||
`
|
||||
INSERT INTO dismiss_events
|
||||
(user_id, input_ext, dismissed_format, dismissed_engine, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
event.user_id,
|
||||
event.input_ext,
|
||||
event.dismissed_format,
|
||||
event.dismissed_engine ?? null,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 使用者 Profile 管理 ====================
|
||||
|
||||
/**
|
||||
* 異步更新使用者 Profile (防止阻塞主流程)
|
||||
*/
|
||||
function updateUserProfileAsync(userId: number): void {
|
||||
// 使用 setImmediate 或 setTimeout 避免阻塞
|
||||
setTimeout(() => {
|
||||
try {
|
||||
rebuildUserProfile(userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to update user profile:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建使用者 Profile
|
||||
*/
|
||||
export function rebuildUserProfile(userId: number): UserProfile {
|
||||
// 取得最近 30 天的轉檔記錄
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
interface ConversionRow {
|
||||
input_ext: string;
|
||||
searched_format: string;
|
||||
selected_engine: string;
|
||||
success: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const events = db
|
||||
.query(
|
||||
`
|
||||
SELECT input_ext, searched_format, selected_engine, success, timestamp
|
||||
FROM conversion_events
|
||||
WHERE user_id = ? AND timestamp > ?
|
||||
ORDER BY timestamp DESC
|
||||
`,
|
||||
)
|
||||
.all(userId, thirtyDaysAgo) as ConversionRow[];
|
||||
|
||||
// 計算格式偏好
|
||||
const formatCounts: Record<string, number> = {};
|
||||
const engineCounts: Record<string, Record<string, number>> = {};
|
||||
const recentFormats: string[] = [];
|
||||
let totalConversions = 0;
|
||||
|
||||
for (const event of events) {
|
||||
// 格式偏好
|
||||
const pathKey = `${event.input_ext}->${event.searched_format}`;
|
||||
formatCounts[pathKey] = (formatCounts[pathKey] || 0) + 1;
|
||||
|
||||
// 引擎偏好
|
||||
if (!engineCounts[event.searched_format]) {
|
||||
engineCounts[event.searched_format] = {};
|
||||
}
|
||||
const formatEngines = engineCounts[event.searched_format];
|
||||
if (formatEngines) {
|
||||
formatEngines[event.selected_engine] = (formatEngines[event.selected_engine] || 0) + 1;
|
||||
}
|
||||
|
||||
// 最近格式 (去重)
|
||||
if (!recentFormats.includes(event.searched_format)) {
|
||||
recentFormats.push(event.searched_format);
|
||||
}
|
||||
|
||||
totalConversions++;
|
||||
}
|
||||
|
||||
// 正規化為 0-1 範圍
|
||||
const maxFormatCount = Math.max(...Object.values(formatCounts), 1);
|
||||
const formatPreferences: Record<string, number> = {};
|
||||
for (const [key, count] of Object.entries(formatCounts)) {
|
||||
formatPreferences[key] = count / maxFormatCount;
|
||||
}
|
||||
|
||||
const enginePreferences: Record<string, Record<string, number>> = {};
|
||||
for (const [format, engines] of Object.entries(engineCounts)) {
|
||||
const maxCount = Math.max(...Object.values(engines), 1);
|
||||
enginePreferences[format] = {};
|
||||
for (const [engine, count] of Object.entries(engines)) {
|
||||
enginePreferences[format][engine] = count / maxCount;
|
||||
}
|
||||
}
|
||||
|
||||
const profile: UserProfile = {
|
||||
user_id: userId,
|
||||
format_preferences: formatPreferences,
|
||||
engine_preferences: enginePreferences,
|
||||
recent_formats: recentFormats.slice(0, 10),
|
||||
total_conversions: totalConversions,
|
||||
last_updated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 儲存到資料庫
|
||||
db.query(
|
||||
`
|
||||
INSERT OR REPLACE INTO user_profiles
|
||||
(user_id, format_preferences, engine_preferences, recent_formats, total_conversions, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
profile.user_id,
|
||||
JSON.stringify(profile.format_preferences),
|
||||
JSON.stringify(profile.engine_preferences),
|
||||
JSON.stringify(profile.recent_formats),
|
||||
profile.total_conversions,
|
||||
profile.last_updated,
|
||||
);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得使用者 Profile
|
||||
*/
|
||||
export function getUserProfile(userId: number): UserProfile | null {
|
||||
interface ProfileRow {
|
||||
user_id: number;
|
||||
format_preferences: string;
|
||||
engine_preferences: string;
|
||||
recent_formats: string;
|
||||
total_conversions: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
const row = db
|
||||
.query(
|
||||
`
|
||||
SELECT * FROM user_profiles WHERE user_id = ?
|
||||
`,
|
||||
)
|
||||
.get(userId) as ProfileRow | null;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
format_preferences: JSON.parse(row.format_preferences),
|
||||
engine_preferences: JSON.parse(row.engine_preferences),
|
||||
recent_formats: JSON.parse(row.recent_formats),
|
||||
total_conversions: row.total_conversions,
|
||||
last_updated: row.last_updated,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 全域統計 ====================
|
||||
|
||||
/**
|
||||
* 計算全域格式轉換統計
|
||||
*/
|
||||
export function calculateGlobalStats(): FormatConversionStats {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
interface StatsRow {
|
||||
searched_format: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PathRow {
|
||||
input_ext: string;
|
||||
searched_format: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface EngineRow {
|
||||
selected_engine: string;
|
||||
total: number;
|
||||
success_count: number;
|
||||
}
|
||||
|
||||
// 格式流行度
|
||||
const formatStats = db
|
||||
.query(
|
||||
`
|
||||
SELECT searched_format, COUNT(*) as count
|
||||
FROM conversion_events
|
||||
WHERE timestamp > ?
|
||||
GROUP BY searched_format
|
||||
ORDER BY count DESC
|
||||
`,
|
||||
)
|
||||
.all(sevenDaysAgo) as StatsRow[];
|
||||
|
||||
const maxCount = formatStats[0]?.count ?? 1;
|
||||
const formatPopularity: Record<string, number> = {};
|
||||
for (const row of formatStats) {
|
||||
formatPopularity[row.searched_format] = row.count / maxCount;
|
||||
}
|
||||
|
||||
// 轉換路徑
|
||||
const pathStats = db
|
||||
.query(
|
||||
`
|
||||
SELECT input_ext, searched_format, COUNT(*) as count
|
||||
FROM conversion_events
|
||||
WHERE timestamp > ?
|
||||
GROUP BY input_ext, searched_format
|
||||
`,
|
||||
)
|
||||
.all(sevenDaysAgo) as PathRow[];
|
||||
|
||||
const conversionPaths: Record<string, number> = {};
|
||||
for (const row of pathStats) {
|
||||
conversionPaths[`${row.input_ext}->${row.searched_format}`] = row.count;
|
||||
}
|
||||
|
||||
// 引擎成功率
|
||||
const engineStats = db
|
||||
.query(
|
||||
`
|
||||
SELECT selected_engine, COUNT(*) as total, SUM(success) as success_count
|
||||
FROM conversion_events
|
||||
WHERE timestamp > ?
|
||||
GROUP BY selected_engine
|
||||
`,
|
||||
)
|
||||
.all(sevenDaysAgo) as EngineRow[];
|
||||
|
||||
const engineSuccessRates: Record<string, number> = {};
|
||||
for (const row of engineStats) {
|
||||
engineSuccessRates[row.selected_engine] = row.success_count / row.total;
|
||||
}
|
||||
|
||||
return {
|
||||
format_popularity: formatPopularity,
|
||||
conversion_paths: conversionPaths,
|
||||
engine_success_rates: engineSuccessRates,
|
||||
stats_period: {
|
||||
start: sevenDaysAgo,
|
||||
end: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 資料清理 ====================
|
||||
|
||||
/**
|
||||
* 清理過期的行為資料
|
||||
*/
|
||||
export function cleanupOldEvents(daysToKeep = 90): number {
|
||||
const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
interface DeleteResult {
|
||||
changes: number;
|
||||
}
|
||||
|
||||
const result1 = db
|
||||
.query(
|
||||
`
|
||||
DELETE FROM conversion_events WHERE timestamp < ?
|
||||
`,
|
||||
)
|
||||
.run(cutoff) as unknown as DeleteResult;
|
||||
|
||||
const result2 = db
|
||||
.query(
|
||||
`
|
||||
DELETE FROM dismiss_events WHERE timestamp < ?
|
||||
`,
|
||||
)
|
||||
.run(cutoff) as unknown as DeleteResult;
|
||||
|
||||
const deletedCount = (result1.changes ?? 0) + (result2.changes ?? 0);
|
||||
console.log(`🧹 Cleaned up ${deletedCount} old behavior events`);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
// 導出初始化函數
|
||||
export default {
|
||||
initBehaviorTables,
|
||||
logConversionEvent,
|
||||
logDismissEvent,
|
||||
getUserProfile,
|
||||
rebuildUserProfile,
|
||||
calculateGlobalStats,
|
||||
cleanupOldEvents,
|
||||
};
|
||||
647
src/inference/enginePredictionModel.ts
Normal file
647
src/inference/enginePredictionModel.ts
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
/**
|
||||
* 引擎預測模型
|
||||
*
|
||||
* 根據目標格式、檔案特徵和使用者偏好預測最適合的轉換引擎
|
||||
*/
|
||||
|
||||
import type { FileFeatures } from "./featureExtraction";
|
||||
import type { UserProfile } from "./behaviorStore";
|
||||
|
||||
/**
|
||||
* 引擎能力描述
|
||||
*/
|
||||
export interface EngineCapability {
|
||||
/** 引擎優勢描述 */
|
||||
strength: string;
|
||||
/** 冷啟動成本 (毫秒) */
|
||||
cold_start_cost: number;
|
||||
/** 是否禁止用於此格式 */
|
||||
disallowed?: boolean;
|
||||
/** 最佳適用場景 */
|
||||
best_for?: string[];
|
||||
/** 處理大檔案的能力 (0-1) */
|
||||
large_file_capability?: number;
|
||||
/** 品質優先程度 (0-1) */
|
||||
quality_priority?: number;
|
||||
/** 速度優先程度 (0-1) */
|
||||
speed_priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎能力矩陣
|
||||
* 定義每種格式可用的引擎及其特性
|
||||
*/
|
||||
export const ENGINE_CAPABILITY_MATRIX: Record<string, Record<string, EngineCapability>> = {
|
||||
// ==================== 圖片格式 ====================
|
||||
png: {
|
||||
vips: {
|
||||
strength: "large_image_fast",
|
||||
cold_start_cost: 300,
|
||||
best_for: ["large_images", "batch_processing"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "general_purpose",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["general", "filters", "effects"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
graphicsmagick: {
|
||||
strength: "memory_efficient",
|
||||
cold_start_cost: 400,
|
||||
best_for: ["memory_constrained", "simple_conversions"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.75,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
ffmpeg: {
|
||||
strength: "video_only",
|
||||
cold_start_cost: 800,
|
||||
disallowed: true,
|
||||
},
|
||||
},
|
||||
jpeg: {
|
||||
vips: {
|
||||
strength: "large_image_fast",
|
||||
cold_start_cost: 300,
|
||||
best_for: ["large_images", "photos"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "general_purpose",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["general", "quality_control"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
graphicsmagick: {
|
||||
strength: "memory_efficient",
|
||||
cold_start_cost: 400,
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.75,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
},
|
||||
webp: {
|
||||
vips: {
|
||||
strength: "fast_modern_formats",
|
||||
cold_start_cost: 300,
|
||||
best_for: ["web_optimization", "large_images"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "general_purpose",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["general"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
libheif: {
|
||||
strength: "heif_webp_specialist",
|
||||
cold_start_cost: 400,
|
||||
best_for: ["modern_formats"],
|
||||
large_file_capability: 0.85,
|
||||
speed_priority: 0.8,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
avif: {
|
||||
vips: {
|
||||
strength: "fast_avif",
|
||||
cold_start_cost: 300,
|
||||
best_for: ["modern_formats", "compression"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.85,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
libheif: {
|
||||
strength: "avif_specialist",
|
||||
cold_start_cost: 400,
|
||||
best_for: ["avif_native"],
|
||||
large_file_capability: 0.85,
|
||||
speed_priority: 0.8,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "fallback",
|
||||
cold_start_cost: 500,
|
||||
large_file_capability: 0.6,
|
||||
speed_priority: 0.5,
|
||||
quality_priority: 0.8,
|
||||
},
|
||||
},
|
||||
gif: {
|
||||
imagemagick: {
|
||||
strength: "animation_expert",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["animations", "optimization"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
vips: {
|
||||
strength: "fast_static",
|
||||
cold_start_cost: 300,
|
||||
best_for: ["static_gifs"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.8,
|
||||
},
|
||||
ffmpeg: {
|
||||
strength: "video_to_gif",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["video_source"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
},
|
||||
svg: {
|
||||
inkscape: {
|
||||
strength: "svg_expert",
|
||||
cold_start_cost: 1500,
|
||||
best_for: ["vector_editing", "complex_svg"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.5,
|
||||
quality_priority: 1.0,
|
||||
},
|
||||
resvg: {
|
||||
strength: "fast_svg_render",
|
||||
cold_start_cost: 200,
|
||||
best_for: ["svg_to_png", "simple_render"],
|
||||
large_file_capability: 0.85,
|
||||
speed_priority: 0.95,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "fallback",
|
||||
cold_start_cost: 500,
|
||||
large_file_capability: 0.6,
|
||||
speed_priority: 0.6,
|
||||
quality_priority: 0.7,
|
||||
},
|
||||
},
|
||||
pdf: {
|
||||
libreoffice: {
|
||||
strength: "document_expert",
|
||||
cold_start_cost: 3000,
|
||||
best_for: ["office_to_pdf", "complex_documents"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.4,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
imagemagick: {
|
||||
strength: "image_to_pdf",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["image_conversion"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
pandoc: {
|
||||
strength: "markdown_to_pdf",
|
||||
cold_start_cost: 1000,
|
||||
best_for: ["markdown", "text_documents"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.6,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 影片格式 ====================
|
||||
mp4: {
|
||||
ffmpeg: {
|
||||
strength: "video_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["all_video"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.8,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
webm: {
|
||||
ffmpeg: {
|
||||
strength: "video_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["web_video"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.75,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
mkv: {
|
||||
ffmpeg: {
|
||||
strength: "video_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["container_conversion"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.85,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 音訊格式 ====================
|
||||
mp3: {
|
||||
ffmpeg: {
|
||||
strength: "audio_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["all_audio"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
},
|
||||
wav: {
|
||||
ffmpeg: {
|
||||
strength: "audio_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["lossless_audio"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 1.0,
|
||||
},
|
||||
},
|
||||
flac: {
|
||||
ffmpeg: {
|
||||
strength: "audio_universal",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["lossless_compression"],
|
||||
large_file_capability: 0.95,
|
||||
speed_priority: 0.85,
|
||||
quality_priority: 1.0,
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 文件格式 ====================
|
||||
docx: {
|
||||
libreoffice: {
|
||||
strength: "document_expert",
|
||||
cold_start_cost: 3000,
|
||||
best_for: ["office_documents"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.4,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
pandoc: {
|
||||
strength: "text_conversion",
|
||||
cold_start_cost: 1000,
|
||||
best_for: ["simple_documents", "markdown_source"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.85,
|
||||
},
|
||||
},
|
||||
txt: {
|
||||
pandoc: {
|
||||
strength: "text_extraction",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["text_extraction"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
markitDown: {
|
||||
strength: "ai_extraction",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["complex_documents"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.6,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
},
|
||||
md: {
|
||||
pandoc: {
|
||||
strength: "markdown_expert",
|
||||
cold_start_cost: 500,
|
||||
best_for: ["markdown_conversion"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.9,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
markitDown: {
|
||||
strength: "ai_extraction",
|
||||
cold_start_cost: 800,
|
||||
best_for: ["pdf_to_md", "image_to_md"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.6,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
epub: {
|
||||
calibre: {
|
||||
strength: "ebook_expert",
|
||||
cold_start_cost: 2000,
|
||||
best_for: ["ebook_conversion"],
|
||||
large_file_capability: 0.85,
|
||||
speed_priority: 0.5,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
pandoc: {
|
||||
strength: "simple_epub",
|
||||
cold_start_cost: 1000,
|
||||
best_for: ["simple_ebooks"],
|
||||
large_file_capability: 0.7,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.8,
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 資料格式 ====================
|
||||
json: {
|
||||
dasel: {
|
||||
strength: "data_transform",
|
||||
cold_start_cost: 100,
|
||||
best_for: ["data_conversion"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.95,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
},
|
||||
yaml: {
|
||||
dasel: {
|
||||
strength: "data_transform",
|
||||
cold_start_cost: 100,
|
||||
best_for: ["data_conversion"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.95,
|
||||
quality_priority: 0.95,
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
dasel: {
|
||||
strength: "data_transform",
|
||||
cold_start_cost: 100,
|
||||
best_for: ["structured_data"],
|
||||
large_file_capability: 0.9,
|
||||
speed_priority: 0.95,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 3D 模型格式 ====================
|
||||
obj: {
|
||||
assimp: {
|
||||
strength: "3d_universal",
|
||||
cold_start_cost: 600,
|
||||
best_for: ["3d_conversion"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
gltf: {
|
||||
assimp: {
|
||||
strength: "3d_universal",
|
||||
cold_start_cost: 600,
|
||||
best_for: ["web_3d"],
|
||||
large_file_capability: 0.8,
|
||||
speed_priority: 0.7,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
stl: {
|
||||
assimp: {
|
||||
strength: "3d_universal",
|
||||
cold_start_cost: 600,
|
||||
best_for: ["3d_printing"],
|
||||
large_file_capability: 0.85,
|
||||
speed_priority: 0.75,
|
||||
quality_priority: 0.9,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 引擎預測結果
|
||||
*/
|
||||
export interface EnginePrediction {
|
||||
/** 預測的引擎名稱 */
|
||||
engine: string;
|
||||
/** 預測信心度 (0-1) */
|
||||
confidence: number;
|
||||
/** 是否應該預調用 */
|
||||
should_warmup: boolean;
|
||||
/** 預估冷啟動成本 (毫秒) */
|
||||
cold_start_cost: number;
|
||||
/** 預測原因 */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎預測模型類
|
||||
*/
|
||||
export class EnginePredictionModel {
|
||||
private warmupConfidenceThreshold: number;
|
||||
private warmupMinColdStartCost: number;
|
||||
|
||||
constructor(warmupConfidenceThreshold = 0.7, warmupMinColdStartCost = 500) {
|
||||
this.warmupConfidenceThreshold = warmupConfidenceThreshold;
|
||||
this.warmupMinColdStartCost = warmupMinColdStartCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 預測最適合的轉換引擎
|
||||
*/
|
||||
predict(
|
||||
targetFormat: string,
|
||||
features: FileFeatures,
|
||||
userProfile: UserProfile | null,
|
||||
availableEngines?: string[],
|
||||
): EnginePrediction | null {
|
||||
const formatEngines = ENGINE_CAPABILITY_MATRIX[targetFormat];
|
||||
if (!formatEngines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 過濾可用引擎
|
||||
let engines = Object.entries(formatEngines);
|
||||
|
||||
if (availableEngines && availableEngines.length > 0) {
|
||||
engines = engines.filter(([name]) => availableEngines.includes(name));
|
||||
}
|
||||
|
||||
// 過濾禁止的引擎
|
||||
engines = engines.filter(([, cap]) => !cap.disallowed);
|
||||
|
||||
if (engines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 計算每個引擎的得分
|
||||
const scoredEngines: Array<{
|
||||
engine: string;
|
||||
score: number;
|
||||
cap: EngineCapability;
|
||||
reason: string;
|
||||
}> = [];
|
||||
|
||||
for (const [engineName, capability] of engines) {
|
||||
let score = 0;
|
||||
let reason = "";
|
||||
|
||||
// 1. 使用者歷史偏好
|
||||
const userPref = this.getUserEnginePreference(targetFormat, engineName, userProfile);
|
||||
score += userPref * 0.4;
|
||||
if (userPref > 0.5) {
|
||||
reason = "user_preference";
|
||||
}
|
||||
|
||||
// 2. 檔案特徵匹配
|
||||
const featureScore = this.calculateFeatureMatchScore(features, capability);
|
||||
score += featureScore * 0.35;
|
||||
if (featureScore > 0.7 && !reason) {
|
||||
reason = "feature_match";
|
||||
}
|
||||
|
||||
// 3. 歷史成功率 (假設都有好的成功率,用能力值代替)
|
||||
const reliabilityScore = (capability.quality_priority ?? 0.8) * 0.15;
|
||||
score += reliabilityScore;
|
||||
|
||||
// 4. 速度考量
|
||||
const speedScore = (capability.speed_priority ?? 0.7) * 0.1;
|
||||
score += speedScore;
|
||||
|
||||
if (!reason) {
|
||||
reason = "balanced_choice";
|
||||
}
|
||||
|
||||
scoredEngines.push({
|
||||
engine: engineName,
|
||||
score,
|
||||
cap: capability,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
// 排序取最佳
|
||||
scoredEngines.sort((a, b) => b.score - a.score);
|
||||
const bestEngine = scoredEngines[0];
|
||||
|
||||
if (!bestEngine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 計算信心度
|
||||
const confidence = this.calculateConfidence(scoredEngines);
|
||||
|
||||
// 決定是否預調用
|
||||
const shouldWarmup =
|
||||
confidence >= this.warmupConfidenceThreshold &&
|
||||
bestEngine.cap.cold_start_cost >= this.warmupMinColdStartCost;
|
||||
|
||||
return {
|
||||
engine: bestEngine.engine,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
should_warmup: shouldWarmup,
|
||||
cold_start_cost: bestEngine.cap.cold_start_cost,
|
||||
reason: bestEngine.reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得使用者對特定格式的引擎偏好
|
||||
*/
|
||||
private getUserEnginePreference(
|
||||
format: string,
|
||||
engine: string,
|
||||
userProfile: UserProfile | null,
|
||||
): number {
|
||||
if (!userProfile?.engine_preferences) return 0;
|
||||
|
||||
const formatPrefs = userProfile.engine_preferences[format];
|
||||
if (!formatPrefs) return 0;
|
||||
|
||||
return formatPrefs[engine] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算檔案特徵與引擎能力的匹配分數
|
||||
*/
|
||||
private calculateFeatureMatchScore(features: FileFeatures, capability: EngineCapability): number {
|
||||
let score = 0.5; // 基礎分
|
||||
|
||||
// 大檔案處理能力
|
||||
if (features.file_size_kb > 5000) {
|
||||
// > 5MB
|
||||
score += (capability.large_file_capability ?? 0.5) * 0.3;
|
||||
}
|
||||
|
||||
// 圖片特定評估
|
||||
if (features.image) {
|
||||
// 高解析度圖片
|
||||
if (features.image.megapixels > 10) {
|
||||
score += (capability.large_file_capability ?? 0.5) * 0.2;
|
||||
score += (capability.speed_priority ?? 0.5) * 0.1;
|
||||
}
|
||||
|
||||
// 檢查 best_for 標籤
|
||||
if (capability.best_for) {
|
||||
if (features.image.megapixels > 10 && capability.best_for.includes("large_images")) {
|
||||
score += 0.2;
|
||||
}
|
||||
if (features.image.is_animation && capability.best_for.includes("animations")) {
|
||||
score += 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(score, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算預測信心度
|
||||
*/
|
||||
private calculateConfidence(scoredEngines: Array<{ engine: string; score: number }>): number {
|
||||
if (scoredEngines.length === 0) return 0;
|
||||
|
||||
const first = scoredEngines[0];
|
||||
if (!first) return 0;
|
||||
if (scoredEngines.length === 1) return Math.min(first.score, 1);
|
||||
|
||||
const topScore = first.score;
|
||||
const secondScore = scoredEngines[1]?.score ?? 0;
|
||||
|
||||
// 使用相對優勢
|
||||
const gap = topScore - secondScore;
|
||||
const relativeGap = gap / (topScore + 0.001);
|
||||
|
||||
const confidence = topScore * 0.7 + relativeGap * 0.3;
|
||||
return Math.min(Math.max(confidence, 0), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得格式可用的引擎列表
|
||||
*/
|
||||
getAvailableEngines(format: string): string[] {
|
||||
const formatEngines = ENGINE_CAPABILITY_MATRIX[format];
|
||||
if (!formatEngines) return [];
|
||||
|
||||
return Object.entries(formatEngines)
|
||||
.filter(([, cap]) => !cap.disallowed)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查引擎是否允許用於格式
|
||||
*/
|
||||
isEngineAllowed(format: string, engine: string): boolean {
|
||||
const formatEngines = ENGINE_CAPABILITY_MATRIX[format];
|
||||
if (!formatEngines) return false;
|
||||
|
||||
const capability = formatEngines[engine];
|
||||
return capability !== undefined && !capability.disallowed;
|
||||
}
|
||||
}
|
||||
|
||||
// 導出單例
|
||||
export const enginePredictionModel = new EnginePredictionModel();
|
||||
253
src/inference/engineWarmup.ts
Normal file
253
src/inference/engineWarmup.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* 引擎預調用 (Warm-up) 模組
|
||||
*
|
||||
* 在預測信心度足夠高時,提前預調用引擎以降低轉檔延遲
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 預調用狀態
|
||||
*/
|
||||
export interface WarmupStatus {
|
||||
/** 引擎名稱 */
|
||||
engine: string;
|
||||
/** 狀態 */
|
||||
status: "pending" | "warming" | "ready" | "failed" | "cancelled";
|
||||
/** 開始時間 */
|
||||
started_at?: string;
|
||||
/** 完成時間 */
|
||||
completed_at?: string;
|
||||
/** 錯誤訊息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎預調用命令配置
|
||||
*/
|
||||
interface WarmupCommand {
|
||||
/** 執行檔 */
|
||||
command: string;
|
||||
/** 參數 */
|
||||
args: string[];
|
||||
/** 超時時間 (毫秒) */
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各引擎的預調用命令
|
||||
*/
|
||||
const ENGINE_WARMUP_COMMANDS: Record<string, WarmupCommand> = {
|
||||
imagemagick: {
|
||||
command: "magick",
|
||||
args: ["-version"],
|
||||
timeout: 5000,
|
||||
},
|
||||
graphicsmagick: {
|
||||
command: "gm",
|
||||
args: ["version"],
|
||||
timeout: 5000,
|
||||
},
|
||||
vips: {
|
||||
command: "vips",
|
||||
args: ["--version"],
|
||||
timeout: 3000,
|
||||
},
|
||||
ffmpeg: {
|
||||
command: "ffmpeg",
|
||||
args: ["-version"],
|
||||
timeout: 5000,
|
||||
},
|
||||
libreoffice: {
|
||||
command: "soffice",
|
||||
args: ["--version"],
|
||||
timeout: 10000,
|
||||
},
|
||||
inkscape: {
|
||||
command: "inkscape",
|
||||
args: ["--version"],
|
||||
timeout: 8000,
|
||||
},
|
||||
pandoc: {
|
||||
command: "pandoc",
|
||||
args: ["--version"],
|
||||
timeout: 5000,
|
||||
},
|
||||
calibre: {
|
||||
command: "ebook-convert",
|
||||
args: ["--version"],
|
||||
timeout: 8000,
|
||||
},
|
||||
resvg: {
|
||||
command: "resvg",
|
||||
args: ["--help"],
|
||||
timeout: 3000,
|
||||
},
|
||||
libheif: {
|
||||
command: "heif-info",
|
||||
args: ["--version"],
|
||||
timeout: 3000,
|
||||
},
|
||||
libjxl: {
|
||||
command: "djxl",
|
||||
args: ["--version"],
|
||||
timeout: 3000,
|
||||
},
|
||||
assimp: {
|
||||
command: "assimp",
|
||||
args: ["version"],
|
||||
timeout: 5000,
|
||||
},
|
||||
dasel: {
|
||||
command: "dasel",
|
||||
args: ["--version"],
|
||||
timeout: 2000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 引擎預調用管理器
|
||||
*/
|
||||
export class EngineWarmupManager {
|
||||
/** 當前預調用狀態 */
|
||||
private currentWarmup: WarmupStatus | null = null;
|
||||
|
||||
/** 預調用 Promise */
|
||||
private warmupPromise: Promise<void> | null = null;
|
||||
|
||||
/** 取消標記 */
|
||||
private cancelRequested = false;
|
||||
|
||||
/**
|
||||
* 開始預調用引擎
|
||||
* 注意:同時只會有一個引擎被預調用
|
||||
*/
|
||||
async warmup(engine: string): Promise<boolean> {
|
||||
// 如果已經在預調用同一個引擎,直接返回
|
||||
if (this.currentWarmup?.engine === engine && this.currentWarmup.status === "warming") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 取消之前的預調用
|
||||
if (this.currentWarmup?.status === "warming") {
|
||||
this.cancel();
|
||||
}
|
||||
|
||||
// 檢查是否有該引擎的預調用命令
|
||||
const warmupCmd = ENGINE_WARMUP_COMMANDS[engine];
|
||||
if (!warmupCmd) {
|
||||
console.warn(`No warmup command defined for engine: ${engine}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 設定狀態
|
||||
this.cancelRequested = false;
|
||||
this.currentWarmup = {
|
||||
engine,
|
||||
status: "warming",
|
||||
started_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`🔥 Warming up engine: ${engine}`);
|
||||
|
||||
// 執行預調用
|
||||
this.warmupPromise = this.executeWarmup(engine, warmupCmd);
|
||||
|
||||
try {
|
||||
await this.warmupPromise;
|
||||
return this.currentWarmup?.status === "ready";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行預調用命令
|
||||
*/
|
||||
private async executeWarmup(engine: string, cmd: WarmupCommand): Promise<void> {
|
||||
try {
|
||||
// 檢查是否已取消
|
||||
if (this.cancelRequested) {
|
||||
this.updateStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
await execFileAsync(cmd.command, cmd.args, {
|
||||
timeout: cmd.timeout,
|
||||
});
|
||||
|
||||
// 再次檢查是否已取消
|
||||
if (this.cancelRequested) {
|
||||
this.updateStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus("ready");
|
||||
console.log(`✅ Engine ${engine} warmed up successfully`);
|
||||
} catch (error) {
|
||||
if (this.cancelRequested) {
|
||||
this.updateStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus("failed", error instanceof Error ? error.message : "Unknown error");
|
||||
console.warn(`⚠️ Failed to warm up engine ${engine}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新預調用狀態
|
||||
*/
|
||||
private updateStatus(status: WarmupStatus["status"], error?: string): void {
|
||||
if (this.currentWarmup) {
|
||||
this.currentWarmup.status = status;
|
||||
if (status === "ready" || status === "failed" || status === "cancelled") {
|
||||
this.currentWarmup.completed_at = new Date().toISOString();
|
||||
}
|
||||
if (error) {
|
||||
this.currentWarmup.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消當前預調用
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this.currentWarmup?.status === "warming") {
|
||||
console.log(`❌ Cancelling warmup for engine: ${this.currentWarmup.engine}`);
|
||||
this.cancelRequested = true;
|
||||
this.updateStatus("cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前預調用狀態
|
||||
*/
|
||||
getStatus(): WarmupStatus | null {
|
||||
return this.currentWarmup;
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查引擎是否已預調用完成
|
||||
*/
|
||||
isReady(engine: string): boolean {
|
||||
return this.currentWarmup?.engine === engine && this.currentWarmup.status === "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置狀態
|
||||
*/
|
||||
reset(): void {
|
||||
this.cancel();
|
||||
this.currentWarmup = null;
|
||||
this.warmupPromise = null;
|
||||
this.cancelRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 導出單例
|
||||
export const engineWarmupManager = new EngineWarmupManager();
|
||||
429
src/inference/featureExtraction.ts
Normal file
429
src/inference/featureExtraction.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
/**
|
||||
* 檔案特徵抽取模組
|
||||
*
|
||||
* 從上傳的檔案中提取用於格式推斷的特徵
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { stat } from "node:fs/promises";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 基礎檔案特徵
|
||||
*/
|
||||
export interface BaseFileFeatures {
|
||||
/** 輸入檔案副檔名 (不含點) */
|
||||
input_ext: string;
|
||||
/** MIME 類型 */
|
||||
mime_type: string;
|
||||
/** 檔案大小 (KB) */
|
||||
file_size_kb: number;
|
||||
/** 魔數家族 (image/video/audio/document/data/model3d/archive/font/unknown) */
|
||||
magic_family: string;
|
||||
/** 上傳時段 (morning/afternoon/evening/night) */
|
||||
upload_hour_bucket: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 圖片專用特徵
|
||||
*/
|
||||
export interface ImageFeatures {
|
||||
/** 寬度 (像素) */
|
||||
width: number;
|
||||
/** 高度 (像素) */
|
||||
height: number;
|
||||
/** 百萬像素 */
|
||||
megapixels: number;
|
||||
/** 是否有透明通道 */
|
||||
has_alpha: boolean;
|
||||
/** 是否為動畫 */
|
||||
is_animation: boolean;
|
||||
/** 色彩模式 (rgb/rgba/cmyk/grayscale) */
|
||||
color_mode: string;
|
||||
/** 長寬比描述 */
|
||||
aspect_ratio: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整檔案特徵
|
||||
*/
|
||||
export interface FileFeatures extends BaseFileFeatures {
|
||||
/** 圖片專用特徵 (僅當 magic_family === 'image' 時存在) */
|
||||
image?: ImageFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 副檔名到 magic_family 的映射
|
||||
*/
|
||||
const EXT_TO_FAMILY: Record<string, string> = {
|
||||
// 圖片
|
||||
jpg: "image",
|
||||
jpeg: "image",
|
||||
png: "image",
|
||||
gif: "image",
|
||||
webp: "image",
|
||||
avif: "image",
|
||||
heic: "image",
|
||||
heif: "image",
|
||||
bmp: "image",
|
||||
tiff: "image",
|
||||
tif: "image",
|
||||
svg: "image",
|
||||
ico: "image",
|
||||
icns: "image",
|
||||
psd: "image",
|
||||
raw: "image",
|
||||
cr2: "image",
|
||||
cr3: "image",
|
||||
nef: "image",
|
||||
arw: "image",
|
||||
dng: "image",
|
||||
orf: "image",
|
||||
rw2: "image",
|
||||
jxl: "image",
|
||||
apng: "image",
|
||||
eps: "image",
|
||||
ai: "image",
|
||||
emf: "image",
|
||||
wmf: "image",
|
||||
cur: "image",
|
||||
|
||||
// 影片
|
||||
mp4: "video",
|
||||
mkv: "video",
|
||||
avi: "video",
|
||||
mov: "video",
|
||||
webm: "video",
|
||||
flv: "video",
|
||||
wmv: "video",
|
||||
m4v: "video",
|
||||
"3gp": "video",
|
||||
mpeg: "video",
|
||||
mpg: "video",
|
||||
mts: "video",
|
||||
m2ts: "video",
|
||||
vob: "video",
|
||||
ogv: "video",
|
||||
prores: "video",
|
||||
mxf: "video",
|
||||
|
||||
// 音訊
|
||||
mp3: "audio",
|
||||
wav: "audio",
|
||||
flac: "audio",
|
||||
aac: "audio",
|
||||
ogg: "audio",
|
||||
m4a: "audio",
|
||||
wma: "audio",
|
||||
opus: "audio",
|
||||
aiff: "audio",
|
||||
ape: "audio",
|
||||
alac: "audio",
|
||||
mid: "audio",
|
||||
midi: "audio",
|
||||
|
||||
// 文件
|
||||
pdf: "document",
|
||||
doc: "document",
|
||||
docx: "document",
|
||||
odt: "document",
|
||||
rtf: "document",
|
||||
txt: "document",
|
||||
xls: "document",
|
||||
xlsx: "document",
|
||||
ods: "document",
|
||||
ppt: "document",
|
||||
pptx: "document",
|
||||
odp: "document",
|
||||
epub: "document",
|
||||
mobi: "document",
|
||||
azw3: "document",
|
||||
fb2: "document",
|
||||
md: "document",
|
||||
markdown: "document",
|
||||
rst: "document",
|
||||
tex: "document",
|
||||
html: "document",
|
||||
htm: "document",
|
||||
key: "document",
|
||||
pages: "document",
|
||||
numbers: "document",
|
||||
msg: "document",
|
||||
eml: "document",
|
||||
|
||||
// 資料
|
||||
json: "data",
|
||||
yaml: "data",
|
||||
yml: "data",
|
||||
xml: "data",
|
||||
csv: "data",
|
||||
toml: "data",
|
||||
ini: "data",
|
||||
vcf: "data",
|
||||
|
||||
// 3D 模型
|
||||
obj: "model3d",
|
||||
fbx: "model3d",
|
||||
gltf: "model3d",
|
||||
glb: "model3d",
|
||||
stl: "model3d",
|
||||
dae: "model3d",
|
||||
ply: "model3d",
|
||||
"3ds": "model3d",
|
||||
blend: "model3d",
|
||||
|
||||
// 壓縮檔
|
||||
zip: "archive",
|
||||
tar: "archive",
|
||||
gz: "archive",
|
||||
"7z": "archive",
|
||||
rar: "archive",
|
||||
bz2: "archive",
|
||||
xz: "archive",
|
||||
|
||||
// 字型
|
||||
ttf: "font",
|
||||
otf: "font",
|
||||
woff: "font",
|
||||
woff2: "font",
|
||||
eot: "font",
|
||||
};
|
||||
|
||||
/**
|
||||
* 副檔名到 MIME 類型的映射
|
||||
*/
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
bmp: "image/bmp",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
ico: "image/x-icon",
|
||||
psd: "image/vnd.adobe.photoshop",
|
||||
jxl: "image/jxl",
|
||||
|
||||
mp4: "video/mp4",
|
||||
mkv: "video/x-matroska",
|
||||
avi: "video/x-msvideo",
|
||||
mov: "video/quicktime",
|
||||
webm: "video/webm",
|
||||
flv: "video/x-flv",
|
||||
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
flac: "audio/flac",
|
||||
aac: "audio/aac",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
opus: "audio/opus",
|
||||
|
||||
pdf: "application/pdf",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
json: "application/json",
|
||||
xml: "application/xml",
|
||||
csv: "text/csv",
|
||||
txt: "text/plain",
|
||||
html: "text/html",
|
||||
md: "text/markdown",
|
||||
};
|
||||
|
||||
/**
|
||||
* 根據小時數取得時段
|
||||
*/
|
||||
function getHourBucket(hour: number): string {
|
||||
if (hour >= 6 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 22) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算長寬比描述
|
||||
*/
|
||||
function getAspectRatio(width: number, height: number): string {
|
||||
const ratio = width / height;
|
||||
if (Math.abs(ratio - 1) < 0.05) return "1:1";
|
||||
if (Math.abs(ratio - 4 / 3) < 0.1) return "4:3";
|
||||
if (Math.abs(ratio - 3 / 4) < 0.1) return "3:4";
|
||||
if (Math.abs(ratio - 16 / 9) < 0.1) return "16:9";
|
||||
if (Math.abs(ratio - 9 / 16) < 0.1) return "9:16";
|
||||
if (Math.abs(ratio - 3 / 2) < 0.1) return "3:2";
|
||||
if (Math.abs(ratio - 2 / 3) < 0.1) return "2:3";
|
||||
if (ratio > 2) return "panorama";
|
||||
if (ratio < 0.5) return "tall";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ImageMagick 提取圖片特徵
|
||||
*/
|
||||
async function extractImageFeaturesWithImageMagick(
|
||||
filePath: string,
|
||||
): Promise<ImageFeatures | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("identify", [
|
||||
"-format",
|
||||
"%w|%h|%[channels]|%n|%[colorspace]",
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const parts = stdout.trim().split("|");
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const width = parseInt(parts[0] ?? "0", 10);
|
||||
const height = parseInt(parts[1] ?? "0", 10);
|
||||
const channels = parts[2] ?? "";
|
||||
const frameCount = parseInt(parts[3] ?? "0", 10);
|
||||
const colorspace = (parts[4] ?? "").toLowerCase();
|
||||
|
||||
const megapixels = (width * height) / 1_000_000;
|
||||
const has_alpha = channels.includes("a") || channels.includes("alpha");
|
||||
const is_animation = frameCount > 1;
|
||||
|
||||
let color_mode = "rgb";
|
||||
if (colorspace.includes("gray")) {
|
||||
color_mode = "grayscale";
|
||||
} else if (colorspace.includes("cmyk")) {
|
||||
color_mode = "cmyk";
|
||||
} else if (has_alpha) {
|
||||
color_mode = "rgba";
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
megapixels: Math.round(megapixels * 100) / 100,
|
||||
has_alpha,
|
||||
is_animation,
|
||||
color_mode,
|
||||
aspect_ratio: getAspectRatio(width, height),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("ImageMagick identify failed, trying vips...", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 vips 提取圖片特徵 (備用)
|
||||
*/
|
||||
async function extractImageFeaturesWithVips(filePath: string): Promise<ImageFeatures | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("vipsheader", ["-a", filePath]);
|
||||
|
||||
const lines = stdout.trim().split("\n");
|
||||
let width = 0,
|
||||
height = 0,
|
||||
bands = 3;
|
||||
|
||||
for (const line of lines) {
|
||||
const [key, value] = line.split(":").map((s) => s.trim());
|
||||
if (key === "width" && value) width = parseInt(value, 10);
|
||||
if (key === "height" && value) height = parseInt(value, 10);
|
||||
if (key === "bands" && value) bands = parseInt(value, 10);
|
||||
}
|
||||
|
||||
if (width === 0 || height === 0) return null;
|
||||
|
||||
const megapixels = (width * height) / 1_000_000;
|
||||
const has_alpha = bands === 4;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
megapixels: Math.round(megapixels * 100) / 100,
|
||||
has_alpha,
|
||||
is_animation: false, // vipsheader doesn't easily detect animation
|
||||
color_mode: has_alpha ? "rgba" : "rgb",
|
||||
aspect_ratio: getAspectRatio(width, height),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("vipsheader failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取圖片特徵
|
||||
*/
|
||||
async function extractImageFeatures(filePath: string): Promise<ImageFeatures | null> {
|
||||
// 優先使用 ImageMagick
|
||||
let features = await extractImageFeaturesWithImageMagick(filePath);
|
||||
if (features) return features;
|
||||
|
||||
// 備用使用 vips
|
||||
features = await extractImageFeaturesWithVips(filePath);
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從檔案路徑提取完整特徵
|
||||
*/
|
||||
export async function extractFeatures(filePath: string): Promise<FileFeatures> {
|
||||
// 取得副檔名
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
// 取得檔案大小
|
||||
let fileSizeKb = 0;
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
fileSizeKb = Math.round(stats.size / 1024);
|
||||
} catch {
|
||||
console.warn("Could not stat file:", filePath);
|
||||
}
|
||||
|
||||
// 決定 magic_family
|
||||
const magicFamily = EXT_TO_FAMILY[ext] ?? "unknown";
|
||||
|
||||
// 決定 MIME 類型
|
||||
const mimeType = EXT_TO_MIME[ext] ?? "application/octet-stream";
|
||||
|
||||
// 取得時段
|
||||
const uploadHourBucket = getHourBucket(new Date().getHours());
|
||||
|
||||
const baseFeatures: FileFeatures = {
|
||||
input_ext: ext,
|
||||
mime_type: mimeType,
|
||||
file_size_kb: fileSizeKb,
|
||||
magic_family: magicFamily,
|
||||
upload_hour_bucket: uploadHourBucket,
|
||||
};
|
||||
|
||||
// 如果是圖片,提取圖片特徵
|
||||
if (magicFamily === "image") {
|
||||
const imageFeatures = await extractImageFeatures(filePath);
|
||||
if (imageFeatures) {
|
||||
baseFeatures.image = imageFeatures;
|
||||
}
|
||||
}
|
||||
|
||||
return baseFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從副檔名快速提取基礎特徵 (不需要實際檔案)
|
||||
* 用於前端快速推斷
|
||||
*/
|
||||
export function extractFeaturesFromExtension(ext: string): Omit<FileFeatures, "file_size_kb"> {
|
||||
const cleanExt = ext.toLowerCase().replace(/^\./, "");
|
||||
const magicFamily = EXT_TO_FAMILY[cleanExt] ?? "unknown";
|
||||
const mimeType = EXT_TO_MIME[cleanExt] ?? "application/octet-stream";
|
||||
const uploadHourBucket = getHourBucket(new Date().getHours());
|
||||
|
||||
return {
|
||||
input_ext: cleanExt,
|
||||
mime_type: mimeType,
|
||||
magic_family: magicFamily,
|
||||
upload_hour_bucket: uploadHourBucket,
|
||||
};
|
||||
}
|
||||
512
src/inference/formatCandidateRules.ts
Normal file
512
src/inference/formatCandidateRules.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
/**
|
||||
* 格式候選規則配置檔
|
||||
*
|
||||
* 本模組定義了基於檔案特徵的格式候選生成規則
|
||||
* 用於將 1000+ 格式空間降維為 10~30 個候選格式
|
||||
*/
|
||||
|
||||
export interface FormatCandidateRule {
|
||||
/** 規則ID */
|
||||
id: string;
|
||||
/** 觸發條件 */
|
||||
conditions: {
|
||||
magic_family?: string;
|
||||
has_alpha?: boolean;
|
||||
is_animation?: boolean;
|
||||
input_ext?: string | string[];
|
||||
min_megapixels?: number;
|
||||
max_megapixels?: number;
|
||||
color_mode?: string;
|
||||
};
|
||||
/** 候選格式及其先驗權重 (0-1) */
|
||||
candidates: Record<string, number>;
|
||||
/** 規則原因碼 */
|
||||
reason_code: string;
|
||||
/** 規則優先級 (數字越大優先級越高) */
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式候選規則庫
|
||||
* 規則按優先級排序,高優先級規則先匹配
|
||||
*/
|
||||
export const formatCandidateRules: FormatCandidateRule[] = [
|
||||
// ==================== 圖片格式規則 ====================
|
||||
{
|
||||
id: "image_alpha",
|
||||
conditions: { magic_family: "image", has_alpha: true },
|
||||
candidates: {
|
||||
png: 1.0,
|
||||
webp: 0.9,
|
||||
avif: 0.7,
|
||||
tiff: 0.4,
|
||||
gif: 0.3,
|
||||
},
|
||||
reason_code: "IMG_ALPHA",
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
id: "image_animation",
|
||||
conditions: { magic_family: "image", is_animation: true },
|
||||
candidates: {
|
||||
gif: 1.0,
|
||||
webp: 0.9,
|
||||
mp4: 0.6,
|
||||
apng: 0.5,
|
||||
},
|
||||
reason_code: "IMG_ANIM",
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
id: "image_high_res",
|
||||
conditions: { magic_family: "image", min_megapixels: 10 },
|
||||
candidates: {
|
||||
webp: 1.0,
|
||||
avif: 0.95,
|
||||
jpeg: 0.85,
|
||||
jxl: 0.8,
|
||||
png: 0.6,
|
||||
heif: 0.5,
|
||||
},
|
||||
reason_code: "IMG_HIGHRES",
|
||||
priority: 90,
|
||||
},
|
||||
{
|
||||
id: "image_photo_raw",
|
||||
conditions: {
|
||||
magic_family: "image",
|
||||
input_ext: ["raw", "cr2", "cr3", "nef", "arw", "dng", "orf", "rw2"],
|
||||
},
|
||||
candidates: {
|
||||
jpeg: 1.0,
|
||||
png: 0.8,
|
||||
tiff: 0.7,
|
||||
webp: 0.6,
|
||||
},
|
||||
reason_code: "IMG_RAW",
|
||||
priority: 95,
|
||||
},
|
||||
{
|
||||
id: "image_vector_input",
|
||||
conditions: {
|
||||
magic_family: "image",
|
||||
input_ext: ["svg", "eps", "ai", "emf", "wmf"],
|
||||
},
|
||||
candidates: {
|
||||
svg: 1.0,
|
||||
png: 0.9,
|
||||
pdf: 0.8,
|
||||
eps: 0.6,
|
||||
},
|
||||
reason_code: "IMG_VECTOR",
|
||||
priority: 95,
|
||||
},
|
||||
{
|
||||
id: "image_icon",
|
||||
conditions: {
|
||||
magic_family: "image",
|
||||
input_ext: ["ico", "icns", "cur"],
|
||||
},
|
||||
candidates: {
|
||||
png: 1.0,
|
||||
ico: 0.9,
|
||||
svg: 0.5,
|
||||
},
|
||||
reason_code: "IMG_ICON",
|
||||
priority: 95,
|
||||
},
|
||||
{
|
||||
id: "image_default",
|
||||
conditions: { magic_family: "image" },
|
||||
candidates: {
|
||||
png: 0.9,
|
||||
jpeg: 0.85,
|
||||
webp: 0.8,
|
||||
avif: 0.7,
|
||||
pdf: 0.4,
|
||||
gif: 0.3,
|
||||
tiff: 0.25,
|
||||
bmp: 0.2,
|
||||
},
|
||||
reason_code: "IMG_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
// ==================== 影片格式規則 ====================
|
||||
{
|
||||
id: "video_default",
|
||||
conditions: { magic_family: "video" },
|
||||
candidates: {
|
||||
mp4: 1.0,
|
||||
webm: 0.8,
|
||||
mkv: 0.7,
|
||||
avi: 0.5,
|
||||
mov: 0.5,
|
||||
gif: 0.4,
|
||||
mp3: 0.35,
|
||||
wav: 0.25,
|
||||
},
|
||||
reason_code: "VID_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
id: "video_high_quality",
|
||||
conditions: {
|
||||
magic_family: "video",
|
||||
input_ext: ["mov", "prores", "mxf"],
|
||||
},
|
||||
candidates: {
|
||||
mp4: 1.0,
|
||||
mkv: 0.9,
|
||||
webm: 0.7,
|
||||
prores: 0.6,
|
||||
},
|
||||
reason_code: "VID_HQ",
|
||||
priority: 80,
|
||||
},
|
||||
|
||||
// ==================== 音訊格式規則 ====================
|
||||
{
|
||||
id: "audio_default",
|
||||
conditions: { magic_family: "audio" },
|
||||
candidates: {
|
||||
mp3: 1.0,
|
||||
wav: 0.8,
|
||||
flac: 0.7,
|
||||
aac: 0.65,
|
||||
ogg: 0.6,
|
||||
m4a: 0.55,
|
||||
opus: 0.5,
|
||||
},
|
||||
reason_code: "AUD_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
id: "audio_lossless",
|
||||
conditions: {
|
||||
magic_family: "audio",
|
||||
input_ext: ["flac", "wav", "alac", "ape", "aiff"],
|
||||
},
|
||||
candidates: {
|
||||
flac: 1.0,
|
||||
wav: 0.9,
|
||||
mp3: 0.8,
|
||||
aac: 0.7,
|
||||
alac: 0.6,
|
||||
},
|
||||
reason_code: "AUD_LOSSLESS",
|
||||
priority: 80,
|
||||
},
|
||||
|
||||
// ==================== 文件格式規則 ====================
|
||||
{
|
||||
id: "document_office",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["doc", "docx", "odt", "rtf"],
|
||||
},
|
||||
candidates: {
|
||||
pdf: 1.0,
|
||||
docx: 0.9,
|
||||
odt: 0.7,
|
||||
txt: 0.5,
|
||||
html: 0.4,
|
||||
md: 0.3,
|
||||
},
|
||||
reason_code: "DOC_OFFICE",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "document_spreadsheet",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["xls", "xlsx", "ods", "csv"],
|
||||
},
|
||||
candidates: {
|
||||
xlsx: 1.0,
|
||||
csv: 0.9,
|
||||
ods: 0.7,
|
||||
pdf: 0.6,
|
||||
json: 0.4,
|
||||
},
|
||||
reason_code: "DOC_SHEET",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "document_presentation",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["ppt", "pptx", "odp", "key"],
|
||||
},
|
||||
candidates: {
|
||||
pdf: 1.0,
|
||||
pptx: 0.9,
|
||||
odp: 0.7,
|
||||
png: 0.5,
|
||||
},
|
||||
reason_code: "DOC_PRES",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "document_pdf",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["pdf"],
|
||||
},
|
||||
candidates: {
|
||||
docx: 0.9,
|
||||
txt: 0.85,
|
||||
png: 0.8,
|
||||
jpeg: 0.7,
|
||||
html: 0.6,
|
||||
md: 0.55,
|
||||
epub: 0.4,
|
||||
},
|
||||
reason_code: "DOC_PDF",
|
||||
priority: 85,
|
||||
},
|
||||
{
|
||||
id: "document_ebook",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["epub", "mobi", "azw3", "fb2"],
|
||||
},
|
||||
candidates: {
|
||||
epub: 1.0,
|
||||
mobi: 0.9,
|
||||
pdf: 0.85,
|
||||
azw3: 0.7,
|
||||
txt: 0.5,
|
||||
},
|
||||
reason_code: "DOC_EBOOK",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "document_markdown",
|
||||
conditions: {
|
||||
magic_family: "document",
|
||||
input_ext: ["md", "markdown", "rst", "txt"],
|
||||
},
|
||||
candidates: {
|
||||
pdf: 1.0,
|
||||
html: 0.9,
|
||||
docx: 0.8,
|
||||
txt: 0.7,
|
||||
epub: 0.5,
|
||||
},
|
||||
reason_code: "DOC_MD",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "document_default",
|
||||
conditions: { magic_family: "document" },
|
||||
candidates: {
|
||||
pdf: 1.0,
|
||||
docx: 0.8,
|
||||
txt: 0.7,
|
||||
html: 0.5,
|
||||
},
|
||||
reason_code: "DOC_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
// ==================== 資料格式規則 ====================
|
||||
{
|
||||
id: "data_json",
|
||||
conditions: {
|
||||
magic_family: "data",
|
||||
input_ext: ["json"],
|
||||
},
|
||||
candidates: {
|
||||
yaml: 1.0,
|
||||
xml: 0.8,
|
||||
csv: 0.7,
|
||||
toml: 0.6,
|
||||
},
|
||||
reason_code: "DATA_JSON",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "data_yaml",
|
||||
conditions: {
|
||||
magic_family: "data",
|
||||
input_ext: ["yaml", "yml"],
|
||||
},
|
||||
candidates: {
|
||||
json: 1.0,
|
||||
xml: 0.7,
|
||||
toml: 0.6,
|
||||
},
|
||||
reason_code: "DATA_YAML",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "data_xml",
|
||||
conditions: {
|
||||
magic_family: "data",
|
||||
input_ext: ["xml"],
|
||||
},
|
||||
candidates: {
|
||||
json: 1.0,
|
||||
yaml: 0.8,
|
||||
csv: 0.5,
|
||||
},
|
||||
reason_code: "DATA_XML",
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
id: "data_default",
|
||||
conditions: { magic_family: "data" },
|
||||
candidates: {
|
||||
json: 1.0,
|
||||
csv: 0.8,
|
||||
yaml: 0.7,
|
||||
xml: 0.6,
|
||||
},
|
||||
reason_code: "DATA_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
// ==================== 3D 模型格式規則 ====================
|
||||
{
|
||||
id: "model3d_default",
|
||||
conditions: { magic_family: "model3d" },
|
||||
candidates: {
|
||||
obj: 1.0,
|
||||
fbx: 0.9,
|
||||
gltf: 0.85,
|
||||
glb: 0.8,
|
||||
stl: 0.7,
|
||||
dae: 0.6,
|
||||
ply: 0.5,
|
||||
},
|
||||
reason_code: "3D_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
// ==================== 壓縮檔規則 ====================
|
||||
{
|
||||
id: "archive_default",
|
||||
conditions: { magic_family: "archive" },
|
||||
candidates: {
|
||||
zip: 1.0,
|
||||
tar: 0.8,
|
||||
"7z": 0.75,
|
||||
rar: 0.6,
|
||||
},
|
||||
reason_code: "ARC_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
// ==================== 字型格式規則 ====================
|
||||
{
|
||||
id: "font_default",
|
||||
conditions: { magic_family: "font" },
|
||||
candidates: {
|
||||
woff2: 1.0,
|
||||
woff: 0.9,
|
||||
ttf: 0.85,
|
||||
otf: 0.8,
|
||||
eot: 0.5,
|
||||
},
|
||||
reason_code: "FONT_BASE",
|
||||
priority: 10,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根據檔案特徵生成候選格式
|
||||
* @param features 檔案特徵
|
||||
* @returns 候選格式及權重
|
||||
*/
|
||||
export function generateCandidates(features: {
|
||||
input_ext: string;
|
||||
magic_family: string;
|
||||
has_alpha?: boolean;
|
||||
is_animation?: boolean;
|
||||
megapixels?: number;
|
||||
color_mode?: string;
|
||||
}): { candidates: Record<string, number>; matched_rules: string[] } {
|
||||
const sortedRules = [...formatCandidateRules].sort((a, b) => b.priority - a.priority);
|
||||
const mergedCandidates: Record<string, number> = {};
|
||||
const matchedRules: string[] = [];
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
if (matchesConditions(features, rule.conditions)) {
|
||||
matchedRules.push(rule.id);
|
||||
|
||||
// 合併候選格式,取較高權重
|
||||
for (const [format, weight] of Object.entries(rule.candidates)) {
|
||||
if (!mergedCandidates[format] || mergedCandidates[format] < weight) {
|
||||
mergedCandidates[format] = weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除自身格式
|
||||
delete mergedCandidates[features.input_ext];
|
||||
|
||||
return { candidates: mergedCandidates, matched_rules: matchedRules };
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查特徵是否匹配規則條件
|
||||
*/
|
||||
function matchesConditions(
|
||||
features: {
|
||||
input_ext: string;
|
||||
magic_family: string;
|
||||
has_alpha?: boolean;
|
||||
is_animation?: boolean;
|
||||
megapixels?: number;
|
||||
color_mode?: string;
|
||||
},
|
||||
conditions: FormatCandidateRule["conditions"],
|
||||
): boolean {
|
||||
// 檢查 magic_family
|
||||
if (conditions.magic_family && features.magic_family !== conditions.magic_family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 檢查 has_alpha
|
||||
if (conditions.has_alpha !== undefined && features.has_alpha !== conditions.has_alpha) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 檢查 is_animation
|
||||
if (conditions.is_animation !== undefined && features.is_animation !== conditions.is_animation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 檢查 input_ext
|
||||
if (conditions.input_ext) {
|
||||
const extList = Array.isArray(conditions.input_ext)
|
||||
? conditions.input_ext
|
||||
: [conditions.input_ext];
|
||||
if (!extList.includes(features.input_ext)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查 min_megapixels
|
||||
if (conditions.min_megapixels !== undefined) {
|
||||
if (!features.megapixels || features.megapixels < conditions.min_megapixels) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查 max_megapixels
|
||||
if (conditions.max_megapixels !== undefined) {
|
||||
if (!features.megapixels || features.megapixels > conditions.max_megapixels) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查 color_mode
|
||||
if (conditions.color_mode && features.color_mode !== conditions.color_mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
325
src/inference/formatPredictionModel.ts
Normal file
325
src/inference/formatPredictionModel.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* 格式預測模型
|
||||
*
|
||||
* 基於使用者行為歷史和檔案特徵預測最可能的目標格式
|
||||
* 使用輕量級的邏輯回歸模型 + 規則融合
|
||||
*/
|
||||
|
||||
import { generateCandidates } from "./formatCandidateRules";
|
||||
import type { FileFeatures } from "./featureExtraction";
|
||||
import type { UserProfile, FormatConversionStats } from "./behaviorStore";
|
||||
|
||||
/**
|
||||
* 格式預測結果
|
||||
*/
|
||||
export interface FormatPrediction {
|
||||
/** 預測的搜尋格式 */
|
||||
search_format: string;
|
||||
/** 預測信心度 (0-1) */
|
||||
confidence: number;
|
||||
/** Top-K 候選格式 */
|
||||
top_k: Array<{ format: string; score: number }>;
|
||||
/** 預測原因碼 */
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型權重配置
|
||||
*/
|
||||
export interface ModelWeights {
|
||||
/** 使用者歷史權重 */
|
||||
user_history_weight: number;
|
||||
/** 規則先驗權重 */
|
||||
rule_prior_weight: number;
|
||||
/** 全域流行度權重 */
|
||||
global_popularity_weight: number;
|
||||
/** 最近使用權重 */
|
||||
recency_weight: number;
|
||||
/** 檔案特徵權重 */
|
||||
feature_weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 預設模型權重
|
||||
*/
|
||||
const DEFAULT_WEIGHTS: ModelWeights = {
|
||||
user_history_weight: 0.45,
|
||||
rule_prior_weight: 0.25,
|
||||
global_popularity_weight: 0.15,
|
||||
recency_weight: 0.1,
|
||||
feature_weight: 0.05,
|
||||
};
|
||||
|
||||
/**
|
||||
* 全域格式流行度 (預設值,會被實際統計覆蓋)
|
||||
*/
|
||||
const DEFAULT_GLOBAL_POPULARITY: Record<string, number> = {
|
||||
png: 0.85,
|
||||
jpeg: 0.8,
|
||||
pdf: 0.75,
|
||||
mp4: 0.7,
|
||||
webp: 0.65,
|
||||
mp3: 0.6,
|
||||
docx: 0.55,
|
||||
gif: 0.5,
|
||||
svg: 0.45,
|
||||
wav: 0.4,
|
||||
xlsx: 0.35,
|
||||
txt: 0.3,
|
||||
json: 0.25,
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式預測模型類
|
||||
*/
|
||||
export class FormatPredictionModel {
|
||||
private weights: ModelWeights;
|
||||
private globalPopularity: Record<string, number>;
|
||||
private minConfidenceThreshold: number;
|
||||
|
||||
constructor(
|
||||
weights: Partial<ModelWeights> = {},
|
||||
globalPopularity: Record<string, number> = DEFAULT_GLOBAL_POPULARITY,
|
||||
minConfidenceThreshold = 0.4,
|
||||
) {
|
||||
this.weights = { ...DEFAULT_WEIGHTS, ...weights };
|
||||
this.globalPopularity = globalPopularity;
|
||||
this.minConfidenceThreshold = minConfidenceThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 預測最可能的目標格式
|
||||
*/
|
||||
predict(
|
||||
features: FileFeatures,
|
||||
userProfile: UserProfile | null,
|
||||
globalStats?: FormatConversionStats,
|
||||
): FormatPrediction | null {
|
||||
// 第一步:生成候選格式
|
||||
const candidateInput: {
|
||||
input_ext: string;
|
||||
magic_family: string;
|
||||
has_alpha?: boolean;
|
||||
is_animation?: boolean;
|
||||
megapixels?: number;
|
||||
color_mode?: string;
|
||||
} = {
|
||||
input_ext: features.input_ext,
|
||||
magic_family: features.magic_family,
|
||||
};
|
||||
|
||||
if (features.image?.has_alpha !== undefined) {
|
||||
candidateInput.has_alpha = features.image.has_alpha;
|
||||
}
|
||||
if (features.image?.is_animation !== undefined) {
|
||||
candidateInput.is_animation = features.image.is_animation;
|
||||
}
|
||||
if (features.image?.megapixels !== undefined) {
|
||||
candidateInput.megapixels = features.image.megapixels;
|
||||
}
|
||||
if (features.image?.color_mode !== undefined) {
|
||||
candidateInput.color_mode = features.image.color_mode;
|
||||
}
|
||||
|
||||
const { candidates, matched_rules } = generateCandidates(candidateInput);
|
||||
|
||||
if (Object.keys(candidates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 第二步:計算每個候選格式的得分
|
||||
const scoredFormats: Array<{
|
||||
format: string;
|
||||
score: number;
|
||||
components: Record<string, number>;
|
||||
}> = [];
|
||||
|
||||
for (const [format, rulePrior] of Object.entries(candidates)) {
|
||||
const components: Record<string, number> = {};
|
||||
|
||||
// 1. 規則先驗分數
|
||||
components.rule_prior = rulePrior * this.weights.rule_prior_weight;
|
||||
|
||||
// 2. 使用者歷史偏好分數
|
||||
components.user_history =
|
||||
this.calculateUserHistoryScore(features.input_ext, format, userProfile) *
|
||||
this.weights.user_history_weight;
|
||||
|
||||
// 3. 全域流行度分數
|
||||
const globalPop =
|
||||
globalStats?.format_popularity?.[format] ?? this.globalPopularity[format] ?? 0.1;
|
||||
components.global_popularity = globalPop * this.weights.global_popularity_weight;
|
||||
|
||||
// 4. 最近使用分數
|
||||
components.recency =
|
||||
this.calculateRecencyScore(format, userProfile) * this.weights.recency_weight;
|
||||
|
||||
// 5. 檔案特徵分數
|
||||
components.feature =
|
||||
this.calculateFeatureScore(features, format) * this.weights.feature_weight;
|
||||
|
||||
// 總分
|
||||
const totalScore = Object.values(components).reduce((a, b) => a + b, 0);
|
||||
|
||||
scoredFormats.push({
|
||||
format,
|
||||
score: totalScore,
|
||||
components,
|
||||
});
|
||||
}
|
||||
|
||||
// 排序並取 Top-K
|
||||
scoredFormats.sort((a, b) => b.score - a.score);
|
||||
const topK = scoredFormats.slice(0, 5).map((s) => ({
|
||||
format: s.format,
|
||||
score: Math.round(s.score * 100) / 100,
|
||||
}));
|
||||
|
||||
// 最高分的格式
|
||||
const topFormat = scoredFormats[0];
|
||||
if (!topFormat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 計算信心度 (使用 softmax 正規化後的相對優勢)
|
||||
const confidence = this.calculateConfidence(scoredFormats);
|
||||
|
||||
// 如果信心度低於閾值,不推薦
|
||||
if (confidence < this.minConfidenceThreshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
search_format: topFormat.format,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
top_k: topK,
|
||||
reason_codes: matched_rules,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算使用者歷史偏好分數
|
||||
*/
|
||||
private calculateUserHistoryScore(
|
||||
inputExt: string,
|
||||
targetFormat: string,
|
||||
userProfile: UserProfile | null,
|
||||
): number {
|
||||
if (!userProfile) return 0;
|
||||
|
||||
const conversionKey = `${inputExt}->${targetFormat}`;
|
||||
const preference = userProfile.format_preferences[conversionKey];
|
||||
|
||||
if (preference !== undefined) {
|
||||
return preference;
|
||||
}
|
||||
|
||||
// 檢查是否有任何以此格式為目標的記錄
|
||||
let totalToFormat = 0;
|
||||
let count = 0;
|
||||
const entries = Object.entries(userProfile.format_preferences) as [string, number][];
|
||||
for (const [key, value] of entries) {
|
||||
if (key.endsWith(`->${targetFormat}`)) {
|
||||
totalToFormat += value;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? (totalToFormat / count) * 0.5 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算最近使用分數
|
||||
*/
|
||||
private calculateRecencyScore(format: string, userProfile: UserProfile | null): number {
|
||||
if (!userProfile?.recent_formats) return 0;
|
||||
|
||||
const recentFormats = userProfile.recent_formats;
|
||||
const index = recentFormats.indexOf(format);
|
||||
|
||||
if (index === -1) return 0;
|
||||
|
||||
// 越近使用的分數越高
|
||||
const recencyScore = 1 - index / recentFormats.length;
|
||||
return recencyScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算檔案特徵分數
|
||||
*/
|
||||
private calculateFeatureScore(features: FileFeatures, format: string): number {
|
||||
let score = 0.5; // 基礎分
|
||||
|
||||
if (features.image) {
|
||||
// 大圖優先考慮壓縮格式
|
||||
if (features.image.megapixels > 10) {
|
||||
if (["webp", "avif", "jxl", "heif"].includes(format)) {
|
||||
score += 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 有透明通道優先 PNG
|
||||
if (features.image.has_alpha) {
|
||||
if (format === "png" || format === "webp") {
|
||||
score += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 動畫優先 GIF 或 WebP
|
||||
if (features.image.is_animation) {
|
||||
if (["gif", "webp", "apng"].includes(format)) {
|
||||
score += 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(score, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算預測信心度
|
||||
*/
|
||||
private calculateConfidence(scoredFormats: Array<{ format: string; score: number }>): number {
|
||||
if (scoredFormats.length === 0) return 0;
|
||||
|
||||
const first = scoredFormats[0];
|
||||
if (!first) return 0;
|
||||
if (scoredFormats.length === 1) return first.score;
|
||||
|
||||
const topScore = first.score;
|
||||
const secondScore = scoredFormats[1]?.score ?? 0;
|
||||
|
||||
// 使用相對優勢計算信心度
|
||||
const gap = topScore - secondScore;
|
||||
const relativeGap = gap / (topScore + 0.001);
|
||||
|
||||
// 結合絕對分數和相對優勢
|
||||
const confidence = topScore * 0.6 + relativeGap * 0.4;
|
||||
|
||||
return Math.min(Math.max(confidence, 0), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型權重 (用於線上學習)
|
||||
*/
|
||||
updateWeights(newWeights: Partial<ModelWeights>): void {
|
||||
this.weights = { ...this.weights, ...newWeights };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全域流行度統計
|
||||
*/
|
||||
updateGlobalPopularity(popularity: Record<string, number>): void {
|
||||
this.globalPopularity = { ...this.globalPopularity, ...popularity };
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定最低信心度閾值
|
||||
*/
|
||||
setMinConfidenceThreshold(threshold: number): void {
|
||||
this.minConfidenceThreshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
// 導出單例
|
||||
export const formatPredictionModel = new FormatPredictionModel();
|
||||
14
src/inference/index.ts
Normal file
14
src/inference/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* 推斷模組統一導出
|
||||
*/
|
||||
|
||||
export * from "./featureExtraction";
|
||||
export * from "./formatCandidateRules";
|
||||
export * from "./formatPredictionModel";
|
||||
export * from "./enginePredictionModel";
|
||||
export * from "./behaviorStore";
|
||||
export * from "./engineWarmup";
|
||||
export * from "./inferenceService";
|
||||
|
||||
// 預設導出推斷服務
|
||||
export { inferenceService as default } from "./inferenceService";
|
||||
343
src/inference/inferenceService.ts
Normal file
343
src/inference/inferenceService.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* 自動格式推斷服務
|
||||
*
|
||||
* 整合所有推斷模組,提供統一的推斷 API
|
||||
*/
|
||||
|
||||
import {
|
||||
extractFeatures,
|
||||
extractFeaturesFromExtension,
|
||||
type FileFeatures,
|
||||
} from "./featureExtraction";
|
||||
import {
|
||||
FormatPredictionModel,
|
||||
formatPredictionModel,
|
||||
type FormatPrediction,
|
||||
} from "./formatPredictionModel";
|
||||
import {
|
||||
EnginePredictionModel,
|
||||
enginePredictionModel,
|
||||
type EnginePrediction,
|
||||
} from "./enginePredictionModel";
|
||||
import {
|
||||
initBehaviorTables,
|
||||
getUserProfile,
|
||||
logConversionEvent,
|
||||
logDismissEvent,
|
||||
calculateGlobalStats,
|
||||
cleanupOldEvents,
|
||||
type UserProfile,
|
||||
type FormatConversionStats,
|
||||
} from "./behaviorStore";
|
||||
import { engineWarmupManager, type WarmupStatus } from "./engineWarmup";
|
||||
|
||||
/**
|
||||
* 完整推斷結果
|
||||
*/
|
||||
export interface InferenceResult {
|
||||
/** 格式推斷結果 */
|
||||
format: FormatPrediction | null;
|
||||
/** 引擎推斷結果 */
|
||||
engine: EnginePrediction | null;
|
||||
/** 檔案特徵 */
|
||||
features: FileFeatures;
|
||||
/** 是否應自動填入 */
|
||||
should_auto_fill: boolean;
|
||||
/** 預調用狀態 */
|
||||
warmup_status?: WarmupStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推斷服務配置
|
||||
*/
|
||||
export interface InferenceServiceConfig {
|
||||
/** 格式推斷最低信心度閾值 */
|
||||
formatConfidenceThreshold: number;
|
||||
/** 引擎推斷最低信心度閾值 */
|
||||
engineConfidenceThreshold: number;
|
||||
/** 是否啟用預調用 */
|
||||
enableWarmup: boolean;
|
||||
/** 預調用最低信心度閾值 */
|
||||
warmupConfidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 預設配置
|
||||
*/
|
||||
const DEFAULT_CONFIG: InferenceServiceConfig = {
|
||||
formatConfidenceThreshold: 0.4,
|
||||
engineConfidenceThreshold: 0.5,
|
||||
enableWarmup: true,
|
||||
warmupConfidenceThreshold: 0.7,
|
||||
};
|
||||
|
||||
/**
|
||||
* 自動格式推斷服務類
|
||||
*/
|
||||
export class InferenceService {
|
||||
private config: InferenceServiceConfig;
|
||||
private formatModel: FormatPredictionModel;
|
||||
private engineModel: EnginePredictionModel;
|
||||
private globalStats: FormatConversionStats | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: Partial<InferenceServiceConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.formatModel = formatPredictionModel;
|
||||
this.engineModel = enginePredictionModel;
|
||||
|
||||
// 設定模型閾值
|
||||
this.formatModel.setMinConfidenceThreshold(this.config.formatConfidenceThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服務
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
// 初始化行為資料表
|
||||
initBehaviorTables();
|
||||
|
||||
// 載入全域統計
|
||||
this.globalStats = calculateGlobalStats();
|
||||
|
||||
// 更新格式模型的流行度
|
||||
if (this.globalStats?.format_popularity) {
|
||||
this.formatModel.updateGlobalPopularity(this.globalStats.format_popularity);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log("✅ Inference service initialized");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize inference service:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據檔案路徑進行推斷
|
||||
*/
|
||||
async inferFromFile(
|
||||
filePath: string,
|
||||
userId: number,
|
||||
availableEngines?: string[],
|
||||
): Promise<InferenceResult> {
|
||||
// 確保已初始化
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// 提取檔案特徵
|
||||
const features = await extractFeatures(filePath);
|
||||
|
||||
return this.inferFromFeatures(features, userId, availableEngines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據副檔名快速推斷 (用於前端即時推斷)
|
||||
*/
|
||||
async inferFromExtension(
|
||||
ext: string,
|
||||
userId: number,
|
||||
fileSizeKb?: number,
|
||||
availableEngines?: string[],
|
||||
): Promise<InferenceResult> {
|
||||
// 確保已初始化
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// 從副檔名提取基礎特徵
|
||||
const baseFeatures = extractFeaturesFromExtension(ext);
|
||||
const features: FileFeatures = {
|
||||
...baseFeatures,
|
||||
file_size_kb: fileSizeKb ?? 0,
|
||||
};
|
||||
|
||||
return this.inferFromFeatures(features, userId, availableEngines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據特徵進行推斷
|
||||
*/
|
||||
private async inferFromFeatures(
|
||||
features: FileFeatures,
|
||||
userId: number,
|
||||
availableEngines?: string[],
|
||||
): Promise<InferenceResult> {
|
||||
// 取得使用者 Profile
|
||||
const userProfile = getUserProfile(userId);
|
||||
|
||||
// 格式推斷
|
||||
const formatPrediction = this.formatModel.predict(
|
||||
features,
|
||||
userProfile,
|
||||
this.globalStats ?? undefined,
|
||||
);
|
||||
|
||||
// 引擎推斷 (如果有格式預測)
|
||||
let enginePrediction: EnginePrediction | null = null;
|
||||
if (formatPrediction) {
|
||||
enginePrediction = this.engineModel.predict(
|
||||
formatPrediction.search_format,
|
||||
features,
|
||||
userProfile,
|
||||
availableEngines,
|
||||
);
|
||||
}
|
||||
|
||||
// 決定是否自動填入
|
||||
const shouldAutoFill =
|
||||
formatPrediction !== null &&
|
||||
formatPrediction.confidence >= this.config.formatConfidenceThreshold;
|
||||
|
||||
// 預調用處理
|
||||
let warmupStatus: WarmupStatus | null = null;
|
||||
if (
|
||||
this.config.enableWarmup &&
|
||||
enginePrediction?.should_warmup &&
|
||||
enginePrediction.confidence >= this.config.warmupConfidenceThreshold
|
||||
) {
|
||||
// 啟動預調用 (非阻塞)
|
||||
engineWarmupManager.warmup(enginePrediction.engine).catch(console.error);
|
||||
warmupStatus = engineWarmupManager.getStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
format: formatPrediction,
|
||||
engine: enginePrediction,
|
||||
features,
|
||||
should_auto_fill: shouldAutoFill,
|
||||
warmup_status: warmupStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消預調用 (當使用者行為與預測不一致時呼叫)
|
||||
*/
|
||||
cancelWarmup(): void {
|
||||
engineWarmupManager.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄轉檔完成
|
||||
*/
|
||||
logConversion(params: {
|
||||
userId: number;
|
||||
inputExt: string;
|
||||
searchedFormat: string;
|
||||
selectedEngine: string;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
fileSizeKb?: number;
|
||||
megapixels?: number;
|
||||
}): void {
|
||||
const eventData: Parameters<typeof logConversionEvent>[0] = {
|
||||
user_id: params.userId,
|
||||
input_ext: params.inputExt,
|
||||
searched_format: params.searchedFormat,
|
||||
selected_engine: params.selectedEngine,
|
||||
success: params.success,
|
||||
duration_ms: params.durationMs,
|
||||
};
|
||||
|
||||
if (params.fileSizeKb !== undefined) {
|
||||
eventData.file_size_kb = params.fileSizeKb;
|
||||
}
|
||||
if (params.megapixels !== undefined) {
|
||||
eventData.megapixels = params.megapixels;
|
||||
}
|
||||
|
||||
logConversionEvent(eventData);
|
||||
|
||||
// 定期更新全域統計 (每 100 次轉檔)
|
||||
this.maybeRefreshGlobalStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄推薦被拒絕
|
||||
*/
|
||||
logDismiss(params: {
|
||||
userId: number;
|
||||
inputExt: string;
|
||||
dismissedFormat: string;
|
||||
dismissedEngine?: string;
|
||||
}): void {
|
||||
const eventData: Parameters<typeof logDismissEvent>[0] = {
|
||||
user_id: params.userId,
|
||||
input_ext: params.inputExt,
|
||||
dismissed_format: params.dismissedFormat,
|
||||
};
|
||||
|
||||
if (params.dismissedEngine !== undefined) {
|
||||
eventData.dismissed_engine = params.dismissedEngine;
|
||||
}
|
||||
|
||||
logDismissEvent(eventData);
|
||||
|
||||
// 取消預調用
|
||||
this.cancelWarmup();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得使用者 Profile
|
||||
*/
|
||||
getUserProfile(userId: number): UserProfile | null {
|
||||
return getUserProfile(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期刷新全域統計
|
||||
*/
|
||||
private refreshCounter = 0;
|
||||
private maybeRefreshGlobalStats(): void {
|
||||
this.refreshCounter++;
|
||||
if (this.refreshCounter >= 100) {
|
||||
this.refreshCounter = 0;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.globalStats = calculateGlobalStats();
|
||||
if (this.globalStats?.format_popularity) {
|
||||
this.formatModel.updateGlobalPopularity(this.globalStats.format_popularity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh global stats:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理過期資料
|
||||
*/
|
||||
cleanup(daysToKeep = 90): number {
|
||||
return cleanupOldEvents(daysToKeep);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(config: Partial<InferenceServiceConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
this.formatModel.setMinConfidenceThreshold(this.config.formatConfidenceThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查引擎是否已預調用完成
|
||||
*/
|
||||
isEngineReady(engine: string): boolean {
|
||||
return engineWarmupManager.isReady(engine);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得預調用狀態
|
||||
*/
|
||||
getWarmupStatus(): WarmupStatus | null {
|
||||
return engineWarmupManager.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// 導出單例
|
||||
export const inferenceService = new InferenceService();
|
||||
|
|
@ -8,6 +8,7 @@ import { Jobs } from "../db/types";
|
|||
import { WEBROOT } from "../helpers/env";
|
||||
import { normalizeFiletype } from "../helpers/normalizeFiletype";
|
||||
import { userService } from "./user";
|
||||
import { inferenceService } from "../inference";
|
||||
|
||||
export const convert = new Elysia().use(userService).post(
|
||||
"/convert",
|
||||
|
|
@ -67,6 +68,12 @@ export const convert = new Elysia().use(userService).post(
|
|||
);
|
||||
|
||||
// Start the conversion process in the background
|
||||
// 記錄轉檔開始時間
|
||||
const conversionStartTime = Date.now();
|
||||
|
||||
// 取得輸入檔案的副檔名 (從第一個檔案)
|
||||
const inputExt = fileNames[0]?.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
handleConvert(fileNames, userUploadsDir, userOutputDir, convertTo, converterName, jobId)
|
||||
.then(() => {
|
||||
// All conversions are done, update the job status to 'completed'
|
||||
|
|
@ -74,11 +81,41 @@ export const convert = new Elysia().use(userService).post(
|
|||
db.query("UPDATE jobs SET status = 'completed' WHERE id = ?1").run(jobId.value);
|
||||
}
|
||||
|
||||
// 記錄轉檔行為 (用於推斷學習)
|
||||
try {
|
||||
const durationMs = Date.now() - conversionStartTime;
|
||||
inferenceService.logConversion({
|
||||
userId: parseInt(user.id, 10),
|
||||
inputExt: inputExt,
|
||||
searchedFormat: convertTo,
|
||||
selectedEngine: converterName,
|
||||
success: true,
|
||||
durationMs: durationMs,
|
||||
});
|
||||
} catch (logError) {
|
||||
console.warn("Failed to log conversion event:", logError);
|
||||
}
|
||||
|
||||
// Delete all uploaded files in userUploadsDir
|
||||
// rmSync(userUploadsDir, { recursive: true, force: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error in conversion process:", error);
|
||||
|
||||
// 記錄失敗的轉檔
|
||||
try {
|
||||
const durationMs = Date.now() - conversionStartTime;
|
||||
inferenceService.logConversion({
|
||||
userId: parseInt(user.id, 10),
|
||||
inputExt: inputExt,
|
||||
searchedFormat: convertTo,
|
||||
selectedEngine: converterName,
|
||||
success: false,
|
||||
durationMs: durationMs,
|
||||
});
|
||||
} catch (logError) {
|
||||
console.warn("Failed to log conversion event:", logError);
|
||||
}
|
||||
});
|
||||
|
||||
// Redirect the client immediately
|
||||
|
|
|
|||
139
src/pages/inference.tsx
Normal file
139
src/pages/inference.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* 推斷 API 端點
|
||||
*
|
||||
* 提供前端呼叫的推斷 API
|
||||
*/
|
||||
|
||||
import { Elysia, t } from "elysia";
|
||||
import { inferenceService } from "../inference";
|
||||
|
||||
export const inferenceApi = new Elysia({ prefix: "/inference" })
|
||||
/**
|
||||
* 根據副檔名推斷最可能的目標格式和引擎
|
||||
*/
|
||||
.post(
|
||||
"/predict",
|
||||
async ({ body, cookie: { user_id } }) => {
|
||||
const userId = parseInt(String(user_id?.value ?? "0"), 10);
|
||||
|
||||
try {
|
||||
const result = await inferenceService.inferFromExtension(
|
||||
body.ext,
|
||||
userId,
|
||||
body.file_size_kb,
|
||||
body.available_engines,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
format: result.format,
|
||||
engine: result.engine,
|
||||
should_auto_fill: result.should_auto_fill,
|
||||
warmup_status: result.warmup_status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Inference prediction error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to predict format",
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
ext: t.String({ description: "輸入檔案副檔名" }),
|
||||
file_size_kb: t.Optional(t.Number({ description: "檔案大小 (KB)" })),
|
||||
available_engines: t.Optional(t.Array(t.String(), { description: "可用引擎列表" })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 記錄推薦被拒絕事件
|
||||
*/
|
||||
.post(
|
||||
"/dismiss",
|
||||
async ({ body, cookie: { user_id } }) => {
|
||||
const userId = parseInt(String(user_id?.value ?? "0"), 10);
|
||||
|
||||
try {
|
||||
const dismissParams: {
|
||||
userId: number;
|
||||
inputExt: string;
|
||||
dismissedFormat: string;
|
||||
dismissedEngine?: string;
|
||||
} = {
|
||||
userId,
|
||||
inputExt: body.input_ext,
|
||||
dismissedFormat: body.dismissed_format,
|
||||
};
|
||||
|
||||
if (body.dismissed_engine !== undefined) {
|
||||
dismissParams.dismissedEngine = body.dismissed_engine;
|
||||
}
|
||||
|
||||
inferenceService.logDismiss(dismissParams);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to log dismiss event:", error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
input_ext: t.String({ description: "輸入檔案副檔名" }),
|
||||
dismissed_format: t.String({ description: "被拒絕的推薦格式" }),
|
||||
dismissed_engine: t.Optional(t.String({ description: "被拒絕的推薦引擎" })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 取消預調用
|
||||
*/
|
||||
.post("/cancel-warmup", () => {
|
||||
try {
|
||||
inferenceService.cancelWarmup();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel warmup:", error);
|
||||
return { success: false };
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 取得預調用狀態
|
||||
*/
|
||||
.get("/warmup-status", () => {
|
||||
try {
|
||||
const status = inferenceService.getWarmupStatus();
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get warmup status:", error);
|
||||
return { success: false, data: null };
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 取得使用者 Profile
|
||||
*/
|
||||
.get("/profile", ({ cookie: { user_id } }) => {
|
||||
const userId = parseInt(String(user_id?.value ?? "0"), 10);
|
||||
|
||||
try {
|
||||
const profile = inferenceService.getUserProfile(userId);
|
||||
return {
|
||||
success: true,
|
||||
data: profile,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get user profile:", error);
|
||||
return { success: false, data: null };
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue