Skip to content

Commit 7fd3e2f

Browse files
luongndclaude
andcommitted
fix(v1.2.11): bundle ffmpeg, drag window, transcript preserved on summary failure
Six accumulated fixes: 1) ffmpeg auto-bundle (zero manual setup) - Add imageio-ffmpeg to requirements.txt + collect_all() in spec. - find_ffmpeg() prefers the bundled static binary (resolved via imageio_ffmpeg.get_ffmpeg_exe()) over system PATH so end users don't need brew/choco install ffmpeg anymore. - Rewrite _ffprobe_duration to parse "Duration:" from ffmpeg stderr with early-terminate Popen (imageio-ffmpeg ships ffmpeg only, no ffprobe). - Cache result so audio call sites don't re-spawn. - Drop the now-redundant CI install/copy steps on Win/Mac/Linux that installed ffmpeg on the build runner. 2) Drag window - Add core:window:allow-start-dragging + toggle-maximize permissions to capabilities/default.json — they're NOT in core:default in Tauri 2.9. Without them startDragging() rejects silently; the click did nothing. - Make handleTopnavMouseDown fire-and-forget (drag-start must be called synchronously while OS still has mouse button held). - console.error with permission-hint instead of silent console.warn. 3) Upload + summary best-effort (transcript preserved on LLM failure) - Pipeline: pre-check llm_api_key; skip summarize with summary_skipped=true when missing — job still ends DONE with saved transcript. Summary errors also isolated in their own try/except so they never trash the upload pass. - JobState: add transcript_saved, summary_skipped, summary_skip_reason. - UploadAudioModal: pre-flight STT key check; recovery path when status=failed but transcript_saved=true; friendly "Transcript đã lưu" screen with hint to configure AI key in Settings. 4) Empty meeting cleanup + smart duplicate check - On pipeline failure or cancellation with no transcript: DELETE the meeting row + audio file instead of leaving it as 'failed'. Stops cluttering the meeting list and prevents poisoning the duplicate lookup for the user's next upload of the same file. - find_meeting_by_hash filters out empty/failed rows (transcript IS NULL OR empty), belt-and-suspenders for users who already have rows from earlier versions. 5) Realtime WS error toast - When openStreamingWebSocket returns null (all bases rejected), show a clear toast with provider name + recovery hint instead of silently downgrading to chunk mode. 6) Version bump 1.2.10 → 1.2.11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2a80fe2 commit 7fd3e2f

18 files changed

Lines changed: 522 additions & 97 deletions

.github/workflows/build-linux.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ jobs:
1515
- uses: actions/checkout@v4
1616

1717
- name: Install system dependencies
18+
# ffmpeg removed from this list (v1.2.11+): the sidecar bundles a
19+
# static ffmpeg binary via imageio-ffmpeg's wheel, so the runner
20+
# doesn't need its own. See src-python/services/audio.py find_ffmpeg().
1821
run: |
1922
sudo apt-get update
2023
sudo apt-get install -y \
@@ -23,8 +26,7 @@ jobs:
2326
patchelf \
2427
libssl-dev \
2528
libgtk-3-dev \
26-
libayatana-appindicator3-dev \
27-
ffmpeg
29+
libayatana-appindicator3-dev
2830
2931
- name: Setup Node.js
3032
uses: actions/setup-node@v4

.github/workflows/build-macos.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ jobs:
5454
- name: Install uv
5555
uses: astral-sh/setup-uv@v4
5656

57-
- name: Install ffmpeg
58-
run: brew install ffmpeg
57+
# ffmpeg is bundled via imageio-ffmpeg's Python wheel (v1.2.11+), so the
58+
# build runner doesn't need brew install ffmpeg anymore. The wheel ships
59+
# a static macOS-aarch64 binary that PyInstaller picks up via
60+
# collect_all('imageio_ffmpeg'). See src-python/services/audio.py.
5961

6062
- name: Install Python dependencies
6163
working-directory: src-python

.github/workflows/build.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ jobs:
3737
- name: Install uv
3838
uses: astral-sh/setup-uv@v4
3939

40-
- name: Install ffmpeg
41-
run: choco install ffmpeg -y
40+
# ffmpeg is bundled via imageio-ffmpeg's Python wheel (v1.2.11+), so the
41+
# build runner doesn't need choco install ffmpeg anymore. The wheel ships
42+
# a static ffmpeg.exe that PyInstaller picks up via
43+
# collect_all('imageio_ffmpeg'). See src-python/services/audio.py.
4244

4345
- name: Install Python dependencies
4446
working-directory: src-python
@@ -73,13 +75,11 @@ jobs:
7375
"%~dp0sidecar-dist\scribble-sidecar.exe" %*
7476
WINEOF
7577
76-
- name: Bundle ffmpeg for Windows
77-
shell: bash
78-
run: |
79-
FFMPEG_PATH=$(which ffmpeg || echo "")
80-
if [ -n "$FFMPEG_PATH" ]; then
81-
cp "$FFMPEG_PATH" src-tauri/binaries/ffmpeg.exe
82-
fi
78+
# "Bundle ffmpeg for Windows" step removed in v1.2.11. The file was
79+
# copied into src-tauri/binaries/ffmpeg.exe but never actually shipped
80+
# to end users — it wasn't listed in tauri.conf.json's externalBin or
81+
# resources. ffmpeg now ships inside the sidecar tarball via
82+
# imageio-ffmpeg, see src-python/services/audio.py find_ffmpeg().
8383

8484
- name: Install frontend dependencies
8585
run: pnpm install

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "voicescribe",
33
"private": true,
4-
"version": "1.2.10",
4+
"version": "1.2.11",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-python/db.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,12 +397,28 @@ def delete_meeting(self, mid: int):
397397
conn.commit()
398398

399399
def find_meeting_by_hash(self, file_hash: str) -> int | None:
400-
"""Idempotency lookup for uploaded files. Returns meeting_id if exists, else None."""
400+
"""Idempotency lookup for uploaded files. Returns meeting_id if a
401+
PREVIOUS SUCCESSFUL upload of the same file exists.
402+
403+
Only matches meetings that produced an actual transcript — failed /
404+
empty uploads from earlier attempts must NOT block a re-upload. This
405+
is belt-and-suspenders alongside the pipeline's failure-cleanup
406+
(which deletes empty meeting rows when no transcript was saved);
407+
we keep the filter here so users upgrading from older versions
408+
aren't blocked by leftover empty rows in their existing DB.
409+
"""
401410
if not file_hash:
402411
return None
403412
conn = self._conn()
404413
row = conn.execute(
405-
"SELECT id FROM meetings WHERE file_hash = ? LIMIT 1",
414+
"""
415+
SELECT id FROM meetings
416+
WHERE file_hash = ?
417+
AND transcript IS NOT NULL
418+
AND TRIM(transcript) NOT IN ('', '[]')
419+
ORDER BY id DESC
420+
LIMIT 1
421+
""",
406422
(file_hash,),
407423
).fetchone()
408424
return row["id"] if row else None

src-python/requirements.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,10 @@ requests
1111
soniox>=2.0.0
1212
python-docx>=1.1.2
1313
onnxruntime>=1.17.0
14+
# imageio-ffmpeg ships a static ffmpeg binary inside its wheel — auto-bundled
15+
# by PyInstaller via collect_all() in the spec. Eliminates the "brew install
16+
# ffmpeg" / "choco install ffmpeg" prerequisite for end users (zero manual
17+
# setup). ffprobe is NOT included; we parse "Duration:" from ffmpeg stderr
18+
# instead (see _ffprobe_duration in upload_pipeline.py).
19+
imageio-ffmpeg>=0.5.1
1420
pyinstaller>=6.0

src-python/scribble-sidecar.spec

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
5353
# fails CERTIFICATE_VERIFY_FAILED on TLS handshake to wss://stt-rt.soniox.com.
5454
tmp_ret = collect_all('certifi')
5555
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
56+
# imageio-ffmpeg bundles a static ffmpeg binary in its wheel. collect_all
57+
# pulls the binary file in as `datas`; at runtime find_ffmpeg() calls
58+
# imageio_ffmpeg.get_ffmpeg_exe() to resolve the absolute path inside the
59+
# PyInstaller _MEIPASS / dist directory. Zero manual setup — no more
60+
# "brew install ffmpeg" prerequisite for end users.
61+
tmp_ret = collect_all('imageio_ffmpeg')
62+
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
5663

5764
# ── Aggressive excludes ──
5865
# These are transitive deps pulled in by collect_all('riva') and other packages.

src-python/services/audio.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,70 @@
1212
log = get_logger(__name__)
1313

1414

15+
_FFMPEG_CACHE: str | None = None
16+
17+
1518
def find_ffmpeg() -> str:
16-
"""Find ffmpeg binary, searching common locations on macOS/Linux/Windows."""
19+
"""Resolve an ffmpeg binary path, preferring the bundled one.
20+
21+
Resolution order (first hit wins):
22+
1. imageio-ffmpeg's bundled static binary — ships in the Python wheel
23+
and is picked up by PyInstaller via `collect_all('imageio_ffmpeg')`.
24+
This is the path end users hit in a packaged install (no homebrew /
25+
choco required — zero manual setup).
26+
2. ffmpeg sitting next to the sidecar executable — Windows CI copies
27+
the build-host's ffmpeg.exe here as a legacy fallback.
28+
3. System PATH (`shutil.which`).
29+
4. Common per-OS install locations (Homebrew prefix, ProgramFiles,
30+
Scoop, etc.) for dev environments that have ffmpeg installed but
31+
not in the inherited PATH (Tauri-launched apps on macOS get a
32+
stripped PATH that excludes /opt/homebrew/bin).
33+
34+
Cached after first lookup so we don't subprocess-spawn ffmpeg dozens of
35+
times for the import-resolution side effect on every audio call.
36+
"""
37+
global _FFMPEG_CACHE
38+
if _FFMPEG_CACHE and os.path.isfile(_FFMPEG_CACHE):
39+
return _FFMPEG_CACHE
40+
1741
import shutil
1842
import sys as _sys
1943

20-
found = shutil.which("ffmpeg")
21-
if found:
22-
return found
23-
24-
# Check next to the running sidecar executable (PyInstaller bundle)
44+
# 1. Prefer the bundled static binary from imageio-ffmpeg. This is the
45+
# path that makes the app actually self-contained for end users.
46+
try:
47+
import imageio_ffmpeg
48+
bundled = imageio_ffmpeg.get_ffmpeg_exe()
49+
if bundled and os.path.isfile(bundled):
50+
# Defensive: ensure exec bit (PyInstaller _MEIPASS preserves it
51+
# but the static-binary may land in a tarball-extracted dir
52+
# without +x on Linux).
53+
if not _sys.platform == "win32":
54+
try:
55+
os.chmod(bundled, 0o755)
56+
except OSError:
57+
pass
58+
_FFMPEG_CACHE = bundled
59+
log.info("[ffmpeg] Using bundled imageio-ffmpeg binary: %s", bundled)
60+
return bundled
61+
except Exception as e:
62+
log.warning("[ffmpeg] imageio-ffmpeg not available, falling back to system search: %s", e)
63+
64+
# 2. Next to the sidecar executable (CI bundling path for Windows).
2565
exe_dir = Path(_sys.executable).parent
2666
for name in ("ffmpeg.exe", "ffmpeg"):
2767
candidate = exe_dir / name
2868
if candidate.is_file():
29-
return str(candidate)
69+
_FFMPEG_CACHE = str(candidate)
70+
return _FFMPEG_CACHE
3071

72+
# 3. System PATH.
73+
found = shutil.which("ffmpeg")
74+
if found:
75+
_FFMPEG_CACHE = found
76+
return found
77+
78+
# 4. Common install locations (dev fallback).
3179
if _sys.platform == "win32":
3280
win_candidates = [
3381
Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "ffmpeg" / "bin" / "ffmpeg.exe",
@@ -40,18 +88,25 @@ def find_ffmpeg() -> str:
4088
for c in win_candidates:
4189
try:
4290
if c.is_file():
43-
return str(c)
91+
_FFMPEG_CACHE = str(c)
92+
return _FFMPEG_CACHE
4493
except Exception:
4594
pass
4695
raise FileNotFoundError(
47-
"ffmpeg not found. Install via: choco install ffmpeg OR "
48-
"scoop install ffmpeg OR download from https://ffmpeg.org"
96+
"ffmpeg binary not found in the app bundle (this should never happen — "
97+
"imageio-ffmpeg ships it with the sidecar). Reinstall Scribble from "
98+
"https://scribble-rho.vercel.app or contact support."
4999
)
50100
else:
51101
for candidate in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]:
52102
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
103+
_FFMPEG_CACHE = candidate
53104
return candidate
54-
raise FileNotFoundError("ffmpeg not found. Install via: brew install ffmpeg")
105+
raise FileNotFoundError(
106+
"ffmpeg binary not found in the app bundle (this should never happen — "
107+
"imageio-ffmpeg ships it with the sidecar). Reinstall Scribble from "
108+
"https://scribble-rho.vercel.app or contact support."
109+
)
55110

56111

57112
def transcode_audio_for_export(source: Path, fmt: str) -> Path:

src-python/services/job_registry.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ class JobState:
4040
error: str | None = None
4141
total_chunks: int = 0
4242
processed_chunks: int = 0
43+
# Set when the LLM summarize step is intentionally skipped (no API key)
44+
# or fails — the pipeline still ends in DONE with a saved transcript.
45+
# Frontend uses this to show a friendly "transcript saved, configure LLM
46+
# to enable auto-minutes" message instead of an error.
47+
summary_skipped: bool = False
48+
summary_skip_reason: str = ""
49+
# True once the transcript JSON has been written to DB. Lets the UI
50+
# offer "Open meeting" even if the job later ends in FAILED — so users
51+
# never lose their transcript work even when a downstream step crashes.
52+
transcript_saved: bool = False
4353
created_at: float = field(default_factory=time.time)
4454
updated_at: float = field(default_factory=time.time)
4555

@@ -56,6 +66,9 @@ def to_dict(self) -> dict[str, Any]:
5666
"error": self.error,
5767
"total_chunks": self.total_chunks,
5868
"processed_chunks": self.processed_chunks,
69+
"summary_skipped": self.summary_skipped,
70+
"summary_skip_reason": self.summary_skip_reason,
71+
"transcript_saved": self.transcript_saved,
5972
"created_at": self.created_at,
6073
"updated_at": self.updated_at,
6174
}

0 commit comments

Comments
 (0)