Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ jobs:
# macos-14 / macos-15 = Apple Silicon runners
# ubuntu 用来验证非 macOS 的 ONNX/jieba 二进制能正常解析
os: [macos-14, ubuntu-latest]
node: ['20']
# 22:服务器版(Docker 镜像)跑在 Node 22 上,pdf.js 用到 Promise.withResolvers(Node 20 没有)
node: ['22']

steps:
- uses: actions/checkout@v4
Expand All @@ -37,6 +38,10 @@ jobs:
- name: Install (skip native build)
run: npm install --legacy-peer-deps --ignore-scripts --no-audit --no-fund

# --ignore-scripts 也跳过了 ffmpeg-static 的下载;视频相关测试要真的 ffmpeg 二进制
- name: Fetch ffmpeg binary
run: node node_modules/ffmpeg-static/install.js

- name: Typecheck
run: npm run typecheck
# 该项目的根 tsconfig 用复合项目引用,存在已知 TS6310 + window.api
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.1] - 2026-10-15

### Fixed
- **Videos and audio failed to index in the installed apps (macOS and Windows).** The
packaged app started ffmpeg from a path inside `app.asar`, which `spawn` can't execute
(`spawn ENOTDIR` on macOS, `ENOENT` on Windows), so every video and audio file errored
and had no searchable segments. ffmpeg now runs from `app.asar.unpacked`. This affected
every desktop release since 0.2.0 (development builds and the Docker image were fine,
which is why tests missed it); `scripts/packaged-video-check.sh` now indexes videos with
the packaged app before each release. Thanks to the V2EX user who reported it. After
updating, videos that failed are re-indexed automatically on the next launch.

## [0.5.0] - 2026-10-15

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vixel",
"version": "0.5.0",
"version": "0.5.1",
"description": "Private, offline AI search for your photos, videos and audio",
"main": "./out/main/index.js",
"author": "Vixel Team",
Expand Down
35 changes: 35 additions & 0 deletions scripts/packaged-video-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/sh
# 发布前检查:用"打包后的"应用(不是开发模式)索引几个视频,必须产出片段向量、零错误。
# 开发模式没有 app.asar,打包后才会出现的问题(spawn 读不了 asar 里的二进制 → ENOTDIR)只有这样才测得到。
#
# sh scripts/packaged-video-check.sh [Vixel.app 路径] # 默认 release/mac-arm64/Vixel.app
set -eu
APP="${1:-release/mac-arm64/Vixel.app}"
BIN="$APP/Contents/MacOS/Vixel"
FOOTAGE="${VIXEL_EVAL_FOOTAGE_LIB:-$HOME/workspace/dancer/vixel-demo/footage}"
WORK="$(mktemp -d)"; DATA="$WORK/data"; MEDIA="$WORK/media"
trap 'pkill -f "$BIN" 2>/dev/null || true; rm -rf "$WORK"' EXIT
mkdir -p "$MEDIA"
# 有声视频 + 无声视频 + 多片段视频
cp "$FOOTAGE/waterfall-watagataki.mp4" "$FOOTAGE/dogs-hurdles-1899.mp4" "$FOOTAGE/rain-sonoma.mp4" "$MEDIA/"

q() { sqlite3 "$DATA/library.db" "$1" 2>/dev/null || true; }
# 第一次启动只为建库,退出后登记测试目录,再启动开始索引
"$BIN" --user-data-dir="$DATA" >"$WORK/run1.log" 2>&1 &
for _ in $(seq 1 30); do [ -f "$DATA/library.db" ] && break; sleep 2; done
sleep 5; pkill -f "$BIN" || true; sleep 3
q "insert into watched_folders(path) values('$MEDIA')"
"$BIN" --user-data-dir="$DATA" >"$WORK/run2.log" 2>&1 &
for i in $(seq 1 60); do
pending=$(q "select count(*) from index_queue where task_type='extract_frames' and status in ('pending','processing')")
videos=$(q "select count(*) from videos")
[ "${videos:-0}" -ge 3 ] && [ "${pending:-1}" = 0 ] && [ "$i" -gt 3 ] && break
sleep 5
done
segments=$(q "select count(*) from video_segments")
errors=$(q "select task_type || ': ' || substr(error_msg,1,120) from index_queue where status='error'")
echo "videos=$(q 'select count(*) from videos') segments=$segments"
if [ "${segments:-0}" -lt 3 ] || [ -n "$errors" ]; then
echo "FAIL"; [ -n "$errors" ] && echo "$errors"; exit 1
fi
echo "OK"
14 changes: 1 addition & 13 deletions src/core/audio/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,7 @@

import { spawn } from 'child_process'
import { FFMPEG_THREADS, lowerPriority } from '../video/extract'

// 复用 video/extract.ts 的 ffmpeg 路径获取逻辑
let ffmpegPath: string | null | undefined
function getFfmpegPath(): string | null {
if (ffmpegPath === undefined) {
try {
ffmpegPath = (require('ffmpeg-static') as string | null) || null
} catch {
ffmpegPath = null
}
}
return ffmpegPath
}
import { getFfmpegPath } from '../video/ffmpegPath'

export function isFfmpegAvailable(): boolean {
return getFfmpegPath() !== null
Expand Down
16 changes: 15 additions & 1 deletion src/core/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1179,10 +1179,23 @@ export function initDatabase(dbPath: string, options?: { runCleanup?: boolean; r
AND v.id NOT IN (SELECT video_id FROM video_segments)
AND v.id NOT IN (SELECT photo_id FROM index_queue WHERE task_type = 'extract_frames' AND status IN ('pending', 'processing', 'error'))
`),
// ≤0.5.0 打包版 spawn 不了 asar 里的 ffmpeg:探测时长失败,视频被标成"扫过了、读不到"(frame_count=0)。
// 先把这些视频的标记清掉,下面的"缺片段"补排才会选中它们
resetSpawnFailedVideos: db.prepare(`
UPDATE videos SET frame_count = NULL
WHERE frame_count = 0 AND id IN (
SELECT photo_id FROM index_queue
WHERE task_type = 'extract_frames' AND status = 'error'
AND (error_msg LIKE 'spawn ENOTDIR%' OR error_msg LIKE 'spawn ENOENT%')
)
`),
// 环境原因的失败(模型没就绪、推理进程挂了、≤0.5.0 打包版 spawn 不了 asar 里的 ffmpeg):
// 启动时清掉,让下面的"缺向量 / 缺片段"补排重新跑;坏文件的错误留给任务面板
clearModelNotReadyErrors: db.prepare(`
DELETE FROM index_queue
WHERE task_type IN ('embed', 'extract_frames') AND status = 'error'
AND (error_msg LIKE 'Embedding model not ready%' OR error_msg LIKE 'Inference process exited%')
AND (error_msg LIKE 'Embedding model not ready%' OR error_msg LIKE 'Inference process exited%'
OR error_msg LIKE 'spawn ENOTDIR%' OR error_msg LIKE 'spawn ENOENT%')
`),
getPhotosWithoutEmbedding: db.prepare(`
SELECT p.id, p.file_hash FROM photos p
Expand Down Expand Up @@ -1861,6 +1874,7 @@ export function initDatabase(dbPath: string, options?: { runCleanup?: boolean; r
requeueMissingEmbeddings: db.transaction((): number => {
// 模型未就绪 / 推理进程退出(崩溃、看门狗)的失败可以自动重来(先删掉旧的失败行,免得队列里一条媒体两行);
// 单文件失败(坏图 / 解码失败)不自动重排,留在任务面板由用户重试,避免每次启动白跑
stmts.resetSpawnFailedVideos.run()
stmts.clearModelNotReadyErrors.run()
const photos = stmts.getPhotosWithoutEmbedding.all() as Array<{ id: number }>
for (const photo of photos) {
Expand Down
15 changes: 9 additions & 6 deletions src/core/screenshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeAll } from 'vitest'
import { execFileSync } from 'child_process'
import { mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
Expand Down Expand Up @@ -37,15 +37,18 @@ describe('inScreenshotsFolder', () => {
})

// macOS 真实 xattr:写一个带 / 不带属性的文件,经 koffi getxattr 读回
// runIf 只跳过用例,describe 体在收集阶段照样执行 —— 建文件 / 调 xattr 必须放进 beforeAll,否则 Linux CI 直接报错
describe.runIf(process.platform === 'darwin')('xattrIsScreenCapture (darwin)', () => {
const dir = mkdtempSync(join(tmpdir(), 'vx-shot-'))
const plain = join(dir, 'plain.png')
const marked = join(dir, 'marked.png')
writeFileSync(plain, 'x')
writeFileSync(marked, 'x')
// Spotlight 写的是 bplist 布尔 true;属性存在与否才是判据,值随便
execFileSync('xattr', ['-wx', 'com.apple.metadata:kMDItemIsScreenCapture',
'62706c6973743030090800000000000001010000000000000001000000000000000000000000000000090a', marked])
beforeAll(() => {
writeFileSync(plain, 'x')
writeFileSync(marked, 'x')
// Spotlight 写的是 bplist 布尔 true;属性存在与否才是判据,值随便
execFileSync('xattr', ['-wx', 'com.apple.metadata:kMDItemIsScreenCapture',
'62706c6973743030090800000000000001010000000000000001000000000000000000000000000000090a', marked])
})

it('属性在 → true,不在 → false,文件不存在 → false(名字模式兜底)', () => {
expect(xattrIsScreenCapture(marked)).toBe(true)
Expand Down
16 changes: 1 addition & 15 deletions src/core/video/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,10 @@ import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
import { setPriority, tmpdir } from 'os'
import { join } from 'path'
import type { ExtractedFrame, ExtractOptions } from './types'
import { getFfmpegPath } from './ffmpegPath'

export type { ExtractedFrame, ExtractOptions } from './types'

// ffmpeg-static 在 install 时把二进制放到包目录;require() 返回路径或 null
let ffmpegPath: string | null | undefined
function getFfmpegPath(): string | null {
if (ffmpegPath === undefined) {
try {
// 用 eval require 避免被 bundler 静态分析时尝试解析(electron-vite 期间)
// eslint-disable-next-line @typescript-eslint/no-var-requires
ffmpegPath = (require('ffmpeg-static') as string | null) || null
} catch {
ffmpegPath = null
}
}
return ffmpegPath
}

export function isFfmpegAvailable(): boolean {
return getFfmpegPath() !== null
}
Expand Down
15 changes: 15 additions & 0 deletions src/core/video/ffmpegPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { unpackedAsarPath } from './ffmpegPath'

describe('unpackedAsarPath', () => {
it('points packaged paths at app.asar.unpacked (spawn cannot run binaries inside app.asar)', () => {
expect(unpackedAsarPath('/Applications/Vixel.app/Contents/Resources/app.asar/node_modules/ffmpeg-static/ffmpeg'))
.toBe('/Applications/Vixel.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg')
expect(unpackedAsarPath('C:\\Program Files\\Vixel\\resources\\app.asar\\node_modules\\ffmpeg-static\\ffmpeg.exe'))
.toBe('C:\\Program Files\\Vixel\\resources\\app.asar.unpacked\\node_modules\\ffmpeg-static\\ffmpeg.exe')
})
it('leaves dev / server paths and already-unpacked paths alone', () => {
expect(unpackedAsarPath('/repo/node_modules/ffmpeg-static/ffmpeg')).toBe('/repo/node_modules/ffmpeg-static/ffmpeg')
expect(unpackedAsarPath('/x/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg')).toBe('/x/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg')
})
})
30 changes: 30 additions & 0 deletions src/core/video/ffmpegPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* ffmpeg 二进制路径(ffmpeg-static 在 install 时下载到包目录,require() 返回路径或 null)。
*
* 打包后的应用里 require 解析到 `…/app.asar/node_modules/ffmpeg-static/ffmpeg`:Electron 的 fs 和
* execFile 能透明处理 asar 路径,但 child_process.spawn 不能 —— 直接 spawn 报 ENOTDIR(asar 是文件不是目录),
* 所有视频 / 音频索引都失败。二进制本身在 electron-builder 的 asarUnpack 里(app.asar.unpacked),
* 所以这里把路径改指向 unpacked。开发环境 / 服务器(没有 asar)原样返回。
*/
import { existsSync } from 'fs'

/** `…/app.asar/…` → `…/app.asar.unpacked/…`(导出给测试) */
export function unpackedAsarPath(p: string): string {
return p.replace(/([\\/])app\.asar([\\/])/, '$1app.asar.unpacked$2')
}

let cached: string | null | undefined

export function getFfmpegPath(): string | null {
if (cached === undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const p = (require('ffmpeg-static') as string | null) || null
const unpacked = p ? unpackedAsarPath(p) : null
cached = unpacked && unpacked !== p && existsSync(unpacked) ? unpacked : p
} catch {
cached = null
}
}
return cached
}
9 changes: 5 additions & 4 deletions src/main/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { IPC_CHANNELS, type MenuCommand } from '../shared/types'
import { t } from '../shared/i18n'
import { getFfmpegPath } from '../core/video/ffmpegPath'

const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))

Expand Down Expand Up @@ -436,8 +437,8 @@ const VIDEO_SCENES: Scene[] = [
export async function runRecording(win: BrowserWindow, outPath: string, hooks: CaptureHooks = {}): Promise<void> {
if (process.env.VIXEL_PROMO === 'panel' && hooks.quickPanel) return runPanelRecording(win, outPath, hooks.quickPanel)
const { spawn } = await import('child_process')
const mod = (await import('ffmpeg-static')) as unknown as { default?: string } & string
const ffmpeg = (mod.default ?? mod) as string
const ffmpeg = getFfmpegPath()
if (!ffmpeg) throw new Error('ffmpeg-static not available')
const FPS = 30
win.setContentSize(1280, 720)
win.center()
Expand Down Expand Up @@ -492,8 +493,8 @@ export async function runRecording(win: BrowserWindow, outPath: string, hooks: C

async function runPanelRecording(win: BrowserWindow, outPath: string, qp: { show: () => BrowserWindow; hide: () => void }): Promise<void> {
const { spawn } = await import('child_process')
const mod = (await import('ffmpeg-static')) as unknown as { default?: string } & string
const ffmpeg = (mod.default ?? mod) as string
const ffmpeg = getFfmpegPath()
if (!ffmpeg) throw new Error('ffmpeg-static not available')
const sharp = (await import('sharp')).default
const FPS = 30
const W = 1920
Expand Down
Loading