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:
Your Name 2026-01-26 12:47:16 +08:00
parent 1173d505f7
commit 6d948b0873
18 changed files with 3710 additions and 14 deletions

View 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,
};
}