From d8038059471ae20603d1556cf1ba11bc5745adaa Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:24:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(desktop):=20videos/audio=20never=20inde?= =?UTF-8?q?xed=20in=20packaged=20apps=20=E2=80=94=20spawn=20ffmpeg=20from?= =?UTF-8?q?=20app.asar.unpacked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require('ffmpeg-static') resolves inside app.asar; Electron patches execFile for asar but not spawn, so every packaged release since 0.2.0 failed all video/audio with spawn ENOTDIR (macOS) / ENOENT (Windows). Dev builds and Docker have no asar, so tests missed it. Shared getFfmpegPath() maps to app.asar.unpacked; on startup, videos that failed this way are reset and re-indexed. scripts/packaged-video-check.sh indexes videos with the packaged app. Version 0.5.1. --- CHANGELOG.md | 12 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- scripts/packaged-video-check.sh | 35 +++++++++++++++++++++++++++++++ src/core/audio/extract.ts | 14 +------------ src/core/db.ts | 16 +++++++++++++- src/core/video/extract.ts | 16 +------------- src/core/video/ffmpegPath.test.ts | 15 +++++++++++++ src/core/video/ffmpegPath.ts | 30 ++++++++++++++++++++++++++ src/main/capture.ts | 9 ++++---- 10 files changed, 117 insertions(+), 36 deletions(-) create mode 100755 scripts/packaged-video-check.sh create mode 100644 src/core/video/ffmpegPath.test.ts create mode 100644 src/core/video/ffmpegPath.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c212ac5..52acbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 26e3db8..061fb46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vixel", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vixel", - "version": "0.5.0", + "version": "0.5.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8a5c53d..bd8264c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/packaged-video-check.sh b/scripts/packaged-video-check.sh new file mode 100755 index 0000000..734c80a --- /dev/null +++ b/scripts/packaged-video-check.sh @@ -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" diff --git a/src/core/audio/extract.ts b/src/core/audio/extract.ts index 860ac4e..41d206b 100644 --- a/src/core/audio/extract.ts +++ b/src/core/audio/extract.ts @@ -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 diff --git a/src/core/db.ts b/src/core/db.ts index 1fb34e2..9ac2612 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -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 @@ -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) { diff --git a/src/core/video/extract.ts b/src/core/video/extract.ts index d68e7a1..db80c4a 100644 --- a/src/core/video/extract.ts +++ b/src/core/video/extract.ts @@ -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 } diff --git a/src/core/video/ffmpegPath.test.ts b/src/core/video/ffmpegPath.test.ts new file mode 100644 index 0000000..45a123a --- /dev/null +++ b/src/core/video/ffmpegPath.test.ts @@ -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') + }) +}) diff --git a/src/core/video/ffmpegPath.ts b/src/core/video/ffmpegPath.ts new file mode 100644 index 0000000..d2ebca5 --- /dev/null +++ b/src/core/video/ffmpegPath.ts @@ -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 +} diff --git a/src/main/capture.ts b/src/main/capture.ts index 99ecd15..7389077 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -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 => new Promise((r) => setTimeout(r, ms)) @@ -436,8 +437,8 @@ const VIDEO_SCENES: Scene[] = [ export async function runRecording(win: BrowserWindow, outPath: string, hooks: CaptureHooks = {}): Promise { 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() @@ -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 { 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 From c6bdb95f8af60991cb543a2a28b8b9aabed3227f Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:06:26 +0800 Subject: [PATCH 2/3] ci: run tests on Node 22 (server edition's pdf.js needs it) and fetch the ffmpeg binary skipped by --ignore-scripts --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f780bcc..a6f4b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 From f4c94ef3b01d403e330e1941cc65c00633c2969c Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:12:52 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20screenshot=20xattr=20setup=20ran=20?= =?UTF-8?q?at=20collection=20time=20on=20Linux=20(describe.runIf=20only=20?= =?UTF-8?q?skips=20tests)=20=E2=80=94=20move=20into=20beforeAll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/screenshot.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/core/screenshot.test.ts b/src/core/screenshot.test.ts index 2e9090f..dff942d 100644 --- a/src/core/screenshot.test.ts +++ b/src/core/screenshot.test.ts @@ -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' @@ -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)