Merge branch 'release/2.3.2'

v2.3.2:修后端启动崩溃(ctranslate2 4.6.0) + whisper 模型路径 + 桌面端版本号。
This commit is contained in:
huangjianwu
2026-05-22 11:41:04 +08:00
7 changed files with 102 additions and 68 deletions

View File

@@ -79,6 +79,19 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# 从 tag 注入版本号到 tauri.conf.jsonTauri 取该文件的静态 version 作为
# 产物版本,不同步的话构建产物会恒为 conf 里写死的值(此前的 2.0.0)。
# github.ref_name 形如 v2.3.2,去掉前缀 v。workflow_dispatch无 tag时跳过保留静态值。
- name: Sync version from tag
if: startsWith(github.ref, 'refs/tags/v')
working-directory: BillNote_frontend
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "Injecting version $VERSION into tauri.conf.json"
node -e "const f='src-tauri/tauri.conf.json'; const fs=require('fs'); const j=JSON.parse(fs.readFileSync(f,'utf8')); j.version=process.argv[1]; fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');" "$VERSION"
node -e "console.log('tauri.conf.json version =', require('./src-tauri/tauri.conf.json').version)"
# 打包 Tauri 应用
- name: Build Tauri App
working-directory: BillNote_frontend

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "2.0.0",
"version": "2.3.2",
"identifier": "com.jefferyhuang.bilinote",
"build": {
"frontendDist": "../dist",

View File

@@ -2,6 +2,14 @@
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [2.3.2] - 2026-05-22
### Fixed
- **后端启动崩溃Docker**`python:3.11-slim` 基础镜像升级到 Debian 13 / glibc 2.41 后,`ctranslate2` 4.5.0 预编译库带「可执行栈」标记被 glibc 拒绝加载(`cannot enable executable stack ... Invalid argument`)。由于 `from faster_whisper import WhisperModel` 在顶层 importimport 失败直接拖垮整个后端启动 → 容器反复重启。升级 `ctranslate2` 4.5.0→4.6.0wheel 加入 `noexecstack` 链接标志,从二进制层根治)
- **whisper 模型误报「离线模式找不到模型」**下载modelscope 自定义目录与加载faster-whisper HF cache布局不一致导致命不中缓存。统一为下载 / 加载 / 完整性检测 / 损坏自愈都走 HF cache 布局,并向后兼容老 modelscope 目录
- **桌面端构建产物版本恒为 2.0.0**Release 工作流在 `pnpm tauri build` 前从 git tag 注入版本号到 `tauri.conf.json`,使产物版本与 Release 版本对齐
## [2.3.1] - 2026-05-22
### Changed

View File

@@ -3,7 +3,7 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v2.3.1</h1>
<h1 align="center" > BiliNote v2.3.2</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>

View File

@@ -119,12 +119,21 @@ _downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
"""检查指定 whisper 模型是否已下载完整到本地。
必须 model.bin 落盘才算完成,仅有空目录或半成品不能算「已下载」——
否则监控页会显示绿勾但加载时报「Unable to open file 'model.bin'」。
faster-whisper 把模型缓存在 HF cache 布局下:
<model_dir>/models--Systran--faster-whisper-{size}/snapshots/<hash>/model.bin
必须能在某个 snapshot 目录里找到 model.bin 才算完成。
(历史 modelscope 布局 <model_dir>/whisper-{size}/model.bin 也兼容识别。)
"""
model_dir = get_model_dir(subdir)
model_path = os.path.join(model_dir, f"whisper-{model_size}")
return (Path(model_path) / "model.bin").exists()
model_dir = Path(get_model_dir(subdir))
# HF cache 布局
hf_repo_dir = model_dir / f"models--Systran--faster-whisper-{model_size}" / "snapshots"
if hf_repo_dir.exists():
for snapshot in hf_repo_dir.iterdir():
if (snapshot / "model.bin").exists():
return True
# 历史 modelscope 布局(向后兼容老用户)
legacy = model_dir / f"whisper-{model_size}" / "model.bin"
return legacy.exists()
def _check_mlx_whisper_model_exists(model_size: str) -> bool:
@@ -189,24 +198,37 @@ class ModelDownloadRequest(BaseModel):
def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型。"""
from app.transcriber.whisper import MODEL_MAP
from modelscope import snapshot_download
"""后台下载 faster-whisper 模型。
直接走 huggingface_hub.snapshot_download把模型放到 HF cache 布局里——
这样 faster-whisper 加载时WhisperModel(model_size_or_path=size_name,
download_root=model_dir))能直接命中缓存,跟加载路径完全对齐。
"""
from huggingface_hub import snapshot_download
try:
_downloading[model_size] = "downloading"
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
# 用 model.bin 判定而非目录存在:半成品目录不能算「已下载」
if (Path(model_path) / "model.bin").exists():
# 已经下好就不重复下
if _check_whisper_model_exists(model_size, "whisper"):
_downloading[model_size] = "done"
return
repo_id = MODEL_MAP.get(model_size)
if not repo_id:
_downloading[model_size] = "failed"
return
logger.info(f"开始下载 whisper 模型: {model_size}")
snapshot_download(repo_id, local_dir=model_path)
repo_id = f"Systran/faster-whisper-{model_size}"
logger.info(f"开始下载 whisper 模型: {repo_id}")
# 跟 faster-whisper utils.py 用同样的 allow_patterns避免多下无关文件
# 不传 local_dir 让它走 HF 默认 cache 布局(与加载逻辑对齐)
snapshot_download(
repo_id,
cache_dir=model_dir,
allow_patterns=[
"config.json",
"preprocessor_config.json",
"model.bin",
"tokenizer.json",
"vocabulary.*",
],
)
logger.info(f"whisper 模型下载完成: {model_size}")
_downloading[model_size] = "done"
except Exception as e:

View File

@@ -11,8 +11,6 @@ from events import transcription_finished
from pathlib import Path
import os
import shutil
from tqdm import tqdm
from modelscope import snapshot_download
'''
@@ -20,19 +18,16 @@ from modelscope import snapshot_download
'''
logger=get_logger(__name__)
MODEL_MAP={
"tiny": "pengzhendong/faster-whisper-tiny",
'base':'pengzhendong/faster-whisper-base',
'small':'pengzhendong/faster-whisper-small',
'medium':'pengzhendong/faster-whisper-medium',
'large-v1':'pengzhendong/faster-whisper-large-v1',
'large-v2':'pengzhendong/faster-whisper-large-v2',
'large-v3':'pengzhendong/faster-whisper-large-v3',
'large-v3-turbo':'pengzhendong/faster-whisper-large-v3-turbo',
}
# 历史遗留:之前用 modelscope 下载到自定义目录然后把路径传给 WhisperModel。
# 但 faster-whisper 1.1.1 的 download_modelutils.py:76逻辑是
# 只要 size_or_id 里含 "/" 就当 HF repo_id 处理,没有「本地目录直接返回」分支。
# 我们传 /app/models/whisper/whisper-tiny 进去 → 被当成不存在的 HF repo →
# 在线请求失败 → fallback local_files_only=True → HF cache 找不到(因为是
# modelscope 目录布局不是 HF→ LocalEntryNotFoundError误导说"离线模式"。
# 解法:彻底让 faster-whisper 自己处理下载——传 size name配 download_root
# 作为 HF cache 根目录HF_ENDPOINT 已经在 Dockerfile 里指到 hf-mirror.com
# 国内能用。删掉 modelscope 那一套,避免布局不匹配。
class WhisperTranscriber(Transcriber):
# TODO:修改为可配置
def __init__(
self,
model_size: str = "base",
@@ -48,44 +43,40 @@ class WhisperTranscriber(Transcriber):
print('没有 cuda 使用 cpu进行计算')
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
self.model_size = model_size
model_dir = get_model_dir("whisper")
model_path = os.path.join(model_dir, f"whisper-{model_size}")
repo_id = MODEL_MAP[model_size]
# 第一步:目录 / model.bin 不在 → 下载。
# 关键判据用 model.bin 而不是目录存在:首次下载若被打断(网络中断 / 磁盘满 /
# 容器被 kill会留下半成品目录只看目录存在会跳过下载。
model_bin = Path(model_path) / "model.bin"
if not model_bin.exists():
if Path(model_path).exists():
logger.warning(f"模型目录 {model_path} 存在但 model.bin 缺失(上次下载未完成),重新下载")
else:
logger.info(f"模型 whisper-{model_size} 不存在,开始下载...")
model_path = snapshot_download(repo_id, local_dir=model_path)
logger.info("模型下载完成")
# 第二步加载。model.bin 可能存在但【内容截断】(下载到一半被 kill
# 此时 WhisperModel() 会抛 "File model.bin is incomplete: failed to read a buffer..."。
# 捕获后删掉损坏目录、重新下载、再试一次——自愈,避免 500 死循环。
try:
self.model = WhisperModel(
model_size_or_path=model_path,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir,
)
self.model = self._build_model(model_size, model_dir)
except Exception as e:
logger.warning(f"加载 whisper-{model_size} 失败(疑似模型文件损坏 / 截断):{e};删除后重新下载")
shutil.rmtree(model_path, ignore_errors=True)
model_path = snapshot_download(repo_id, local_dir=model_path)
logger.info("模型重新下载完成,重试加载")
self.model = WhisperModel(
model_size_or_path=model_path,
device=self.device,
compute_type=self.compute_type,
download_root=model_dir,
)
# 自愈:损坏 / 截断 / 半成品 cache → 删掉对应 HF cache 重下一次
logger.warning(f"加载 whisper-{model_size} 失败:{e};清理 cache 后重新下载")
self._purge_cache(model_dir, model_size)
self.model = self._build_model(model_size, model_dir)
def _build_model(self, model_size: str, model_dir: str) -> WhisperModel:
return WhisperModel(
model_size_or_path=model_size, # 传 size name让 faster-whisper 自己映射到 Systran/faster-whisper-*
device=self.device,
compute_type=self.compute_type,
download_root=model_dir,
)
@staticmethod
def _purge_cache(model_dir: str, model_size: str) -> None:
"""删掉 HF cache 里这个 size 对应的 snapshot 目录,强制下次重新下载。
HF cache 布局:<model_dir>/models--Systran--faster-whisper-{size}/
没找到也不报错——可能用户改了 endpoint 或者 cache 布局变了。
"""
candidates = [
Path(model_dir) / f"models--Systran--faster-whisper-{model_size}",
Path(model_dir) / f"whisper-{model_size}", # 历史 modelscope 目录,顺手清掉
]
for path in candidates:
if path.exists():
logger.info(f"清理损坏 cache: {path}")
shutil.rmtree(path, ignore_errors=True)
@staticmethod
def is_torch_installed() -> bool:
try:

View File

@@ -24,7 +24,7 @@ click-repl==0.3.0
colorama==0.4.6
coloredlogs==15.0.1
cssselect2==0.8.0
ctranslate2==4.5.0
ctranslate2==4.6.0
distro==1.9.0
dnspython==2.7.0
email_validator==2.2.0