UNPKG

@zhangziheng/frame-sense

Version:

一个图像识别模型的命令行工具,用于为视频和图片文件生成语义化文件名。

1,718 lines (1,703 loc) 131 kB
#!/usr/bin/env node var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/utils/file-utils.ts import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync } from "fs"; import { tmpdir } from "os"; import { basename, dirname, extname, join, resolve } from "path"; var FileUtils; var init_file_utils = __esm({ "src/utils/file-utils.ts"() { "use strict"; FileUtils = class _FileUtils { /** 支持的图像格式 */ static IMAGE_FORMATS = [ "jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg" ]; /** 支持的视频格式 */ static VIDEO_FORMATS = [ "mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "m4v", "3gp" ]; /** 最大文件名长度 */ static MAX_FILENAME_LENGTH = 255; /** 非法文件名字符正则表达式 */ static INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g; /** * 检查文件是否存在 * @param filePath - 文件路径 * @returns 是否存在 */ static fileExists(filePath) { try { return existsSync(filePath); } catch { return false; } } /** * 检查路径是否为目录 * @param dirPath - 目录路径 * @returns 是否为目录 */ static isDirectory(dirPath) { try { if (!_FileUtils.fileExists(dirPath)) { return false; } return statSync(dirPath).isDirectory(); } catch { return false; } } /** * 获取文件信息 * @param filePath - 文件路径 * @returns 文件信息或 null */ static getFileInfo(filePath) { try { if (!_FileUtils.fileExists(filePath)) { return null; } const stats = statSync(filePath); const name = basename(filePath); const extension = extname(filePath).toLowerCase().slice(1); const type = _FileUtils.getFileType(extension); if (!type) { return null; } return { path: resolve(filePath), name, extension, size: stats.size, type, createdAt: stats.birthtime, modifiedAt: stats.mtime }; } catch { return null; } } /** * 根据扩展名获取文件类型 * @param extension - 文件扩展名 * @returns 文件类型或 null */ static getFileType(extension) { const ext = extension.toLowerCase(); if (_FileUtils.IMAGE_FORMATS.includes(ext)) { return "image"; } if (_FileUtils.VIDEO_FORMATS.includes(ext)) { return "video"; } return null; } /** * 检查是否为图像文件 * @param filePath - 文件路径 * @returns 是否为图像文件 */ static isImageFile(filePath) { const extension = extname(filePath).toLowerCase().slice(1); return _FileUtils.IMAGE_FORMATS.includes(extension); } /** * 检查是否为视频文件 * @param filePath - 文件路径 * @returns 是否为视频文件 */ static isVideoFile(filePath) { const extension = extname(filePath).toLowerCase().slice(1); return _FileUtils.VIDEO_FORMATS.includes(extension); } /** * 检查是否为媒体文件 * @param filePath - 文件路径 * @returns 是否为媒体文件 */ static isMediaFile(filePath) { return _FileUtils.isImageFile(filePath) || _FileUtils.isVideoFile(filePath); } /** * 获取目录中的所有媒体文件 * @param dirPath - 目录路径 * @param recursive - 是否递归搜索(默认不递归) * @returns 媒体文件信息列表 */ static getMediaFiles(dirPath, recursive = false) { const mediaFiles = []; try { if (!_FileUtils.fileExists(dirPath)) { return mediaFiles; } const files = readdirSync(dirPath); for (const file of files) { const filePath = join(dirPath, file); const stats = statSync(filePath); if (stats.isDirectory() && recursive) { mediaFiles.push(..._FileUtils.getMediaFiles(filePath, true)); } else if (stats.isFile() && _FileUtils.isMediaFile(filePath)) { const fileInfo = _FileUtils.getFileInfo(filePath); if (fileInfo) { mediaFiles.push(fileInfo); } } } } catch (error) { console.error(`\u8BFB\u53D6\u76EE\u5F55\u5931\u8D25 ${dirPath}:`, error); } return mediaFiles; } /** * 清理文件名,移除非法字符 * @param filename - 原始文件名 * @returns 清理后的文件名 */ static sanitizeFilename(filename) { let sanitized = filename.replace(_FileUtils.INVALID_FILENAME_CHARS, "_").replace(/\s+/g, "_").replace(/_{2,}/g, "_").replace(/^_|_$/g, "").trim(); if (sanitized.length > _FileUtils.MAX_FILENAME_LENGTH) { sanitized = sanitized.substring(0, _FileUtils.MAX_FILENAME_LENGTH); } if (!sanitized) { sanitized = "unnamed"; } return sanitized; } /** * 生成唯一的文件名,避免冲突 * @param dirPath - 目录路径 * @param filename - 原始文件名 * @param extension - 文件扩展名 * @returns 唯一的文件名 */ static generateUniqueFilename(dirPath, filename, extension) { const sanitizedName = _FileUtils.sanitizeFilename(filename); let uniqueName = sanitizedName; let counter = 1; while (_FileUtils.fileExists(join(dirPath, `${uniqueName}.${extension}`))) { uniqueName = `${sanitizedName}_${counter}`; counter++; } return uniqueName; } /** * 安全地复制文件 * @param sourcePath - 源文件路径 * @param targetPath - 目标文件路径 * @returns 是否成功 */ static copyFile(sourcePath, targetPath) { try { if (!_FileUtils.fileExists(sourcePath)) { return false; } const targetDir = dirname(targetPath); if (!_FileUtils.fileExists(targetDir)) { mkdirSync(targetDir, { recursive: true }); } if (_FileUtils.fileExists(targetPath)) { const dir = dirname(targetPath); const name = basename(targetPath, extname(targetPath)); const ext = extname(targetPath).slice(1); const uniqueName = _FileUtils.generateUniqueFilename(dir, name, ext); targetPath = join(dir, `${uniqueName}.${ext}`); } copyFileSync(sourcePath, targetPath); return true; } catch (error) { console.error(`\u590D\u5236\u6587\u4EF6\u5931\u8D25 ${sourcePath} -> ${targetPath}:`, error); return false; } } /** * 安全地重命名文件 * @param oldPath - 原始文件路径 * @param newPath - 新文件路径 * @returns 是否成功 */ static renameFile(oldPath, newPath) { try { if (!_FileUtils.fileExists(oldPath)) { return false; } const targetDir = dirname(newPath); if (!_FileUtils.fileExists(targetDir)) { mkdirSync(targetDir, { recursive: true }); } if (_FileUtils.fileExists(newPath)) { const dir = dirname(newPath); const name = basename(newPath, extname(newPath)); const ext = extname(newPath).slice(1); const uniqueName = _FileUtils.generateUniqueFilename(dir, name, ext); newPath = join(dir, `${uniqueName}.${ext}`); } renameSync(oldPath, newPath); return true; } catch (error) { console.error(`\u91CD\u547D\u540D\u6587\u4EF6\u5931\u8D25 ${oldPath} -> ${newPath}:`, error); return false; } } /** * 获取临时目录路径 * @param subDir - 子目录名称 * @returns 临时目录路径 */ static getTempDir(subDir = "frame-sense") { const tempPath = join(tmpdir(), subDir); try { if (!_FileUtils.fileExists(tempPath)) { mkdirSync(tempPath, { recursive: true }); } } catch (error) { console.error(`\u521B\u5EFA\u4E34\u65F6\u76EE\u5F55\u5931\u8D25 ${tempPath}:`, error); } return tempPath; } /** * 计算 Base64 编码后的字节大小 * @param input * @returns */ static base64EncodedSize(input) { const buffer = Buffer.from(input); const bytes = Math.ceil(buffer.length / 3) * 4; return bytes; } /** * 格式化文件大小 * @param bytes - 字节数 * @returns 格式化后的大小字符串 */ static formatFileSize(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${Math.round(bytes / k ** i * 100) / 100} ${sizes[i]}`; } /** * 获取支持的格式信息 * @returns 支持的格式列表 */ static getSupportedFormats() { return { images: [..._FileUtils.IMAGE_FORMATS], videos: [..._FileUtils.VIDEO_FORMATS] }; } /** * 获取文件扩展名(不包含点) * @param filePath - 文件路径 * @returns 文件扩展名 */ static getFileExtension(filePath) { return extname(filePath).toLowerCase().slice(1); } /** * 获取不带扩展名的文件名 * @param filePath - 文件路径 * @returns 不带扩展名的文件名 */ static getFileNameWithoutExtension(filePath) { return basename(filePath, extname(filePath)); } }; } }); // src/utils/prompt-utils.ts import inquirer from "inquirer"; async function safePrompt(questions, options) { const { exitMessage = "\u2717 \u64CD\u4F5C\u5DF2\u53D6\u6D88", exitCode = 0 } = options || {}; try { return await inquirer.prompt(questions); } catch (error) { if (error instanceof Error && error.name === "ExitPromptError") { console.log(` ${exitMessage}`); process.exit(exitCode); } throw error; } } var init_prompt_utils = __esm({ "src/utils/prompt-utils.ts"() { "use strict"; } }); // src/core/config.ts var config_exports = {}; __export(config_exports, { ConfigManager: () => ConfigManager, getConfigManager: () => getConfigManager, interactiveConfig: () => interactiveConfig, selectFrameExtractionStrategy: () => selectFrameExtractionStrategy }); import Conf from "conf"; function getConfigManager() { if (!configManager) { configManager = new ConfigManager(); } return configManager; } async function interactiveConfig(options) { const manager = getConfigManager(); try { if (options.api) { manager.setApiKey(options.api); } if (options.batchSize && options.batchSize > 0) { manager.setBatchProcessingConfig({ batchSize: options.batchSize }); } if (options.verbose !== void 0) { manager.setVerboseMode(options.verbose); } if (options.resetPrompt) { manager.resetPromptConfig(); } if (options.filenameLength !== void 0) { manager.setPromptConfig({ filenameLength: options.filenameLength }); } if (options.customPrompt !== void 0) { manager.setPromptConfig({ customTemplate: options.customPrompt }); } if (options.template !== void 0) { manager.setFilenameTemplateConfig({ template: options.template }); } if (options.dateSource !== void 0) { const sources = options.dateSource.split(",").map((s) => s.trim()); const validSources = sources.filter( (s) => ["exif", "created", "modified"].includes(s) ); if (validSources.length > 0) { manager.setFilenameTemplateConfig({ dateSource: validSources }); } } const validation = manager.validateConfig(); if (!validation.valid) { console.error("\u914D\u7F6E\u9A8C\u8BC1\u5931\u8D25:", validation.errors); return false; } return true; } catch (error) { console.error("\u914D\u7F6E\u8BBE\u7F6E\u5931\u8D25:", error); return false; } } async function selectFrameExtractionStrategy() { const manager = getConfigManager(); const currentConfig = manager.getConfig(); console.log("\n\u{1F3AF} \u5E27\u63D0\u53D6\u7B56\u7565\u9009\u62E9\n"); const answer = await safePrompt([ { type: "list", name: "strategy", message: "\u8BF7\u9009\u62E9\u5E27\u63D0\u53D6\u7B56\u7565:", choices: [ { name: "\u5355\u5E27\u63D0\u53D6 (single) - \u63D0\u53D6\u89C6\u9891\u4E2D\u95F4\u4E00\u5E27\uFF0C\u901F\u5EA6\u6700\u5FEB", value: "single" }, { name: "\u591A\u5E27\u63D0\u53D6 (multiple) - \u63D0\u53D6\u591A\u4E2A\u5747\u5300\u5206\u5E03\u7684\u5E27\uFF0C\u5206\u6790\u66F4\u5168\u9762", value: "multiple" }, { name: "\u5173\u952E\u5E27\u63D0\u53D6 (keyframes) - \u63D0\u53D6\u89C6\u9891\u5173\u952E\u5E27\uFF0C\u8D28\u91CF\u6700\u9AD8", value: "keyframes" } ], default: currentConfig.frameExtractionStrategy } ]); return answer?.strategy || null; } var ConfigManager, configManager; var init_config = __esm({ "src/core/config.ts"() { "use strict"; init_file_utils(); init_prompt_utils(); ConfigManager = class { /** 配置存储实例 */ conf; /** 当前配置 */ currentConfig; constructor() { this.conf = new Conf({ projectName: "frame-sense", configName: "frame-sense", defaults: this.getDefaultConfig() }); this.currentConfig = this.loadConfig(); } /** * 获取默认配置 * @returns 默认配置对象 */ getDefaultConfig() { return { api: "", defaultModel: "gemini-2.5-flash", imageProcessing: { quality: 75, maxWidth: 1280, maxHeight: 720, keepAspectRatio: true, format: "jpeg" }, batchProcessing: { batchSize: 40, maxTokens: 1e6, parallel: false, maxConcurrency: 3 }, promptConfig: { filenameLength: 20, customTemplate: void 0, filenameTemplate: { template: void 0, dateSource: ["exif", "created", "modified"] } }, frameExtractionStrategy: "single", tempDirectory: FileUtils.getTempDir() }; } /** * 加载配置 * 优先级:环境变量 > 配置文件 > 默认值 * @returns 配置对象 */ loadConfig() { const config2 = { ...this.getDefaultConfig() }; const storedConfig = this.conf.store; Object.assign(config2, storedConfig); if (process.env.FRAME_SENSE_API_KEY) { config2.api = process.env.FRAME_SENSE_API_KEY; } if (process.env.FRAME_SENSE_MODEL) { config2.defaultModel = process.env.FRAME_SENSE_MODEL; } if (process.env.FRAME_SENSE_BATCH_SIZE) { const batchSize = parseInt(process.env.FRAME_SENSE_BATCH_SIZE, 10); if (!Number.isNaN(batchSize) && batchSize > 0) { config2.batchProcessing.batchSize = batchSize; } } if (process.env.FRAME_SENSE_VERBOSE === "true") { config2.verbose = true; } return config2; } /** * 获取配置值 * @param key - 配置键 * @returns 配置值 */ get(key) { return this.currentConfig[key]; } /** * 设置配置值 * @param key - 配置键 * @param value - 配置值 */ set(key, value) { this.currentConfig[key] = value; this.conf.set(key, value); } /** * 获取完整配置对象 * @returns 配置对象 */ getConfig() { return { ...this.currentConfig }; } /** * 设置多个配置项 * @param config - 配置对象 */ setConfig(config2) { Object.assign(this.currentConfig, config2); for (const [key, value] of Object.entries(config2)) { this.conf.set(key, value); } } /** * 重置配置到默认值 */ resetConfig() { this.currentConfig = this.getDefaultConfig(); this.conf.clear(); } /** * 检查配置是否有效 * @returns 配置验证结果 */ validateConfig() { const errors = []; if (!this.currentConfig.api || this.currentConfig.api.trim() === "") { errors.push("API Key \u672A\u914D\u7F6E"); } if (!this.currentConfig.defaultModel || this.currentConfig.defaultModel.trim() === "") { errors.push("\u9ED8\u8BA4\u6A21\u578B\u540D\u79F0\u672A\u914D\u7F6E"); } const imageConfig = this.currentConfig.imageProcessing; if (imageConfig.quality < 1 || imageConfig.quality > 100) { errors.push("\u56FE\u50CF\u8D28\u91CF\u5FC5\u987B\u5728 1-100 \u4E4B\u95F4"); } if (imageConfig.maxWidth < 1 || imageConfig.maxHeight < 1) { errors.push("\u56FE\u50CF\u5C3A\u5BF8\u5FC5\u987B\u5927\u4E8E 0"); } const batchConfig = this.currentConfig.batchProcessing; if (batchConfig.batchSize < 1) { errors.push("\u6279\u91CF\u5904\u7406\u5927\u5C0F\u5FC5\u987B\u5927\u4E8E 0"); } if (batchConfig.maxTokens < 1) { errors.push("\u6700\u5927 Token \u6570\u91CF\u5FC5\u987B\u5927\u4E8E 0"); } if (batchConfig.maxConcurrency < 1) { errors.push("\u6700\u5927\u5E76\u53D1\u6570\u5FC5\u987B\u5927\u4E8E 0"); } const promptConfig = this.currentConfig.promptConfig; if (promptConfig.filenameLength < 1) { errors.push("\u6587\u4EF6\u540D\u957F\u5EA6\u5FC5\u987B\u5927\u4E8E 0"); } if (promptConfig.filenameLength > 100) { errors.push("\u6587\u4EF6\u540D\u957F\u5EA6\u4E0D\u80FD\u8D85\u8FC7 100 \u4E2A\u5B57\u7B26"); } if (promptConfig.customTemplate) { const restrictedPatterns = [ /请按照以下JSON格式返回结果/, /\{\s*"results"\s*:\s*\[/, /确保为每个图像都提供一个结果/, /结果数量必须与图像数量一致/ ]; for (const pattern of restrictedPatterns) { if (pattern.test(promptConfig.customTemplate)) { errors.push( "\u81EA\u5B9A\u4E49\u6A21\u677F\u4E0D\u80FD\u5305\u542B JSON \u683C\u5F0F\u8981\u6C42\u90E8\u5206\uFF0C\u8BE5\u90E8\u5206\u7531\u7CFB\u7EDF\u81EA\u52A8\u6DFB\u52A0" ); break; } } } return { valid: errors.length === 0, errors }; } /** * 获取配置文件路径 * @returns 配置文件路径 */ getConfigPath() { return this.conf.path; } /** * 导出配置到文件 * @param filePath - 导出文件路径 * @returns 是否成功 */ exportConfig(filePath) { try { const config2 = this.getConfig(); const configJson = JSON.stringify(config2, null, 2); const fs = __require("fs"); fs.writeFileSync(filePath, configJson, "utf8"); return true; } catch (error) { console.error("\u5BFC\u51FA\u914D\u7F6E\u5931\u8D25:", error); return false; } } /** * 获取 API Key * @returns API Key */ getApiKey() { return this.currentConfig.api; } /** * 设置 API Key * @param api - API Key */ setApiKey(api) { this.set("api", api); } /** * 获取图像处理配置 * @returns 图像处理配置 */ getImageProcessingConfig() { return { ...this.currentConfig.imageProcessing }; } /** * 设置图像处理配置 * @param config - 图像处理配置 */ setImageProcessingConfig(config2) { this.set("imageProcessing", { ...this.currentConfig.imageProcessing, ...config2 }); } /** * 获取批量处理配置 * @returns 批量处理配置 */ getBatchProcessingConfig() { return { ...this.currentConfig.batchProcessing }; } /** * 设置批量处理配置 * @param config - 批量处理配置 */ setBatchProcessingConfig(config2) { this.set("batchProcessing", { ...this.currentConfig.batchProcessing, ...config2 }); } /** * 获取帧提取策略 * @returns 帧提取策略 */ getFrameExtractionStrategy() { return this.currentConfig.frameExtractionStrategy; } /** * 设置帧提取策略 * @param strategy - 帧提取策略 */ setFrameExtractionStrategy(strategy) { this.set("frameExtractionStrategy", strategy); } /** * 是否启用详细输出和调试模式 * @returns 是否启用详细输出和调试模式 */ isVerboseMode() { return this.currentConfig.verbose || false; } /** * 设置详细输出和调试模式 * @param verbose - 是否启用详细输出和调试模式 */ setVerboseMode(verbose) { this.currentConfig.verbose = verbose; } /** * 获取临时目录 * @returns 临时目录路径 */ getTempDirectory() { return this.currentConfig.tempDirectory; } /** * 设置临时目录 * @param tempDirectory - 临时目录路径 */ setTempDirectory(tempDirectory) { this.set("tempDirectory", tempDirectory); } /** * 获取 Prompt 配置 * @returns Prompt 配置 */ getPromptConfig() { return { ...this.currentConfig.promptConfig }; } /** * 设置 Prompt 配置 * @param config - Prompt 配置 */ setPromptConfig(config2) { const updatedConfig = { ...config2 }; if (updatedConfig.customTemplate === "") { updatedConfig.customTemplate = void 0; } this.set("promptConfig", { ...this.currentConfig.promptConfig, ...updatedConfig }); } /** * 重置 Prompt 配置到默认值 */ resetPromptConfig() { this.set("promptConfig", { filenameLength: this.getDefaultConfig().promptConfig.filenameLength, customTemplate: void 0, filenameTemplate: { template: void 0, dateSource: ["exif", "created", "modified"] } }); } /** * 获取文件名模板配置 * @returns 文件名模板配置 */ getFilenameTemplateConfig() { const promptConfig = this.getPromptConfig(); const defaultConfig = this.getDefaultConfig(); return promptConfig.filenameTemplate || defaultConfig.promptConfig.filenameTemplate || { template: "", dateSource: ["exif", "created", "modified"] }; } /** * 设置文件名模板配置 * @param config - 文件名模板配置 */ setFilenameTemplateConfig(config2) { const currentPromptConfig = this.getPromptConfig(); const defaultConfig = this.getDefaultConfig(); const currentTemplateConfig = currentPromptConfig.filenameTemplate || defaultConfig.promptConfig.filenameTemplate || { template: "", dateSource: ["exif", "created", "modified"] }; this.setPromptConfig({ ...currentPromptConfig, filenameTemplate: { ...currentTemplateConfig, ...config2 } }); } /** * 检查是否启用了文件名模板 * @returns 是否启用了文件名模板 */ isFilenameTemplateEnabled() { const templateConfig = this.getFilenameTemplateConfig(); return !!templateConfig.template; } }; configManager = null; } }); // src/cli.ts import { readFileSync as readFileSync2 } from "fs"; import { dirname as dirname4, join as join6 } from "path"; import { fileURLToPath } from "url"; import chalk4 from "chalk"; import { Command } from "commander"; import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; // src/core/ai-analyzer.ts init_config(); import { readFileSync } from "fs"; import { GoogleGenAI } from "@google/genai"; // src/core/image-processor.ts init_config(); init_file_utils(); import { unlinkSync } from "fs"; import { join as join2 } from "path"; import sharp from "sharp"; // src/utils/signal-handler.ts import chalk from "chalk"; var SignalHandler = class _SignalHandler { /** 单例实例 */ static instance = null; /** 清理函数列表 */ cleanupFunctions = []; /** 是否正在关闭 */ isShuttingDown = false; /** 构造函数 */ constructor() { this.setupSignalHandlers(); } /** * 获取单例实例 */ static getInstance() { if (!_SignalHandler.instance) { _SignalHandler.instance = new _SignalHandler(); } return _SignalHandler.instance; } /** * 设置信号处理器 */ setupSignalHandlers() { process.on("SIGINT", () => { this.handleShutdown("SIGINT"); }); process.on("SIGTERM", () => { this.handleShutdown("SIGTERM"); }); process.on("uncaughtException", (error) => { console.error(chalk.red("\u672A\u6355\u83B7\u7684\u5F02\u5E38:"), error); this.handleShutdown("uncaughtException"); }); process.on("unhandledRejection", (reason, _promise) => { console.error(chalk.red("\u672A\u5904\u7406\u7684 Promise \u62D2\u7EDD:"), reason); this.handleShutdown("unhandledRejection"); }); } /** * 添加清理函数 */ addCleanupFunction(fn) { this.cleanupFunctions.push(fn); } /** * 移除清理函数 */ removeCleanupFunction(fn) { const index = this.cleanupFunctions.indexOf(fn); if (index > -1) { this.cleanupFunctions.splice(index, 1); } } /** * 处理关闭信号 */ async handleShutdown(_signal) { if (this.isShuttingDown) { process.exit(1); } this.isShuttingDown = true; try { const clearTask = this.cleanupFunctions.map(async (fn) => { try { await fn(); } catch (error) { console.error(chalk.red("\u6E05\u7406\u51FD\u6570\u6267\u884C\u5931\u8D25:"), error); } }); await Promise.all(clearTask); } finally { process.exit(0); } } /** * 手动触发关闭 */ static async shutdown() { await _SignalHandler.getInstance().handleShutdown("manual"); } }; function getSignalHandler() { return SignalHandler.getInstance(); } // src/core/image-processor.ts var ImageProcessor = class { /** 临时文件清理列表 */ tempFiles = []; /** 清理函数 */ cleanupFunction; constructor() { this.cleanupFunction = this.cleanup.bind(this); getSignalHandler().addCleanupFunction(this.cleanupFunction); } /** * 处理单个图像文件 * @param imagePath - 图像文件路径 * @param options - 处理选项 * @returns 处理后的图像路径 */ async processImage(imagePath, options) { if (!FileUtils.fileExists(imagePath)) { throw new Error(`\u56FE\u50CF\u6587\u4EF6\u4E0D\u5B58\u5728: ${imagePath}`); } if (!FileUtils.isImageFile(imagePath)) { throw new Error(`\u4E0D\u662F\u6709\u6548\u7684\u56FE\u50CF\u6587\u4EF6: ${imagePath}`); } const config2 = getConfigManager(); const processOptions = { ...config2.getImageProcessingConfig(), ...options }; const tempDir = FileUtils.getTempDir(); const outputPath = join2( tempDir, `processed_${Date.now()}.${processOptions.format}` ); try { let sharpInstance = sharp(imagePath); const metadata = await sharpInstance.metadata(); const originalWidth = metadata.width || 0; const originalHeight = metadata.height || 0; if (config2.isVerboseMode()) { console.log(`\u5904\u7406\u56FE\u50CF: ${imagePath}`); console.log(`\u539F\u59CB\u5C3A\u5BF8: ${originalWidth}x${originalHeight}`); } const newSize = this.calculateNewSize( originalWidth, originalHeight, processOptions.maxWidth, processOptions.maxHeight, processOptions.keepAspectRatio ); if (newSize.width !== originalWidth || newSize.height !== originalHeight) { sharpInstance = sharpInstance.resize(newSize.width, newSize.height, { fit: processOptions.keepAspectRatio ? "inside" : "fill", withoutEnlargement: true }); if (config2.isVerboseMode()) { console.log(`\u8C03\u6574\u5C3A\u5BF8\u5230: ${newSize.width}x${newSize.height}`); } } switch (processOptions.format) { case "jpeg": case "jpg": sharpInstance = sharpInstance.jpeg({ quality: processOptions.quality, progressive: true }); break; case "png": sharpInstance = sharpInstance.png({ compressionLevel: Math.round( 9 - processOptions.quality / 100 * 9 ) }); break; case "webp": sharpInstance = sharpInstance.webp({ quality: processOptions.quality }); break; default: break; } await sharpInstance.toFile(outputPath); this.tempFiles.push(outputPath); if (config2.isVerboseMode()) { const processedInfo = await sharp(outputPath).metadata(); console.log(`\u5904\u7406\u5B8C\u6210: ${processedInfo.width}x${processedInfo.height}`); } return outputPath; } catch (error) { this.cleanupTempFiles([outputPath]); throw new Error( `\u56FE\u50CF\u5904\u7406\u5931\u8D25: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 批量处理图像文件 * @param imagePaths - 图像文件路径列表 * @param options - 处理选项 * @param onProgress - 进度回调 * @returns 处理后的图像路径列表 */ // public async batchProcessImages( // imagePaths: string[], // options?: Partial<ImageProcessOptions>, // onProgress?: (current: number, total: number, currentFile: string) => void, // ): Promise<string[]> { // const results: string[] = []; // const total = imagePaths.length; // for (let i = 0; i < imagePaths.length; i++) { // const imagePath = imagePaths[i]; // if (onProgress) { // onProgress(i + 1, total, imagePath); // } // try { // // TODO 同步处理 or 并发处理? // const processedPath = await this.processImage(imagePath, options); // results.push(processedPath); // } catch (error) { // console.error(`处理图像文件失败 ${imagePath}:`, error); // // 继续处理其他文件,不中断整个批量处理 // } // } // return results; // } /** * 优化图像以适应 AI 分析 * @param imagePath - 图像文件路径 * @returns 优化后的图像路径 */ async optimizeForAI(imagePath) { const aiOptimizedOptions = { quality: 85, maxWidth: 1024, maxHeight: 1024, keepAspectRatio: true, format: "jpeg" }; return this.processImage(imagePath, aiOptimizedOptions); } /** * 批量优化图像以适应 AI 分析 * @param imagePaths - 图像文件路径列表 * @param onProgress - 进度回调 * @returns 优化后的图像路径列表 */ async batchOptimizeForAI(imagePaths, onProgress) { const results = []; const total = imagePaths.length; for (let i = 0; i < imagePaths.length; i++) { const imagePath = imagePaths[i]; if (onProgress) { onProgress(i + 1, total, imagePath); } try { const optimizedPath = await this.optimizeForAI(imagePath); results.push(optimizedPath); } catch (error) { console.error(`\u4F18\u5316\u56FE\u50CF\u6587\u4EF6\u5931\u8D25 ${imagePath}:`, error); } } return results; } /** * 计算新的图像尺寸 * @param originalWidth - 原始宽度 * @param originalHeight - 原始高度 * @param maxWidth - 最大宽度 * @param maxHeight - 最大高度 * @param keepAspectRatio - 是否保持宽高比 * @returns 新的尺寸 */ calculateNewSize(originalWidth, originalHeight, maxWidth, maxHeight, keepAspectRatio) { if (!keepAspectRatio) { return { width: maxWidth, height: maxHeight }; } if (originalWidth <= maxWidth && originalHeight <= maxHeight) { return { width: originalWidth, height: originalHeight }; } const widthRatio = maxWidth / originalWidth; const heightRatio = maxHeight / originalHeight; const ratio = Math.min(widthRatio, heightRatio); return { width: Math.round(originalWidth * ratio), height: Math.round(originalHeight * ratio) }; } /** * 获取图像信息 * @param imagePath - 图像文件路径 * @returns 图像信息 */ async getImageInfo(imagePath) { try { const metadata = await sharp(imagePath).metadata(); const stats = __require("fs").statSync(imagePath); return { width: metadata.width || 0, height: metadata.height || 0, format: metadata.format || "unknown", size: stats.size, hasAlpha: metadata.hasAlpha || false }; } catch (error) { throw new Error( `\u83B7\u53D6\u56FE\u50CF\u4FE1\u606F\u5931\u8D25: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 验证图像文件 * @param imagePath - 图像文件路径 * @returns 是否为有效图像 */ async validateImage(imagePath) { try { await sharp(imagePath).metadata(); return true; } catch { return false; } } /** * 清理临时文件 * @param filePaths - 要清理的文件路径列表 */ cleanupTempFiles(filePaths) { for (const filePath of filePaths) { try { if (FileUtils.fileExists(filePath)) { unlinkSync(filePath); } } catch (error) { if (getConfigManager().isVerboseMode()) { console.warn(`\u6E05\u7406\u4E34\u65F6\u6587\u4EF6\u5931\u8D25 ${filePath}:`, error); } } } } /** * 清理所有临时文件 */ cleanup() { this.cleanupTempFiles(this.tempFiles); this.tempFiles = []; } /** * 销毁处理器 */ destroy() { getSignalHandler().removeCleanupFunction(this.cleanupFunction); this.cleanup(); } }; // src/core/ai-analyzer.ts init_file_utils(); // src/utils/progress-logger.ts import { EventEmitter } from "events"; import chalk2 from "chalk"; import ora from "ora"; var ProgressLogger = class extends EventEmitter { spinner = null; logBuffer = []; isSpinnerActive = false; verboseMode = false; constructor(verboseMode = false) { super(); this.verboseMode = verboseMode; } /** * 开始进度指示器 */ startProgress(text) { this.stopProgress(); this.spinner = ora({ text, color: "green" }).start(); this.isSpinnerActive = true; } /** * 更新进度 */ updateProgress(text, progress) { if (this.spinner) { let displayText = text; if (progress) { displayText = `${text} (${progress.current}/${progress.total} - ${progress.percentage}%)`; } this.spinner.text = displayText; } if (progress) { this.emit("progress", progress); } } /** * 成功结束进度 */ succeedProgress(text) { if (this.spinner) { this.spinner.succeed(text); this.spinner = null; } this.isSpinnerActive = false; this.flushLogBuffer(); } /** * 失败结束进度 */ failProgress(text) { if (this.spinner) { this.spinner.fail(text); this.spinner = null; } this.isSpinnerActive = false; this.flushLogBuffer(); } /** * 停止进度指示器 */ stopProgress() { if (this.spinner) { this.spinner.stop(); this.spinner = null; } this.isSpinnerActive = false; this.flushLogBuffer(); } /** * 智能日志输出 - 如果有 spinner 运行则缓存,否则直接输出 */ log(level, message) { const logMessage = { level, message, timestamp: /* @__PURE__ */ new Date() }; if (this.isSpinnerActive) { this.logBuffer.push(logMessage); } else { this.outputLog(logMessage); } } /** * 便捷的日志方法 */ info(message) { this.log("info", message); } warn(message) { this.log("warn", message); } error(message) { this.log("error", message); } debug(message) { if (this.verboseMode) { this.log("debug", message); } } /** * 刷新日志缓冲区 */ flushLogBuffer() { if (this.logBuffer.length > 0) { for (const logMessage of this.logBuffer) { this.outputLog(logMessage); } this.logBuffer = []; } } /** * 输出日志到控制台 */ outputLog(logMessage) { const { level, message } = logMessage; switch (level) { case "info": console.log(chalk2.blue("\u2139"), message); break; case "warn": console.log(chalk2.yellow("\u26A0"), message); break; case "error": console.log(chalk2.red("\u2716"), message); break; case "debug": if (this.verboseMode) { console.log(chalk2.gray("\u{1F50D}"), chalk2.gray(message)); } break; } } /** * 临时暂停 spinner 输出一条重要消息 */ pauseAndLog(level, message) { const wasActive = this.isSpinnerActive; const currentText = this.spinner?.text; if (wasActive) { this.spinner?.stop(); } this.outputLog({ level, message, timestamp: /* @__PURE__ */ new Date() }); if (wasActive && currentText) { this.spinner = ora({ text: currentText, color: "cyan" }).start(); } } /** * 批量操作的进度管理 */ createBatchProgress(totalItems, stageName) { let currentItem = 0; return { start: () => { this.startProgress(`${stageName} (0/${totalItems})`); }, next: (itemName) => { currentItem++; const percentage = Math.round(currentItem / totalItems * 100); const details = itemName ? ` - ${itemName}` : ""; this.updateProgress( `${stageName} (${currentItem}/${totalItems})${details}`, { stage: stageName, current: currentItem, total: totalItems, percentage, details: itemName } ); }, succeed: (message) => { this.succeedProgress(message || `${stageName} \u5B8C\u6210`); }, fail: (message) => { this.failProgress(message || `${stageName} \u5931\u8D25`); } }; } /** * 清理资源 */ destroy() { this.stopProgress(); this.removeAllListeners(); } }; var config = (() => { try { const { getConfigManager: getConfigManager2 } = (init_config(), __toCommonJS(config_exports)); return getConfigManager2(); } catch { return { isVerboseMode: () => false }; } })(); var progressLogger = new ProgressLogger(config.isVerboseMode()); // src/core/ai-analyzer.ts var AIAnalyzer = class _AIAnalyzer { /** Google Generative AI 实例 */ genAI; /** 图像处理器实例 */ imageProcessor; /** 默认自定义内容模板 */ static DEFAULT_CUSTOM_CONTENT = ` \u8BF7\u5206\u6790\u8FD9\u4E9B\u56FE\u50CF\u7684\u5185\u5BB9\uFF0C\u5E76\u4E3A\u6BCF\u4E2A\u56FE\u50CF\u751F\u6210\u4E00\u4E2A\u63CF\u8FF0\u6027\u7684\u6587\u4EF6\u540D\u3002 \u8981\u6C42\uFF1A - \u6587\u4EF6\u540D\u957F\u5EA6{{filenameLength}}\u4E2A\u5B57\u7B26\u3002 - \u4F7F\u7528\u4E2D\u6587\u63CF\u8FF0\u4E3B\u8981\u5185\u5BB9 - \u907F\u514D\u4F7F\u7528\u7279\u6B8A\u5B57\u7B26\uFF0C\u53EF\u4EE5\u4F7F\u7528\u4E0B\u5212\u7EBF\u6216\u8FDE\u5B57\u7B26 - \u91CD\u70B9\u7A81\u51FA\u56FE\u50CF\u7684\u4E3B\u8981\u7279\u5F81\u3001\u573A\u666F\u6216\u5BF9\u8C61 - \u5982\u679C\u662F\u4EBA\u7269\u7167\u7247\uFF0C\u63CF\u8FF0\u573A\u666F\u800C\u4E0D\u662F\u5177\u4F53\u4EBA\u7269 - \u5982\u679C\u662F\u98CE\u666F\u7167\u7247\uFF0C\u63CF\u8FF0\u5730\u70B9\u7279\u5F81\u6216\u666F\u89C2\u7C7B\u578B - \u5982\u679C\u662F\u7269\u54C1\u7167\u7247\uFF0C\u63CF\u8FF0\u7269\u54C1\u7C7B\u578B\u548C\u7279\u5F81`; /** 固定的 JSON 格式要求(不可修改) */ static FIXED_JSON_FORMAT = ` \u8BF7\u6309\u7167\u4EE5\u4E0BJSON\u683C\u5F0F\u8FD4\u56DE\u7ED3\u679C\uFF1A { "results": [ { "filename": "\u5EFA\u8BAE\u7684\u6587\u4EF6\u540D", } ] } \u786E\u4FDD\u4E3A\u6BCF\u4E2A\u56FE\u50CF\u90FD\u63D0\u4F9B\u4E00\u4E2A\u7ED3\u679C\uFF0C\u7ED3\u679C\u6570\u91CF\u5FC5\u987B\u4E0E\u56FE\u50CF\u6570\u91CF\u4E00\u81F4\u3002`; constructor() { const config2 = getConfigManager(); const apiKey = config2.getApiKey(); if (!apiKey) { throw new Error("Google Gemini API Key \u672A\u914D\u7F6E"); } this.genAI = new GoogleGenAI({ apiKey }); this.imageProcessor = new ImageProcessor(); } /** * 生成提示词 * @param userPrompt - 用户自定义提示词 * @returns 生成的提示词 */ generatePrompt(userPrompt) { const config2 = getConfigManager(); const promptConfig = config2.getPromptConfig(); if (userPrompt) { return userPrompt; } let customContent; if (promptConfig.customTemplate) { customContent = promptConfig.customTemplate.replace( /\{\{filenameLength\}\}/g, promptConfig.filenameLength.toString() ); } else { customContent = _AIAnalyzer.DEFAULT_CUSTOM_CONTENT.replace( /\{\{filenameLength\}\}/g, promptConfig.filenameLength.toString() ); } return customContent + _AIAnalyzer.FIXED_JSON_FORMAT; } /** * 分析单个图像 * @param imagePath - 图像文件路径 * @param userPrompt - 用户自定义提示词 * @returns 分析结果 */ async analyzeImage(imagePath, userPrompt) { const results = await this.analyzeImages([imagePath], userPrompt); if (results.length === 0) { throw new Error("\u56FE\u50CF\u5206\u6790\u5931\u8D25\uFF0C\u672A\u83B7\u5F97\u7ED3\u679C"); } return results[0]; } /** * 分析多个图像 * @param imagePaths - 图像文件路径列表 * @param userPrompt - 用户自定义提示词 * @returns 分析结果列表 */ async analyzeImages(imagePaths, userPrompt) { if (imagePaths.length === 0) { return []; } const validImages = []; for (const imagePath of imagePaths) { if (FileUtils.fileExists(imagePath) && FileUtils.isImageFile(imagePath)) { validImages.push(imagePath); } else { progressLogger.warn(`\u8DF3\u8FC7\u65E0\u6548\u56FE\u50CF\u6587\u4EF6: ${imagePath}`); } } if (validImages.length === 0) { throw new Error("\u6CA1\u6709\u6709\u6548\u7684\u56FE\u50CF\u6587\u4EF6"); } const optimizedImages = await this.imageProcessor.batchOptimizeForAI(validImages); try { const request = { imagePaths: optimizedImages, userPrompt, parseMultiple: true, requestId: `req_${Date.now()}` }; const results = await this.sendAnalysisRequest(request); return results.map((result, index) => ({ ...result, originalPath: validImages[index] || imagePaths[index] })); } finally { this.imageProcessor.cleanup(); } } /** * 批量分析图像(支持自动分批) * @param imagePaths - 图像文件路径列表 * @param userPrompt - 用户自定义提示词 * @param onProgress - 进度回调 * @returns 批量处理结果 */ async batchAnalyzeImages(imagePaths, userPrompt, onProgress) { const startTime = Date.now(); const config2 = getConfigManager(); const batchConfig = config2.getBatchProcessingConfig(); const batches = this.createBatches(imagePaths, batchConfig.batchSize); const allResults = []; let successfulBatches = 0; let failedBatches = 0; let processedFiles = 0; if (config2.isVerboseMode()) { progressLogger.info(`\u5F00\u59CB\u6279\u91CF\u5206\u6790 ${imagePaths.length} \u4E2A\u56FE\u50CF\u6587\u4EF6`); progressLogger.info( `\u5206\u4E3A ${batches.length} \u6279\uFF0C\u6BCF\u6279\u6700\u591A ${batchConfig.batchSize} \u4E2A\u6587\u4EF6` ); } for (let i = 0; i < batches.length; i++) { const batch = batches[i]; const batchNumber = i + 1; try { if (config2.isVerboseMode()) { progressLogger.info( `\u5904\u7406\u7B2C ${batchNumber}/${batches.length} \u6279\uFF0C\u5305\u542B ${batch.length} \u4E2A\u6587\u4EF6` ); } const batchResults = await this.analyzeImages(batch, userPrompt); allResults.push(...batchResults); successfulBatches++; if (config2.isVerboseMode()) { progressLogger.info( `\u7B2C ${batchNumber} \u6279\u5904\u7406\u5B8C\u6210\uFF0C\u83B7\u5F97 ${batchResults.length} \u4E2A\u7ED3\u679C` ); } } catch (error) { progressLogger.error(`\u7B2C ${batchNumber} \u6279\u5904\u7406\u5931\u8D25: ${error}`); failedBatches++; } processedFiles += batch.length; if (onProgress) { onProgress( processedFiles, imagePaths.length, batchNumber, batches.length ); } } const endTime = Date.now(); const stats = { totalFiles: imagePaths.length, successfulFiles: allResults.length, failedFiles: imagePaths.length - allResults.length, totalProcessingTime: endTime - startTime, batchStats: { totalBatches: batches.length, successfulBatches, failedBatches } }; return { results: allResults, stats }; } /** * 发送分析请求到 AI 服务 * @param request - 分析请求 * @returns 分析结果 */ async sendAnalysisRequest(request) { const config2 = getConfigManager(); const imageParts = []; for (const imagePath of request.imagePaths) { const imageData = readFileSync(imagePath); imageParts.push({ inlineData: { data: imageData.toString("base64"), mimeType: this.getMimeType(imagePath) } }); } const prompt = this.generatePrompt(request.userPrompt); const fullPrompt = `${prompt} \u56FE\u50CF\u6570\u91CF: ${request.imagePaths.length}`; if (config2.isVerboseMode()) { progressLogger.debug( `\u56FE\u50CF base64 \u5927\u5C0F: ${FileUtils.formatFileSize( imageParts.reduce( (sum, p) => sum + FileUtils.base64EncodedSize(p.inlineData.data), 0 ) )}` ); progressLogger.debug(`\u53D1\u9001\u7ED9 AI \u7684\u63D0\u793A\u8BCD: ${fullPrompt}`); } try { const result = await this.genAI.models.generateContent({ model: config2.get("defaultModel"), contents: [fullPrompt, ...imageParts] }); const text = result.text || ""; if (config2.isVerboseMode()) { progressLogger.info( `AI \u4F7F\u7528\u60C5\u51B5: ${JSON.stringify(result.usageMetadata, null, 2)}` ); progressLogger.info(`AI \u54CD\u5E94: ${text}`); } const analysisReponse = this.parseAnalysisResponse( text, request.imagePaths ); return analysisReponse; } catch (error) { throw new Error( `AI \u5206\u6790\u8BF7\u6C42\u5931\u8D25: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 解析 AI 响应 * @param responseText - AI 响应文本 * @param imagePaths - 图像路径列表 * @returns 解析后的结果 */ parseAnalysisResponse(responseText, imagePaths) { try { const cleanedText = responseText.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim(); const fixedJson = cleanedText.replace(/,(\s*})/g, "$1").replace(/,(\s*\])/g, "$1"); const parsed = JSON.parse(fixedJson); const results = parsed.results || []; if (results.length !== imagePaths.length) { progressLogger.warn( `\u7ED3\u679C\u6570\u91CF (${results.length}) \u4E0E\u56FE\u50CF\u6570\u91CF (${imagePaths.length}) \u4E0D\u5339\u914D` ); } const analysisResult = results.map((result, index) => ({ originalPath: imagePaths[index] || "", suggestedName: FileUtils.sanitizeFilename( result.filename || `image_${index + 1}` ), description: result.description || "\u65E0\u63CF\u8FF0", tags: Array.isArray(result.tags) ? result.tags : [], timestamp: Date.now(), filename: result.filename || `image_${index + 1}` })); return analysisResult; } catch (error) { progressLogger.error(`\u89E3\u6790 AI \u54CD\u5E94\u5931\u8D25: ${error}`); return imagePaths.map((imagePath, index) => ({ originalPath: imagePath, suggestedName: `image_${index + 1}`, description: "\u89E3\u6790\u5931\u8D25\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u547D\u540D", tags: [], timestamp: Date.now(), filename: `image_${index + 1}` })); } } /** * 创建批次 * @param items - 要分批的项目 * @param batchSize - 批次大小 * @returns 批次数组 */ createBatches(items, batchSize) { const batches = []; for (let i = 0; i < items.length; i += batchSize) { batches.push(items.slice(i, i + batchSize)); } return batches; } /** * 获取图像的 MIME 类型 * @param imagePath - 图像路径 * @returns MIME 类型 */ getMimeType(imagePath) { const extension = FileUtils.getFileExtension(imagePath); switch (extension) { case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "bmp": return "image/bmp"; case "tiff": return "image/tiff"; case "svg": return "image/svg+xml