不重要了
@ -43,7 +43,7 @@ npm install
|
||||
```bash
|
||||
export OPENAI_API_BASE=https://your-openai-compatible-endpoint/v1
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
export OPENAI_MODEL=gpt-4o-mini
|
||||
export OPENAI_MODEL=GLM-4-Flash-250414
|
||||
```
|
||||
|
||||
6. 若要真正启用 ONNX Runtime 推理,请确保本机存在可被 `ort` 动态加载的 ONNX Runtime 库,或按你的部署方式提供运行库。
|
||||
|
||||
@ -5,9 +5,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"tauri": "tauri dev",
|
||||
"tauri-dev": "tauri dev",
|
||||
"tauri": "tauri",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"prepare-ffmpeg-macos": "sh ./scripts/prepare-bundled-ffmpeg.sh",
|
||||
"prepare-licenses-macos": "python3 ./scripts/prepare-bundled-licenses.py",
|
||||
"tauri-build-app": "npm run prepare-ffmpeg-macos && npm run prepare-licenses-macos && tauri build --bundles app",
|
||||
"tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
|
||||
35
scripts/build-macos-dmg.sh
Normal file
@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
APP_NAME="CrossSubtitle-AI"
|
||||
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
|
||||
ARCH=$(uname -m)
|
||||
APP_PATH="$ROOT_DIR/src-tauri/target/release/bundle/macos/$APP_NAME.app"
|
||||
DMG_DIR="$ROOT_DIR/src-tauri/target/release/bundle/dmg"
|
||||
DMG_PATH="$DMG_DIR/${APP_NAME}_${VERSION}_${ARCH}.dmg"
|
||||
STAGING_DIR=$(mktemp -d "${TMPDIR:-/tmp}/crosssubtitle-dmg.XXXXXX")
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STAGING_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
npm run tauri-build-app
|
||||
|
||||
mkdir -p "$DMG_DIR"
|
||||
rm -f "$DMG_PATH"
|
||||
ditto "$APP_PATH" "$STAGING_DIR/$APP_NAME.app"
|
||||
ln -s /Applications "$STAGING_DIR/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME" \
|
||||
-srcfolder "$STAGING_DIR" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$DMG_PATH"
|
||||
|
||||
printf 'DMG created: %s\n' "$DMG_PATH"
|
||||
156
scripts/prepare-bundled-ffmpeg.sh
Normal file
@ -0,0 +1,156 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
TARGET_ARCH=$(uname -m)
|
||||
FFMPEG_SOURCE=${FFMPEG_SOURCE_PATH:-$(command -v ffmpeg)}
|
||||
VENDOR_ROOT="$ROOT_DIR/src-tauri/vendor/ffmpeg/macos-$TARGET_ARCH"
|
||||
BIN_DIR="$VENDOR_ROOT/bin"
|
||||
LIB_DIR="$VENDOR_ROOT/lib"
|
||||
MARK_DIR="$VENDOR_ROOT/.processed"
|
||||
|
||||
rm -rf "$VENDOR_ROOT"
|
||||
mkdir -p "$BIN_DIR" "$LIB_DIR" "$MARK_DIR"
|
||||
|
||||
copy_binary() {
|
||||
src_path=$1
|
||||
dest_path=$2
|
||||
cp -fL "$src_path" "$dest_path"
|
||||
chmod 755 "$dest_path"
|
||||
}
|
||||
|
||||
resolve_dep_source() {
|
||||
file_path=$1
|
||||
dep_path=$2
|
||||
|
||||
case "$dep_path" in
|
||||
/System/*|/usr/lib/*)
|
||||
return 1
|
||||
;;
|
||||
/*)
|
||||
if [ -f "$dep_path" ]; then
|
||||
printf '%s\n' "$dep_path"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
@loader_path/*)
|
||||
candidate="$(dirname "$file_path")/$(basename "$dep_path")"
|
||||
if [ -f "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
@executable_path/*)
|
||||
candidate="$LIB_DIR/$(basename "$dep_path")"
|
||||
if [ -f "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
@rpath/*)
|
||||
dep_name=$(basename "$dep_path")
|
||||
for candidate in \
|
||||
"$LIB_DIR/$dep_name" \
|
||||
"/opt/homebrew/lib/$dep_name" \
|
||||
$(find /opt/homebrew/Cellar -path "*/lib/$dep_name" -print 2>/dev/null)
|
||||
do
|
||||
if [ -n "$candidate" ] && [ -f "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
marker_path() {
|
||||
printf '%s' "$1" | shasum -a 1 | awk '{print "'"$MARK_DIR"'/" $1 ".done"}'
|
||||
}
|
||||
|
||||
process_file() {
|
||||
file_path=$1
|
||||
loader_prefix=$2
|
||||
mark_file=$(marker_path "$file_path")
|
||||
|
||||
if [ -f "$mark_file" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
: > "$mark_file"
|
||||
|
||||
otool -L "$file_path" | tail -n +2 | awk '{print $1}' | while read -r dep_path; do
|
||||
resolved_dep=$(resolve_dep_source "$file_path" "$dep_path" || true)
|
||||
if [ -z "$resolved_dep" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
dep_name=$(basename "$resolved_dep")
|
||||
vendored_dep="$LIB_DIR/$dep_name"
|
||||
|
||||
if [ ! -f "$vendored_dep" ]; then
|
||||
copy_binary "$resolved_dep" "$vendored_dep"
|
||||
fi
|
||||
|
||||
install_name_tool -change "$dep_path" "$loader_prefix/$dep_name" "$file_path"
|
||||
process_file "$vendored_dep" "@loader_path"
|
||||
done
|
||||
|
||||
if [ "$loader_prefix" = "@loader_path" ]; then
|
||||
install_name_tool -id "@loader_path/$(basename "$file_path")" "$file_path"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_file() {
|
||||
file_path=$1
|
||||
loader_prefix=$2
|
||||
|
||||
otool -L "$file_path" | tail -n +2 | awk '{print $1}' | while read -r dep_path; do
|
||||
case "$dep_path" in
|
||||
/System/*|/usr/lib/*)
|
||||
continue
|
||||
;;
|
||||
@executable_path/*|@loader_path/*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
dep_name=$(basename "$dep_path")
|
||||
vendored_dep="$LIB_DIR/$dep_name"
|
||||
if [ -f "$vendored_dep" ]; then
|
||||
install_name_tool -change "$dep_path" "$loader_prefix/$dep_name" "$file_path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$loader_prefix" = "@loader_path" ]; then
|
||||
install_name_tool -id "@loader_path/$(basename "$file_path")" "$file_path"
|
||||
fi
|
||||
}
|
||||
|
||||
sign_file() {
|
||||
file_path=$1
|
||||
codesign --force --sign - --timestamp=none "$file_path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "Bundling ffmpeg from: $FFMPEG_SOURCE"
|
||||
copy_binary "$FFMPEG_SOURCE" "$BIN_DIR/ffmpeg"
|
||||
process_file "$BIN_DIR/ffmpeg" "@executable_path/../lib"
|
||||
normalize_file "$BIN_DIR/ffmpeg" "@executable_path/../lib"
|
||||
|
||||
find "$LIB_DIR" -type f -name '*.dylib' | while read -r dylib_path; do
|
||||
normalize_file "$dylib_path" "@loader_path"
|
||||
done
|
||||
|
||||
find "$LIB_DIR" -type f -name '*.dylib' | while read -r dylib_path; do
|
||||
normalize_file "$dylib_path" "@loader_path"
|
||||
done
|
||||
|
||||
find "$LIB_DIR" -type f -name '*.dylib' | while read -r dylib_path; do
|
||||
sign_file "$dylib_path"
|
||||
done
|
||||
|
||||
sign_file "$BIN_DIR/ffmpeg"
|
||||
|
||||
echo "Bundled ffmpeg ready at: $VENDOR_ROOT"
|
||||
119
scripts/prepare-bundled-licenses.py
Normal file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
CELLAR_DIR = Path("/opt/homebrew/Cellar")
|
||||
LICENSE_ROOT = ROOT_DIR / "src-tauri" / "vendor" / "licenses" / "homebrew"
|
||||
LICENSE_BUNDLE_ROOT = ROOT_DIR / "src-tauri" / "vendor" / "licenses_bundle"
|
||||
FFMPEG_RECEIPT = sorted(CELLAR_DIR.glob("ffmpeg/*/INSTALL_RECEIPT.json"))[-1]
|
||||
LICENSE_PATTERNS = [
|
||||
"LICENSE*",
|
||||
"LICENCE*",
|
||||
"COPYING*",
|
||||
"COPYRIGHT*",
|
||||
"NOTICE*",
|
||||
]
|
||||
|
||||
|
||||
def load_runtime_formulas() -> list[tuple[str, str | None]]:
|
||||
data = json.loads(FFMPEG_RECEIPT.read_text())
|
||||
formulas: list[tuple[str, str | None]] = [("ffmpeg", None)]
|
||||
for item in data.get("runtime_dependencies", []):
|
||||
full_name = item.get("full_name")
|
||||
version = item.get("version")
|
||||
if full_name:
|
||||
formulas.append((full_name, version))
|
||||
return formulas
|
||||
|
||||
|
||||
def resolve_formula_dir(name: str, version: str | None) -> Path | None:
|
||||
if version:
|
||||
exact = CELLAR_DIR / name / version
|
||||
if exact.exists():
|
||||
return exact
|
||||
matches = sorted((CELLAR_DIR / name).glob("*"))
|
||||
return matches[-1] if matches else None
|
||||
|
||||
|
||||
def collect_license_files(formula_dir: Path) -> list[Path]:
|
||||
found: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for pattern in LICENSE_PATTERNS:
|
||||
for path in sorted(formula_dir.rglob(pattern)):
|
||||
if path.is_file() and path not in seen:
|
||||
seen.add(path)
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if LICENSE_ROOT.exists():
|
||||
shutil.rmtree(LICENSE_ROOT)
|
||||
LICENSE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
LICENSE_BUNDLE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest_lines = [
|
||||
"CrossSubtitle-AI Third-Party Notices",
|
||||
"",
|
||||
"This directory contains license files collected from Homebrew formulas",
|
||||
"used by the bundled ffmpeg binary and its runtime dependencies.",
|
||||
"",
|
||||
]
|
||||
|
||||
for formula_name, version in load_runtime_formulas():
|
||||
formula_dir = resolve_formula_dir(formula_name, version)
|
||||
target_dir = LICENSE_ROOT / formula_name.replace("@", "_at_")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_dir.chmod(0o755)
|
||||
|
||||
if formula_dir is None:
|
||||
manifest_lines.append(f"- {formula_name}: formula directory not found")
|
||||
continue
|
||||
|
||||
copied: list[str] = []
|
||||
for license_file in collect_license_files(formula_dir):
|
||||
destination = target_dir / license_file.name
|
||||
if destination.exists():
|
||||
destination.chmod(0o644)
|
||||
shutil.copy2(license_file, destination)
|
||||
destination.chmod(0o644)
|
||||
copied.append(license_file.name)
|
||||
|
||||
actual_version = formula_dir.name
|
||||
if copied:
|
||||
manifest_lines.append(
|
||||
f"- {formula_name} ({actual_version}): {', '.join(sorted(copied))}"
|
||||
)
|
||||
else:
|
||||
note = target_dir / "MISSING_LICENSE.txt"
|
||||
note.write_text(
|
||||
f"No license file matching common patterns was found in {formula_dir}\n"
|
||||
)
|
||||
manifest_lines.append(
|
||||
f"- {formula_name} ({actual_version}): no matching license file found"
|
||||
)
|
||||
|
||||
manifest_path = LICENSE_ROOT / "THIRD_PARTY_NOTICES.txt"
|
||||
manifest_path.write_text("\n".join(manifest_lines) + "\n")
|
||||
manifest_path.chmod(0o644)
|
||||
|
||||
bundle_manifest = LICENSE_BUNDLE_ROOT / "THIRD_PARTY_NOTICES.txt"
|
||||
shutil.copy2(manifest_path, bundle_manifest)
|
||||
bundle_manifest.chmod(0o644)
|
||||
|
||||
archive_base = LICENSE_BUNDLE_ROOT / "homebrew-licenses"
|
||||
archive_path = shutil.make_archive(str(archive_base), "zip", LICENSE_ROOT)
|
||||
Path(archive_path).chmod(0o644)
|
||||
|
||||
print(f"Prepared bundled licenses at: {LICENSE_ROOT}")
|
||||
print(f"Prepared bundled license archive at: {archive_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 194 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 104 B After Width: | Height: | Size: 264 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 770 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@ -9,12 +9,19 @@ use anyhow::{anyhow, Context, Result};
|
||||
pub struct AudioPipeline;
|
||||
|
||||
impl AudioPipeline {
|
||||
pub fn extract_to_wav(input_path: &str, workspace: &Path) -> Result<PathBuf> {
|
||||
pub fn extract_to_wav(ffmpeg_path: &Path, input_path: &str, workspace: &Path) -> Result<PathBuf> {
|
||||
fs::create_dir_all(workspace)
|
||||
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
||||
|
||||
let output_path = workspace.join("normalized.wav");
|
||||
let status = Command::new("ffmpeg")
|
||||
let mut command = Command::new(ffmpeg_path);
|
||||
if let Some(lib_dir) = ffmpeg_path.parent().and_then(|bin_dir| bin_dir.parent()).map(|root| root.join("lib")) {
|
||||
if lib_dir.exists() {
|
||||
command.env("DYLD_FALLBACK_LIBRARY_PATH", &lib_dir);
|
||||
}
|
||||
}
|
||||
|
||||
let output = command
|
||||
.arg("-y")
|
||||
.arg("-i")
|
||||
.arg(input_path)
|
||||
@ -25,11 +32,19 @@ impl AudioPipeline {
|
||||
.arg("-f")
|
||||
.arg("wav")
|
||||
.arg(&output_path)
|
||||
.status()
|
||||
.context("failed to launch ffmpeg, please install ffmpeg and ensure it is in PATH")?;
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("ffmpeg exited with status: {status}"));
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
return Err(anyhow!("ffmpeg exited with status: {}", output.status));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"ffmpeg exited with status: {} | stderr: {}",
|
||||
output.status,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(output_path)
|
||||
|
||||
@ -7,7 +7,7 @@ mod translate;
|
||||
mod vad;
|
||||
mod whisper;
|
||||
|
||||
use models::{StartTaskPayload, SubtitleSegment, SubtitleTask};
|
||||
use models::{DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask};
|
||||
use state::AppState;
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
||||
@ -59,6 +59,11 @@ fn export_subtitles(
|
||||
task::export_task(state, task_id, format).map_err(error_to_string)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_default_model_paths(app: tauri::AppHandle) -> std::result::Result<DefaultModelPaths, String> {
|
||||
task::get_default_model_paths(&app).map_err(error_to_string)
|
||||
}
|
||||
|
||||
fn error_to_string(error: anyhow::Error) -> String {
|
||||
format!("{error:#}")
|
||||
}
|
||||
@ -78,7 +83,8 @@ pub fn run() {
|
||||
start_subtitle_task,
|
||||
list_tasks,
|
||||
update_segment_text,
|
||||
export_subtitles
|
||||
export_subtitles,
|
||||
get_default_model_paths
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -111,3 +111,10 @@ pub struct TranslationConfig {
|
||||
pub batch_size: usize,
|
||||
pub context_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultModelPaths {
|
||||
pub whisper_model_path: String,
|
||||
pub vad_model_path: String,
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@ -10,8 +10,8 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
audio::AudioPipeline,
|
||||
models::{
|
||||
ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent, StartTaskPayload,
|
||||
SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
||||
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent,
|
||||
StartTaskPayload, SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
||||
},
|
||||
state::AppState,
|
||||
subtitle::{render, SubtitleFormat},
|
||||
@ -20,10 +20,12 @@ use crate::{
|
||||
whisper::WhisperEngine,
|
||||
};
|
||||
|
||||
const DEFAULT_WHISPER_MODEL: &str =
|
||||
"/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/ggml-small-q5_1.bin";
|
||||
const DEFAULT_VAD_MODEL: &str =
|
||||
"/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/silero_vad.onnx";
|
||||
const DEFAULT_WHISPER_MODEL: &str = "model/ggml-small-q5_1.bin";
|
||||
const DEFAULT_VAD_MODEL: &str = "model/silero_vad.onnx";
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
const DEFAULT_FFMPEG_BINARY: &str = "vendor/ffmpeg/macos-arm64/bin/ffmpeg";
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
const DEFAULT_FFMPEG_BINARY: &str = "vendor/ffmpeg/macos-x86_64/bin/ffmpeg";
|
||||
|
||||
pub async fn start_task(
|
||||
app: tauri::AppHandle,
|
||||
@ -32,10 +34,10 @@ pub async fn start_task(
|
||||
mut payload: StartTaskPayload,
|
||||
) -> Result<SubtitleTask> {
|
||||
if payload.whisper_model_path.as_deref().is_none_or(str::is_empty) {
|
||||
payload.whisper_model_path = Some(DEFAULT_WHISPER_MODEL.to_string());
|
||||
payload.whisper_model_path = resolve_default_model_path(&app, DEFAULT_WHISPER_MODEL);
|
||||
}
|
||||
if payload.vad_model_path.as_deref().is_none_or(str::is_empty) {
|
||||
payload.vad_model_path = Some(DEFAULT_VAD_MODEL.to_string());
|
||||
payload.vad_model_path = resolve_default_model_path(&app, DEFAULT_VAD_MODEL);
|
||||
}
|
||||
if payload.source_lang.as_deref().is_none_or(str::is_empty) {
|
||||
payload.source_lang = Some("auto".to_string());
|
||||
@ -83,6 +85,58 @@ pub async fn start_task(
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub fn get_default_model_paths(app: &tauri::AppHandle) -> Result<DefaultModelPaths> {
|
||||
let whisper_model_path = resolve_default_model_path(app, DEFAULT_WHISPER_MODEL)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到内置 Whisper 模型: {}", DEFAULT_WHISPER_MODEL))?;
|
||||
let vad_model_path = resolve_default_model_path(app, DEFAULT_VAD_MODEL)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到内置 VAD 模型: {}", DEFAULT_VAD_MODEL))?;
|
||||
|
||||
Ok(DefaultModelPaths {
|
||||
whisper_model_path,
|
||||
vad_model_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_default_model_path(app: &tauri::AppHandle, relative_path: &str) -> Option<String> {
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
let bundled_path = resource_dir.join(relative_path);
|
||||
if bundled_path.exists() {
|
||||
return Some(bundled_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let local_fallback_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
|
||||
if local_fallback_path.exists() {
|
||||
return Some(local_fallback_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_ffmpeg_path(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
let bundled_path = resource_dir.join(DEFAULT_FFMPEG_BINARY);
|
||||
if bundled_path.exists() {
|
||||
return Some(bundled_path);
|
||||
}
|
||||
}
|
||||
|
||||
let local_bundled_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(DEFAULT_FFMPEG_BINARY);
|
||||
if local_bundled_path.exists() {
|
||||
return Some(local_bundled_path);
|
||||
}
|
||||
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
for directory in std::env::split_paths(&path_var) {
|
||||
let candidate = directory.join("ffmpeg");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_pipeline(
|
||||
app: tauri::AppHandle,
|
||||
window: Window,
|
||||
@ -92,10 +146,13 @@ async fn run_pipeline(
|
||||
let app_state = app.state::<AppState>();
|
||||
let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id);
|
||||
let should_translate = matches!(payload.output_mode, OutputMode::Translate);
|
||||
let ffmpeg_path = resolve_ffmpeg_path(&app)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到可用 ffmpeg,请重新执行打包命令或在系统中安装 ffmpeg"))?;
|
||||
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 8.0, "正在抽取音频")?;
|
||||
emit_log(&window, &task.id, format!("task: input file={}", payload.file_path))?;
|
||||
let wav_path = AudioPipeline::extract_to_wav(&payload.file_path, &workspace)?;
|
||||
emit_log(&window, &task.id, format!("audio: ffmpeg={}", ffmpeg_path.display()))?;
|
||||
let wav_path = AudioPipeline::extract_to_wav(&ffmpeg_path, &payload.file_path, &workspace)?;
|
||||
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
||||
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 22.0, "正在分析语音片段")?;
|
||||
@ -210,7 +267,7 @@ async fn run_pipeline(
|
||||
fn load_translation_config() -> Option<TranslationConfig> {
|
||||
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
||||
let api_key = std::env::var("OPENAI_API_KEY").ok()?;
|
||||
let model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
let model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "GLM-4-Flash-250414".to_string());
|
||||
Some(TranslationConfig {
|
||||
api_base,
|
||||
api_key,
|
||||
|
||||
@ -27,7 +27,16 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns"
|
||||
],
|
||||
"resources": [
|
||||
"model/ggml-small-q5_1.bin",
|
||||
"model/silero_vad.onnx",
|
||||
"vendor/ffmpeg/**/*",
|
||||
"vendor/licenses_bundle/**/*"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
|
||||