版本 v0.2 · 2026-05 · Electron 版本
本文档面向研发工程师,覆盖 Vixel 当前实际形态(v0.2)的完整技术选型理由、系统架构、数据模型、核心模块实现指南与性能特征。
平台范围: macOS first。HEIC 全平台可用(mac 走
sips,Linux/Win 走heic-convertlibheif WASM);视频在全平台均可用(ffmpeg-static)。 RAW (CR2/NEF/ARW/...) 当前仍是 macOS only(sips),Linux/Windows 的 RAW 兜底(libraw-wasm)在 v0.4+ 评估。重大变更(v0.1 → v0.2):
- 移除 Qwen3.5-4B caption 生成(3.4 GB LLM 下载已删除)
- SigLIP 2 base/16-256 ONNX 取代外部 Embedding API 成为默认 provider (2026-10 已被 EmbeddingGemma 2 取代,见下)
- 新增 PaddleOCR v5 全本地 OCR
- 人脸匹配从 O(N) JS 余弦切换到 sqlite-vec ANN
- 新增视频抽帧(ffmpeg-static + 关键帧 → 复用图像流水线)
- FTS5 中文搜索:jieba 分词替代字符级 unicode61
重大变更(2026-10 重构,EmbeddingGemma 2):
- 所有 embedding 任务统一到 EmbeddingGemma 2(文本/图像/音频/视频同一 768 维空间), 删除 SigLIP 2 provider 与外部 Embedding API provider —— 纯本地,无 API 兜底
- 视频从"逐帧当照片"改为 32s 片段语义向量(每 4s 一帧的帧序列 + 音轨 → 单向量), 新表
video_segments+video_segment_vecs- 新增音轨抽取(
src/core/audio/extract.ts,ffmpeg → mono 16 kHz f32le)- 搜索:删除 caption BM25 通道;新增视频片段向量通道;query 只编码一次
- 人脸(MobileFaceNet)与 OCR(PaddleOCR)不是 embedding 任务,保持不变
┌──────────────────────────────────────────────────────────────────────────┐
│ Vixel App (Electron, single Node process) │
│ ┌────────────────┐ ┌────────────────────────────────────────────┐ │
│ │ Renderer │ │ Main Process │ │
│ │ (React) │◄──►│ ┌──────────────────────────────────────┐ │ │
│ └────────────────┘ │ │ Indexer Pipeline │ │ │
│ │ IPC │ │ thumbnail → embed → face → ocr │ │ │
│ ▼ │ │ ↑ │ │ │
│ ┌────────────────┐ │ │ extract_frames (video) │ │ │
│ │ Photo Grid │ │ └──────────────────────────────────────┘ │ │
│ │ Map / People │ │ ┌──────────────────────────────────────┐ │ │
│ │ PhotoDetail │ │ │ ONNX Runtime (onnxruntime-node) │ │ │
│ └────────────────┘ │ │ ┌──────────┐ ┌──────┐ ┌──────────┐ │ │ │
│ │ │ │Embedding-│ │PaddOCR│ │SCRFD + │ │ │ │
│ │ │ │Gemma 2 │ │ det/ │ │MobileFace│ │ │ │
│ │ │ │txt/img/ │ │ rec/ │ │Net │ │ │ │
│ │ │ │aud/video │ │ cls │ │ │ │ │ │
│ │ │ └──────────┘ └──────┘ └──────────┘ │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ ffmpeg-static (subprocess) │ │ │
│ │ │ 32s 片段:8 帧 + 音轨 → 片段向量 │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ better-sqlite3 + sqlite-vec │ │ │
│ │ │ photos | image_vecs(768) │ │ │
│ │ │ video_segments | video_segment_vecs │ │ │
│ │ │ image_ocr+FTS5 (jieba UDF) │ │ │
│ │ │ faces | face_vecs(512) ANN │ │ │
│ │ │ people | videos | meta_state │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ File Watcher (chokidar) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
↑ 零网络请求 ↑ 所有数据本地 ↑ 照片文件原位引用,不复制
↑ 无 Python,无 llama-server,无外部 API(API provider 已删除)
| 层级 | 选型 | 备注 |
|---|---|---|
| 应用框架 | Electron 33+ | 单进程;不再依赖 llama-server / Python 子进程 |
| 构建工具 | electron-vite | 主+预加载+渲染三套 entry |
| UI 框架 | React 18 + TypeScript + Tailwind CSS | — |
| 多模态 Embedding | EmbeddingGemma 2(ONNX,文本/视觉 q4、音频 q8) | 768 维;文本 / 图像 / 音频 / 视频同一空间;多语言 |
| ONNX 运行时 | onnxruntime-node + @huggingface/transformers | CoreML / CPU EP;Transformers.js 负责 tokenizer + processor |
| OCR(图内文字) | PaddleOCR v5(det + cls + rec ONNX) | RapidAI 社区导出;charset 用 ppocr_keys_v1 |
| 人脸检测 | SCRFD-2.5G-KPS(InsightFace ONNX) | ~3 MB |
| 人脸 embedding | MobileFaceNet(InsightFace w600k_mbf,ONNX) | 512 维,L2 归一化 |
| 人脸聚类 | sqlite-vec ANN | 取代 v0.1 的 O(N) JS 余弦扫描 |
| 视频 / 音频抽取 | ffmpeg-static | 32s 片段,每 4s 一帧(8 帧)+ mono 16 kHz 音轨;时长解析 ffmpeg -i stderr |
| 向量存储 | sqlite-vec(vec0 虚表) | image_vecs(768) + video_segment_vecs(768) + face_vecs(512) |
| 元数据存储 | better-sqlite3 + FTS5 | 同进程 sync API |
| 中文分词 | @node-rs/jieba(UDF jiebatok) | FTS5 写入/查询两端对称切词 |
| 文件监听 | chokidar | 图片 + 视频统一 add/change/unlink |
| EXIF 解析 | exifr | 直接读 HEIC/RAW 元数据,不依赖解码 |
| 图片处理 | sharp(libvips) | EXIF auto-rotate;HEIC 走 sips(mac) / heic-convert(Linux/Win);RAW 走 sips(mac only) |
| 文件去重 | xxhash-wasm | photo 内容哈希(dedup 缩略图/embedding) |
| 测试 | vitest | 24 个单测,<1s |
思路:不押宝单个大 LLM 干一切;每个能力配一个专门的小模型,统一通过 onnxruntime-node 跑、统一收口到 SQLite/sqlite-vec。这是 v0.2 取代 v0.1 的 "Qwen3.5-4B 包打天下"路线后留下来的核心原则。
| 维度 | 取值 |
|---|---|
| 模型 ID | onnx-community/embeddinggemma-2-ONNX |
| 规模 | 740M(文本 270M + 视觉 170M + 音频 300M,模块化编码器) |
| 模态 | 文本 / 图像 / 音频 / 视频,统一嵌入空间 |
| 嵌入维度 | 768(Matryoshka 可截断到 512/256/128;当前用满 768) |
| 上下文 | 8K token |
| 默认量化 | 文本 q4、视觉 q4、音频 q8 —— 合计 ~620 MB |
| 量化可调 | 设置页按编码器选 q4 / q8,写入 embedding-config.json |
模型文件(每个 .onnx 都配一个必需的 .onnx_data 外部权重文件):
| 文件 | 内容 | 大小 |
|---|---|---|
onnx/model_q4.onnx |
文本编码器 + 多模态融合(session model) |
~174 MB |
onnx/vision_encoder_q4.onnx |
视觉编码器(图像 + 视频帧) | ~109 MB |
onnx/audio_encoder_quantized.onnx |
音频编码器(q8 → _quantized 后缀) |
~340 MB |
processor_config.json |
video_processor.max_frames = 32,每帧 140 soft token |
— |
| 维度 | SigLIP 2 base(旧) | EmbeddingGemma 2(现) |
|---|---|---|
| 模态 | 文本 + 图像 | 文本 + 图像 + 音频 + 视频 |
| 视频表示 | 每帧一个向量,帧之间互不知情 | 一个片段(帧序列 + 音轨)→ 单向量 |
| 音频 | 无 | 原生音频编码器 |
| 体积 | ~190 MB | ~620 MB |
| 推理成本 | 低 | 更高(待测) |
取舍:体积 ×3、推理更慢,换来的是"视频里听到的内容也能搜"和片段级语义 ("狗在沙滩上叫"这种跨帧 + 跨模态的 query)。产品未上线,无存量向量兼容负担, 所以一次性替换,不保留 SigLIP 2 / 外部 API 双轨。
// src/core/embedding/providers/gemma2Provider.ts(简化)
import { AutoTokenizer, AutoProcessor, AutoModel, RawImage, RawVideo, env }
from '@huggingface/transformers' // ≥ 4.3.1
env.allowRemoteModels = false
env.allowLocalModels = true
env.localModelPath = bundledModelsDir // <Resources>/models
const tokenizer = await AutoTokenizer.from_pretrained('gemma2')
const processor = await AutoProcessor.from_pretrained('gemma2')
// dtype 按 session 名指定;键写错会静默回退到设备默认 dtype
const model = await AutoModel.from_pretrained('gemma2', {
dtype: { model: 'q4', vision_encoder: 'q4', audio_encoder: 'q8' },
})
// 文本 query:必须带任务前缀
const ti = tokenizer('task: query | text: 海边日落', { padding: true, truncation: true })
const { sentence_embedding } = await model(ti) // 768d,已 L2 归一化
// 图像 / 音频 / 视频:processor(text, images, audio, videos)
await model(await processor(null, rawImage)) // 图像
await model(await processor(null, null, samples16k)) // 音频
await model(await processor(null, null, audio, new RawVideo(frames, durationSec))) // 视频片段预热仍在 main 启动后 2s fire-and-forget 触发;冷启动与单次推理耗时待测。
| 子模型 | ONNX 大小 | 用途 |
|---|---|---|
ppocr_v5_det |
~3 MB | 文本框检测(DB / Differentiable Binarization) |
ppocr_v5_cls |
~1 MB | 180° 方向分类(可选) |
ppocr_v5_rec |
~8 MB | CRNN + CTC 文字识别 |
ppocr_keys_v1.txt |
60 KB | 字符表(~6.6k 字符,含中英) |
来源:RapidAI/RapidOCR 社区导出(Apache 2.0),与 PaddleOCR 上游官方 模型一致。
detection.ts → detectTextBoxes()
resize (long side ≤ 960, 32 对齐) → normalize → ONNX
→ sigmoid prob map → 二值化(>0.3) → BFS 连通块
→ AABB 近似最小矩形 → unclip (area*ratio/perimeter)
→ 按 score 排序
cls.ts → detectFlips() (可选)
按框 crop → (48, 192) → softmax 二分类
> 0.9 置信度 → 标记 180° flip
recognition.ts → recognizeBoxes()
按框 crop → resize 到 (48, 动态宽) → 批量 (B, 3, 48, maxW)
→ ONNX → [B, T, C] / [B, C, T] 自适应
→ CTC greedy decode + 去重 + 去 blank
→ 平均字符 prob 作为 line score
index.ts → processPhotoOcr()
det → cls → rec
→ 按 y 中心、x 左缘排序
→ 丢弃 score < 0.5
→ 用换行 join 成 text
输出文本走 image_ocr 表 + FTS5 BM25 (jieba 分词) 索引。
| 组件 | 体积 | 来源 |
|---|---|---|
| SCRFD-2.5G-KPS(检测 + 5 关键点) | ~3 MB | InsightFace |
| MobileFaceNet / w600k_mbf(embedding,512 维) | ~13 MB | InsightFace(权重非商用) |
| 聚类 | (无模型) | 即时 KNN 投票 + 批量质心聚类(face/cluster.ts) |
对齐:解码一次(按 EXIF 摆正、限 2048 边长、统一 3 通道),检测与对齐共用这份 raw;
对齐用 5 点最小二乘相似变换拟合"输出→输入"映射,JS 双线性采样出 112×112(< 1 ms)。
不用 sharp.affine:它要正向矩阵,且 sharp 管线固定先 resize 后 affine——早期实现因此
送进模型的是黑图,所有人 embedding 相似度都是 1.0。FACE_PIPELINE_VERSION 变化时启动会
清空 faces / people 并重扫(只在用户扫过人脸时自动触发)。
质量分(face/quality.ts,不跑额外模型):min(尺寸, 姿态, 清晰度)。尺寸 = 原图人脸短边
24→72 px;姿态 = 鼻尖相对两眼中点在眼线方向的偏移 / 眼距(≈ yaw);清晰度 = 对齐图中央
拉普拉斯方差。质量 < 0.4 的脸不能"开新人物",< 0.12 不参与自动聚类。
归属分两段,原则是宁可漏归、不可错归:
- 入库即时归属(
assignFaceToPerson):高质量脸取 KNN 10,相似度 ≥ T_LINK 的已归属邻居 按相似度加权投票,赢家质心还要 ≥ T_ASSIGN 才归入;否则留给批量。 - 批量聚类(
FaceClusterer,攒 200 张或人脸队列空闲 3 s 后跑):新脸按质量降序和 人物质心 + 本批临时簇比较 → 归入 / 开簇(顺带 KNN 拉回旧落单脸)/ 落单; 然后质心合并(union-find,合并后复核);再用新质心拉一次落单脸;少于 2 张的临时簇解散。 纯 JS,每 64 张让出一次事件循环。
阈值(余弦,LFW 标定:脸↔本人质心 p1 0.57、脸↔他人质心 max 0.26):
| 阈值 | 值 | 用途 |
|---|---|---|
| T_ASSIGN / T_ASSIGN_LOWQ | 0.45 / 0.52 | 脸 → 质心 |
| T_LINK | 0.5 | 脸 ↔ 脸(KNN 投票、拉回落单) |
| T_MERGE / T_MERGE_NAMED | 0.6 / 0.7 | 质心合并:未命名之间 / 并入已命名 |
| T_SUGGEST | 0.4 | ≥ 0.4 且仍是两个人物 → "是同一个人吗?"(驳回一次不再出现) |
用户约束优先于算法:已归属的脸批量聚类不再移动;两个已命名人物永不自动合并;
"不是此人"写入 face_rejections,该脸以后不会再自动归入这个人物,含有这张脸的簇也不会并入;
驳回的合并建议写入 person_dismissed_pairs;隐藏人物仍参与聚类(避免同一人又冒出新人物),只是不展示。
为什么不用 buffalo_l (~166 MB) 的 ArcFace r50:
- MobileFaceNet 在 LFW 上 ~99.5%,buffalo_l ~99.85%;0.3% 收益换 33x 体积, 对个人相册量级(一般 < 50k 张)不划算。
性能曲线(PR5 之前 → 之后):
即时归属的 KNN 走 sqlite-vec(vec0 无法按"未归属"过滤,所以批量里落单脸的 KNN 在内存暴力算):
| 库内人脸数 | JS 暴力 O(N) | sqlite-vec ANN |
|---|---|---|
| 1 k | ~50 ms | < 1 ms |
| 10 k | ~500 ms | ~1 ms |
| 50 k | 多秒掉帧 | ~1 ms |
| 维度 | 配置 |
|---|---|
| 二进制来源 | ffmpeg-static npm(per-platform prebuilt,~80 MB;不带 ffprobe) |
| 时长 | probeDurationMs():解析 ffmpeg -i stderr 的 Duration:,30s 超时 |
| 切片 | 32s 一段(-ss 在 -i 前快速 seek,-t 限长) |
| 帧 | 每 4s 一帧(ffmpeg fps=1/4),8 帧,长边 ≤ 512 px JPEG |
| 音轨 | src/core/audio/extract.ts:ffmpeg → mono 16 kHz f32le |
| 编码 | 帧序列 + 音轨 → encode({ type: 'multimodal', video, audio }) → 单向量;无音轨退化为纯视频 |
| 存储 | video_segments(时间区间)+ video_segment_vecs(vec0, 768d) |
| 编排 | indexer task 类型 extract_frames |
为什么是 32s / 8 帧:processor 上限 32 帧,但 vision encoder 开销随帧数超线性 增长 —— 实测 M 系列 CPU 上 8 帧 ≈ 14s、16 帧 ≈ 43s、32 帧 ≈ 141s(峰值 3.9 GB), 1 fps 对长视频不可用。8 帧 × 140 token + 32s 音频(~800 token)≈ 2K,远低于 8K 上下文。
ONNX session 关闭了 CPU BFCArena(enableCpuMemArena: false):arena 扩容会一次申请
超大对齐块,Electron 的 PartitionAlloc 分配失败直接 SIGTRAP,纯 Node 下不复现。
代表帧:首段首帧落成 segment_0_0ms.jpg,作为一行 photos(video_id,
frame_time_ms = 0)入库,走缩略图 + 图像 embed —— 网格展示、搜索结果落点、
"相似照片"入口都靠它。它在编码前写入,所以模型未就绪时视频也会出现在网格里。
单片段失败(抽帧 / 编码异常)只跳过该片段,不让整个视频失败;失败数记进任务的
error_msg(如 3/86 个片段失败),任务面板可见。全部片段失败才算任务失败。
纯音频复用同一套表和 extract_frames 任务,按 videos.media_kind 分支:
每个 32s 片段只抽音轨 → encode({ type: 'audio' })。代表图由
extractCoverOrWaveform() 生成:先取内嵌封面(-map 0:v? -frames:v 1),
没有则 showwavespic 画波形(accent 色 #d4a574)。代表图只做缩略图、
不做图像 embed —— 波形向量会污染图片搜索。
播放:vixel://media/<videoId> 手写 HTTP Range(206 + Content-Range,
createReadStream({start, end})),不走 net.fetch(file://) —— 后者 Range 不可靠,
<video> seek 依赖它。Chromium 解不了的编码(如 mpeg4 avi)触发 onError,
UI 回退到"在系统播放器中打开"。
悬停拖动预览:抽完片段后 generateSprite()(core/video/sprite.ts)在全片
均匀取 12 个时间点(每格中点),每帧单独 -ss 快速 seek 解一帧,sharp 居中裁成
240px 正方形后横拼成 sprite.jpg。不用 fps=N/duration 一次过 —— 那要解完整部片。
渲染端用 background-size: N×100% + background-position-x 按鼠标横坐标换帧,
帧数由图片宽高比推出。早于此功能索引的视频在首次悬停时由 vixel://sprite/<id>
按需生成(同一视频去重、全局串行);失败则卡片回退为静音播放预览。
| 模型 | 计划用途 | 删除原因 |
|---|---|---|
| Qwen3.5-4B + llama.cpp | 自动 caption | 3.4 GB 下载 + 秒级推理,对"灌进 FTS5 让人能搜到"严重过设计;OCR + 图文 CLIP 双通道已覆盖搜索需求 |
| EmbeddingGemma-300M | 独立文本 embedding | SigLIP 2 文本编码器与图像编码器同空间,query 直接走它就够;多一个文本模型是冗余 |
2026-10 复议:上表第二行针对的是 EmbeddingGemma v1(300M,纯文本), 当时"冗余"的判断前提是"它只能编码文本,而 SigLIP 2 已经覆盖了文本+图像"。 Google 于 2026-10-06 发布 EmbeddingGemma 2,前提已不成立:
- 它是 740M 的多模态模型(文本 270M + 视觉 170M + 音频 300M,模块化), 四种模态统一嵌入空间——跟 SigLIP 2 不是一个品类,不是"多一个文本模型"。
- 基础输出 768 维,与本文档的
EMBEDDING_DIM = 768一致,MRL 可截断到 512/256/128。- 8K 上下文(约 58 帧视频 / 5.5 分钟音频),能把一个视频片段编码成单向量, 而非现在"每帧一个向量、彼此不知情"的做法。
- 音频编码器是 Vixel 当前完全空白的能力(全库零 audio 代码)。
结论:v1 的否决理由继续成立(换纯文本模型仍是冗余),但不再能用来否决 v2。
已采纳(2026-10):
onnx-community/embeddinggemma-2-ONNX提供了 ONNX 导出,@huggingface/transformers4.3.1 原生支持EmbeddingGemma2Model/EmbeddingGemma2Processor,原先的部署阻塞项解除。EmbeddingGemma 2 现在是唯一 embedding 模型,见 §2.1 / §2.4。
-- library.db (SQLite + sqlite-vec, WAL mode, busy_timeout=5000)
-- 监控文件夹
CREATE TABLE watched_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
last_scan_at DATETIME,
recursive BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 照片主表(视频抽出的帧也是一行 photos,video_id 反向引用)
CREATE TABLE photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER REFERENCES watched_folders(id),
file_path TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_mtime INTEGER NOT NULL,
file_hash TEXT,
width INTEGER,
height INTEGER,
taken_at DATETIME,
lat REAL,
lng REAL,
embed_status TEXT DEFAULT 'pending',
face_status TEXT DEFAULT 'pending',
video_id INTEGER REFERENCES videos(id), -- null=独立照片;否则是视频代表帧
frame_time_ms INTEGER, -- 视频代表帧时间戳
deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_photos_folder ON photos(folder_id);
CREATE INDEX idx_photos_status ON photos(embed_status);
CREATE INDEX idx_photos_deleted ON photos(deleted_at);
CREATE INDEX idx_photos_file_hash ON photos(file_hash);
CREATE INDEX idx_photos_video ON photos(video_id);
-- 索引任务队列
CREATE TABLE index_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
photo_id INTEGER REFERENCES photos(id), -- task_type='extract_frames' 时是 videos.id
task_type TEXT NOT NULL, -- thumbnail/embed/face/ocr/extract_frames
priority INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
error_msg TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_queue_status ON index_queue(status, priority DESC);
-- Caption(用户手写,详情页展示;不建 FTS,不参与搜索)
CREATE TABLE captions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_hash TEXT NOT NULL UNIQUE,
lang TEXT DEFAULT 'en',
text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 图像向量(EmbeddingGemma 2,768d;视频代表帧也在这里)
-- vec0 要求 rowid 是整数;image_vec_map 把 file_hash → rowid 映射
CREATE TABLE image_vec_map (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
file_hash TEXT NOT NULL UNIQUE
);
CREATE VIRTUAL TABLE image_vecs USING vec0(embedding FLOAT[768]);
-- OCR 文本(按 file_hash 去重共享;独立于 captions)
CREATE TABLE image_ocr (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_hash TEXT NOT NULL UNIQUE,
text TEXT NOT NULL,
detected_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- FTS5(unicode61,但插入/查询前由 jiebatok UDF 切词;中英都好)
CREATE VIRTUAL TABLE image_ocr_fts USING fts5(text, tokenize='unicode61');
-- INSERT/UPDATE 触发器调用 jiebatok(new.text)
CREATE TRIGGER image_ocr_ai AFTER INSERT ON image_ocr BEGIN
INSERT INTO image_ocr_fts(rowid, text) VALUES (new.id, jiebatok(new.text));
END;
-- (... 对应 _ad / _au)
-- 人脸
CREATE TABLE faces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_hash TEXT NOT NULL,
face_index INTEGER NOT NULL,
bbox TEXT NOT NULL, -- JSON {x,y,w,h} 归一化
confidence REAL NOT NULL,
embedding BLOB NOT NULL, -- source-of-truth;face_vecs 是 ANN 索引
person_id INTEGER REFERENCES people(id),
quality REAL DEFAULT 1, -- face/quality.ts
cluster_state INTEGER DEFAULT 0, -- 未归属时:0 待聚类 / 1 落单
assigned_by TEXT, -- 'auto' | 'user'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_hash, face_index)
);
CREATE INDEX idx_faces_file_hash ON faces(file_hash);
CREATE INDEX idx_faces_person ON faces(person_id);
-- 人脸 ANN 索引(rowid == faces.id;无需 map 表)
CREATE VIRTUAL TABLE face_vecs USING vec0(embedding FLOAT[512]); -- 老库按 128 建的,migrateSchema 重建并回填
-- 人物
CREATE TABLE people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
cover_face_id INTEGER,
face_count INTEGER DEFAULT 0,
centroid BLOB, -- 成员 embedding 均值(L2 归一化)
hidden INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE face_rejections (face_id INTEGER, person_id INTEGER, PRIMARY KEY (face_id, person_id)); -- "不是此人"
CREATE TABLE person_dismissed_pairs (a INTEGER, b INTEGER, PRIMARY KEY (a, b)); -- 驳回的合并建议
-- 视频
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER REFERENCES watched_folders(id),
file_path TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_mtime INTEGER NOT NULL,
file_hash TEXT, -- 轻量哈希 (path|size|mtime)
duration_ms INTEGER,
width INTEGER,
height INTEGER,
frame_count INTEGER DEFAULT 0,
media_kind TEXT NOT NULL DEFAULT 'video', -- 'video' | 'audio'
deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_videos_folder ON videos(folder_id);
CREATE INDEX idx_videos_hash ON videos(file_hash);
-- frame_count 现在表示片段数;duration_ms 缺失时由 indexer 用 probeDurationMs 补齐
-- 视频片段(32s 一段;EmbeddingGemma 2 多模态向量)
CREATE TABLE video_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id),
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
file_hash TEXT NOT NULL, -- hash(视频 hash + 时间区间),dedup 用
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_video_segments_video ON video_segments(video_id);
CREATE INDEX idx_video_segments_hash ON video_segments(file_hash);
-- 片段向量 ANN 索引 + segment_id → rowid 映射
CREATE VIRTUAL TABLE video_segment_vecs USING vec0(embedding FLOAT[768]);
CREATE TABLE video_segment_vec_map (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
segment_id INTEGER NOT NULL UNIQUE REFERENCES video_segments(id)
);
-- 内部状态(KV)
CREATE TABLE meta_state (
key TEXT PRIMARY KEY,
value TEXT
);
-- 已记录的 keys:
-- last_backup_at (备份调度节流)db.function('jiebatok', { deterministic: true, varargs: false }, (text) => {
if (typeof text !== 'string' || text.length === 0) return ''
return tokenizeForFtsSync(text)
})tokenizeForFtsSync 内部:
- 纯 ASCII:去多余空白返回
- 含 CJK:交给 @node-rs/jieba
cutForSearch(更宽召回) - jieba 加载失败:返回原文(FTS5 退化为 char-level,至少不丢数据)
deterministic: true 让 SQLite 缓存重复输入的结果,触发器密集写入时显著省 CPU。
// src/core/search.ts (简化)
const queryVec = await encodeQuery(query) // EmbeddingGemma 2,只编码一次
const vec = searchByVector(queryVec, limit * 2) // image_vecs KNN
const segments = searchVideoSegments(queryVec, limit * 2) // video_segment_vecs KNN,每视频取最佳片段
const ocrBm25 = db.searchByOcr(query, limit * 2) // image_ocr_fts BM25
const fileName = db.searchByFileName(query, limit * 2) // photos.file_name LIKE
// 片段命中 → 该视频代表帧的 fileHash,与照片共用 id 空间
const fused = rrfFuse([vec, segmentHits, ocrBm25, fileName], 60)手写 caption 不参与搜索(caption BM25 通道与 captions_fts 已删除)。
rrfFuse 是 src/core/fusion.ts 里的纯函数(带 7 个单测)。
# macOS(生产)
/Applications/Vixel.app/Contents/Resources/models/
├── gemma2/ (~620 MB, 文本/视觉 q4 + 音频 q8 ONNX + .onnx_data, 打进 DMG)
├── paddleocr/ (~12 MB, 4 个 ONNX + ppocr_keys_v1.txt)
├── scrfd_2.5g_kps.onnx
└── mobilefacenet.onnx
# 用户态
~/Library/Application Support/Vixel/
├── library.db ← 主数据库 (WAL)
├── library.db-wal
├── library.db-shm
├── thumbnails/
│ └── {file_hash}.webp ← 共享去重缩略图
├── video_frames/
│ └── {videoHash}/
│ ├── segment_0_0ms.jpg ← 视频代表帧(音频为 cover.jpg / waveform.jpg)
│ └── sprite.jpg ← 悬停拖动预览:12 帧 × 240px 正方形横拼
└── embedding-config.json ← 各编码器量化档位(q4/q8)
注:模型权重不在用户目录,因此卸载应用 = 删除模型;只删用户数据 不会影响搜索能力。
initDatabase(dbPath)
1. open DB + WAL + busy_timeout
2. sqlite-vec.load
3. db.function('jiebatok', ...) ← 注册 UDF
4. db.exec(SCHEMA) ← CREATE ... IF NOT EXISTS,幂等
5. wal_checkpoint(PASSIVE)
6. cleanupStaleVecMap ← image_vec_map 孤立条目清理
产品未发布,原则上不做 schema 迁移:schema 变更后直接删掉开发库 library.db 重建。
例外是 videos.media_kind:用 hasColumn 判断后 ALTER TABLE ADD COLUMN,幂等。
启动时还会 pruneDoneTasks() 删除已完成的队列行。
注意 SigLIP 2 与 EmbeddingGemma 2 同为 768d,旧库不会报维度错误,但向量空间不同,必须删库。
三类进程:
- Main:UI 路由、SQLite、Indexer 调度、
vixel://协议。不跑模型推理。 - Inference(Electron
utilityProcess,src/main/inference.ts): EmbeddingGemma 2 / SCRFD / MobileFaceNet / PaddleOCR 全在这里,nice 10。 - ffmpeg:抽帧 / 音轨,短命的 spawn-and-wait。
为什么拆:transformers.js 的一次前向会同步占住线程(视频片段单次实测可达 10s), 放在 main 里索引期间事件循环几乎一直被占满 —— 窗口拖动、IPC、光标全卡(macOS 一直转圈)。 实测(33 图 + 7 段音视频):IPC 往返 p50 2.9s / max 20s → p95 5ms / max 32ms, 总索引时间 333s → 221s(main 不再和推理抢 GC)。
┌──────────────────────────────┐ postMessage ┌───────────────────────────────┐
│ Main Process │ ───────────────► │ Inference (utilityProcess) │
│ Indexer · Watcher · SQLite │ ◄─────────────── │ EmbeddingGemma 2 · SCRFD · │
│ embedding/face/ocr 入口函数 │ structured clone │ MobileFaceNet · PaddleOCR │
│ 看到 transport 就转发 │ │ 串行队列;文本 query 插队 │
└──────────────┬───────────────┘ └───────────────────────────────┘
│ spawn 崩溃 → generation+1,下次调用自动拉起并重新 init
▼
ffmpeg-static
│ contextBridge + ipcMain/Renderer
┌──────────────▼───────────────┐
│ Renderer Process (React) │
└──────────────────────────────┘
转发层在 src/core/inference/transport.ts:setInferenceTransport() 之后,
createProvider() 返回 RemoteEmbeddingProvider,initFaceService / processPhotoFaces /
processPhotoOcr 转发到推理进程;没设 transport 时(单测、CLI、推理进程自身)照旧本进程执行。
Buffer 经结构化克隆会变成 Uint8Array,推理进程侧 reviveInput 还原。
src/preload/index.ts 暴露的全部 API(v0.2)。
全部走 ipcRenderer.invoke,没有同步 IPC,没有 nodeIntegration。
window.api = {
// 搜索
search(query, limit?, { dateFrom?, dateTo?, kind? }?) // 结果可带 segment: {startMs, endMs}
findSimilar(photoId, limit?)
getPhotosWithGPS()
// 文件夹
selectFolder() / addFolder(path) / removeFolder(id)
getFolders() / getFolderStats(id)
// 照片
getPhotoDetail(id) / getThumbnailData(id) / getFullImageData(id)
getPhotoLocations(id) / showInFinder(path)
updateCaption(id, text)
// OCR
startOcrScan()
// 人脸
startFaceScan() / getPeople()
getPersonPhotos(id, limit?) / setPersonName(id, name)
mergePeople(target, sources[]) / getFaceThumbnail(faceId)
getPhotoFaces(photoId)
// 模型状态 / 量化档位(EmbeddingGemma 2,纯本地)
getModelStatus() / getEmbeddingConfig() / setEmbeddingConfig(quantPatch)
openSourceVideo(videoId)
// 音视频
getMediaDetail(videoId) // → { kind, durationMs, width, height, filePath, fileSize, segments }
// 进度 / 任务面板
onIndexProgress(cb) // → { stage, totalPhotos, ..., paused, current?, queue }
getIndexPaused() / setIndexPaused(paused)
getTaskOverview() / retryFailedTasks(ids?) / clearFailedTasks()
}历史 channel(
download-model/download-llama-server/init-caption-generator/regenerate-caption等)已在 PR2 全部删除;test-embedding-api随 API provider 在 2026-10 重构中删除, renderer 不再持有对应回调。
file enters watcher
│
├─ image → addPhoto → enqueue('thumbnail', 'embed')
└─ video / audio → addVideo(kind) → enqueue('extract_frames')
(内容未变且已扫过 / 已在队列中 → 跳过,避免每次启动重编码)
indexer.processNext() loop:
task handler
───────────────── ────────────────────────────────
thumbnail processThumbnail
decodeImage() ← sharp → sips(mac HEIC/RAW) → heic-convert(HEIC fallback)
parseAndUpdateMeta() ← exifr on original bytes
sharp.rotate().resize(512).webp
→ <userData>/thumbnails/<hash>.webp
embed processEmbedding
decodeImage()
getEmbeddingService().encodeImage(decoded) ← EmbeddingGemma 2
→ db.saveImageVec(hash, Float32Array<768>)
face processFace
decodeImage()
SCRFD detect → MobileFaceNet embed
→ db.saveFace(...) ← 同时写 face_vecs
→ assignFaceToPerson(...) ← ANN k=10 匹配
ocr processOcr
decodeImage()
processPhotoOcr() ← det/cls/rec
→ db.saveOcrText(hash, joined)
extract_frames processExtractFrames (task.photoId 是 videos.id)
清理旧 segments + 旧帧 photo 行 + 帧目录
probeDurationMs() ← 无时长 → frameCount=0 返回
for 每个 32s 片段:
extractKeyframes({startSec, durationSec, 每 4s, 8 帧})
首段:首帧 → segment_0_0ms.jpg → addPhoto(videoId, 0)
→ enqueue('thumbnail', 'embed')(编码前写入)
extractAudioTrack() ← 失败则纯视频
encode(multimodal) / encodeVideo()
→ db.saveVideoSegment(videoId, startMs, endMs, hash, vec)
frameCount = 片段数
caption legacy queue 残留,直接 completeTask
关键设计点:
- single-flight
isProcessingflag:v0.2 仍是串行处理 task;并发是后续 优化方向(image embedding 走 CoreML 时 ~25 ms,多核 CPU 可受益)。 - task 失败 ≠ 全局停摆:
failTask写 retry_count + error_msg,下一轮 recoverStuckTasks 把 processing 状态拉回 pending 重试。 - decodeImage 是流水线唯一的读文件入口:所有阶段都通过它拿 buffer, 自动处理 HEIC/RAW;EXIF 走原始字节避免 sips 转码丢失 GPS。
src/core/watcher.ts 的 FileWatcher.handleAdd 现在
按 classifyMedia(ext) 分流:
| 扩展名 | 走向 |
|---|---|
.jpg .png .webp .gif .tiff .bmp .avif .heic .heif .cr2 .cr3 .nef .arw .dng .raf .orf .rw2 |
image 路径,addPhoto + enqueue thumbnail/embed |
.mp4 .mov .m4v .webm .mkv .avi |
video 路径,addVideo + enqueue extract_frames |
.mp3 .m4a .aac .wav .flac .ogg .opus |
audio 路径,同上(media_kind = 'audio') |
| 其它 | 忽略 |
视频条目的 file_hash 用 (path|size|mtime) 轻量哈希,避免 watcher 在
GB 级文件上读取全部字节阻塞。代表帧的内容 hash 在 indexer 抽帧后计算;
片段 hash = hash(视频 hash + 时间区间)。
chokidar 启动时对已有文件逐个触发 add。handleAddVideo 在轻量哈希未变、
且 frame_count 非空(已扫过;坏文件为 0)或已有 pending / processing 的
extract_frames 任务时不再入队 —— 否则每次启动都会清掉片段从头重编码。
// src/core/search.ts (简化)
async search(query, limit, options) {
if (!query.trim()) return getRecentPhotos(limit) // 含日期过滤分支
const queryVec = await this.encodeQuery(query) // 模型未就绪 → null,向量通道为空
const vec = this.searchByVector(queryVec, limit * 2)
const segments = this.searchVideoSegments(queryVec, limit * 2) // 每视频最佳片段
const ocrBm25 = db.searchByOcr(query, limit * 2) // image_ocr_fts BM25
const fileName = db.searchByFileName(query, limit * 2) // LIKE %query%
// 片段 → 视频代表帧(第一个未删除的帧 photo)的 fileHash
const segmentHits = segments.flatMap((seg) => {
const frame = db.getFramePhotosByVideo(seg.videoId).find((p) => !p.deletedAt)
return frame ? [{ fileHash: frame.fileHash, distance: seg.distance }] : []
})
const fused = rrfFuse([
[...vec].sort((a, b) => a.distance - b.distance),
segmentHits,
[...ocrBm25].sort((a, b) => b.score - a.score),
fileName,
], 60)
// dedup:同 hash 只取一次;同 video 只取最佳帧
const results = []
const seenHashes = new Set(); const seenVideos = new Set()
for (const { fileHash, score } of fused) {
if (seenHashes.has(fileHash)) continue; seenHashes.add(fileHash)
const photo = db.getRepresentativeByHash(fileHash)
if (!photo || photo.deletedAt) continue
if (photo.videoId != null) {
if (seenVideos.has(photo.videoId)) continue
seenVideos.add(photo.videoId)
}
if (dateFrom && photo.takenAt < dateFrom) continue
if (dateTo && photo.takenAt > dateTo) continue
results.push({ photo, score })
if (results.length >= limit) break
}
return results
}rrfFuse 抽到 src/core/fusion.ts 作纯函数 + 7 个单测;
buildFtsQuery 抽到 src/core/text/fts-query.ts
作纯函数 + 9 个单测(覆盖 jieba 分词 + FTS5 语法字符转义 + keyword 过滤)。
| 阶段 | 耗时 | 说明 |
|---|---|---|
| decodeImage | < 5 ms (JPEG) / ~200 ms (HEIC via sips) / ~600 ms (HEIC via heic-convert WASM) | |
| parseAndUpdateMeta | ~10 ms | exifr |
| generateThumbnail | ~20 ms | sharp resize → webp 512px |
| processEmbedding | ~3.4 s / 张 | EmbeddingGemma 2 视觉 q4(旧 SigLIP 2 为 ~80 ms CPU / ~25 ms CoreML) |
| processFace(按需) | ~30 ms / 张(不含 detect) | SCRFD + MobileFaceNet |
| processOcr(按需) | ~150–400 ms / 张 | PaddleOCR det+rec |
| 单视频片段(32s) | ~14 s | ffmpeg 抽 8 帧 + 音轨 + 一次多模态编码 |
| 操作 | v0.1 | v0.2 |
|---|---|---|
| 新人脸入库(10k 已有脸) | ~500 ms / 张 (JS 暴力余弦) | ~1 ms / 张 (vec0 ANN) |
| 中文 query "海边" | 字符级 BM25,召回错乱 | jieba 分词后 BM25 + vec 并行 |
| HEIC 索引 | 静默失败 | sips fallback 正确解码 |
| 组件 | 内存(M2 实测,加载完) |
|---|---|
| Electron 基础 + React UI | ~250 MB |
| EmbeddingGemma 2(q4/q4/q8 + tokenizer + processor) | 待测(权重 ~620 MB,预计显著高于旧 SigLIP 2 的 ~400 MB) |
| onnxruntime + face / OCR sessions | ~150 MB |
| SQLite WAL + caches | < 100 MB |
| 稳态总计 | 待测(旧 SigLIP 2 版本 < 1 GB) |
视频抽帧 / 音轨抽取期间 ffmpeg 短暂吃额外内存,结束后释放。
- 模型预热:jieba + EmbeddingGemma 2 在启动后 fire-and-forget 预加载
- SQL UDF
deterministic: true:FTS5 触发器密集写入时缓存 jieba 结果 - decodeImage 单读:避免对 HEIC 重复触发 sharp metadata 探测
- 缩略图共享:按 file_hash 复用,重复文件零额外存储
- vec0 ANN:所有向量搜索(image + video segment + face)走 vec0
- query 只编码一次:图像通道与视频片段通道共享同一个 query 向量
- CoreML execution provider 优先:Apple Silicon 上 ONNX 3-4x 加速
- Indexer 流水线
isProcessing串行;可并行的有:- 多个 embed 任务并发(CPU/Metal 都未饱和)
- thumbnail 与 embed 解耦(thumb 是 IO,embed 是计算)
- IPC
getThumbnailData走 base64 base64 over IPC;可换vixel://自定义 协议直接 stream,省一次内存拷贝 - search 各通道并发但
rrfFuse是 JS 单 fold;对 limit=50 量级无所谓
// main/index.ts
app.on('ready', () => {
// 禁用所有网络请求(模型下载除外)
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
const isModelDownload = details.url.includes('huggingface.co');
if (!isModelDownload && details.url.startsWith('http')) {
console.log('Blocked network request:', details.url);
callback({ cancel: true });
} else {
callback({});
}
});
});- 照片原文件:只读引用,不复制
- 索引文件:存储在用户目录,不共享
- Embedding 向量:无法反向还原原图
- 日志:不记录照片内容
// package.json
{
"name": "vixel",
"version": "0.1.0",
"main": "dist/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "electron-builder"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"sqlite-vec": "^0.1.0",
"onnxruntime-node": "^1.17.0",
"node-llama-cpp": "^3.0.0",
"chokidar": "^3.6.0",
"sharp": "^0.33.0",
"exifr": "^7.1.0"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-vite": "^2.3.0",
"electron-builder": "^25.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"typescript": "^5.5.0",
"tailwindcss": "^3.4.0",
"react-window": "^1.8.0"
}
}开发期名称 / 图标:macOS 菜单栏粗体名、Dock / ⌘Tab 的名字和图标取自 bundle 的
Info.plist 和 electron.icns,app.setName() 改不到。predev 跑
scripts/dev-bundle.mjs:用 PlistBuddy 把 node_modules/electron/dist/Electron.app
的 CFBundleName / CFBundleDisplayName 改成 Vixel、换上 build/icon.icns,再
lsregister -f 刷新 Launch Services 缓存(幂等;不改 CFBundleIdentifier,Electron
子进程靠它找主进程)。重装 electron 后下次 npm run dev 会自动再打一次。
打包版的名字 / 图标走 electron-builder.yml。
vixel/
├── src/
│ ├── main/index.ts # Electron 入口 + IPC handlers + vixel:// 协议
│ ├── preload/index.ts # contextBridge 暴露 window.api
│ ├── shared/types.ts # main / renderer 共享类型
│ ├── cli/index.ts # 命令行入口
│ ├── core/
│ │ ├── db.ts # SQLite schema + 全部查询
│ │ ├── watcher.ts # chokidar;classifyMedia(image/video)
│ │ ├── indexer.ts # 任务队列 + 各阶段 handler(含视频片段流水线)
│ │ ├── search.ts # 4-way RRF
│ │ ├── fusion.ts # 纯函数 rrfFuse()
│ │ ├── backup.ts # SQLite 在线备份 + 轮换
│ │ ├── embedding/
│ │ │ ├── index.ts # EmbeddingService 单例 + embedding-config.json
│ │ │ ├── types.ts # EmbeddingInput / 维度常量
│ │ │ └── providers/gemma2Provider.ts # EmbeddingGemma 2(唯一 provider)
│ │ ├── video/extract.ts # 片段抽帧 + probeDurationMs
│ │ ├── audio/extract.ts # 音轨抽取(mono 16 kHz f32le)
│ │ ├── face/ # SCRFD + MobileFaceNet + ANN 匹配
│ │ ├── ocr/ # PaddleOCR det / cls / rec
│ │ ├── image/decode.ts # sharp + sips / heic-convert 兜底
│ │ └── text/ # jieba 分词 + FTS5 query 转义
│ └── renderer/src/ # React 前端
│ ├── App.tsx
│ └── components/ # SearchBar / PhotoGrid / PhotoDetail / MapView /
│ # PeopleView / FolderManager / IndexProgress / ModelStatus
├── scripts/download-models.mjs # 模型下载(gemma2 / paddleocr / face)
├── resources/models/ # 开发期模型目录
├── electron.vite.config.ts
└── package.json
| 优先级 | 任务 | 预估工时 |
|---|---|---|
| P0 | 项目骨架 + electron-vite 配置 | 1 天 |
| P0 | SQLite + sqlite-vec 集成 | 1 天 |
| P0 | 照片导入 + 文件扫描 + EXIF 解析 | 2 天 |
| P0 | chokidar 文件监听 | 1 天 |
| P0 | ||
| P0 | EmbeddingGemma 2 provider(文本 / 图像 / 音频 / 视频) | ✅ 已完成 |
| P0 | 视频 32s 片段 + 音轨抽取 + video_segments 存储 | ✅ 已完成 |
| P0 | 搜索:删 caption 通道,加视频片段通道,query 单次编码 | ✅ 已完成 |
| P0 | 删除外部 Embedding API provider;设置页改为量化档位选择 | ✅ 已完成 |
| P0 | gemma2Provider 端到端冒烟(真实模型文件 + 多模态编码) | 待做 |
| P1 | 检索质量验证(~200 图 + ~30 中文 query);性能 / 内存实测回填 §5 | 待做 |
| P1 | 模型未就绪时视频片段任务应失败重试,而非静默完成 | 待做 |
| P0 | 向量存取 + 基础搜索 | 2 天 |
| P0 | 搜索 UI + 结果展示 | 2 天 |
| P1 | ||
| P1 | ||
| P1 | 多路搜索 + RRF 融合 | 1 天 |
| P1 | 缩略图生成 + 虚拟列表 | 2 天 |
| P1 | PhotoDetail + EXIF 展示 | 1 天 |
| P2 | 索引进度条 | 1 天 |
| P2 | 设置页 + 模型下载 | 2 天 |
| P2 | 性能优化 + 错误处理 | 2 天 |
总计:~24 天(1 人)/ ~12 天(2 人)
| 风险 | 概率 | 应对方案 |
|---|---|---|
| node-llama-cpp 兼容性问题 | 中 | 使用 llama.cpp HTTP server + fetch 调用 |
| onnxruntime-node CoreML 不生效 | 中 | 降级到 CPU,或使用 Python sidecar |
| sqlite-vec Node 绑定问题 | 低 | 使用 better-sqlite3 扩展加载 |
| sharp HEIC 解码失败 | 低 | macOS 使用 sips 命令行工具 |
| 内存占用超预期 | 中 | 模型懒加载 + 卸载不用的模型 |
| 模型下载慢(中国用户) | 高 | 提供 ModelScope 镜像 |
Vixel 技术文档 v0.3 · Electron 版本