不重要了
@ -43,7 +43,7 @@ npm install
|
|||||||
```bash
|
```bash
|
||||||
export OPENAI_API_BASE=https://your-openai-compatible-endpoint/v1
|
export OPENAI_API_BASE=https://your-openai-compatible-endpoint/v1
|
||||||
export OPENAI_API_KEY=your_api_key
|
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 库,或按你的部署方式提供运行库。
|
6. 若要真正启用 ONNX Runtime 推理,请确保本机存在可被 `ort` 动态加载的 ONNX Runtime 库,或按你的部署方式提供运行库。
|
||||||
|
|||||||
@ -5,9 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"tauri": "tauri dev",
|
"tauri-dev": "tauri dev",
|
||||||
|
"tauri": "tauri",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@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;
|
pub struct AudioPipeline;
|
||||||
|
|
||||||
impl 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)
|
fs::create_dir_all(workspace)
|
||||||
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
||||||
|
|
||||||
let output_path = workspace.join("normalized.wav");
|
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("-y")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
.arg(input_path)
|
.arg(input_path)
|
||||||
@ -25,11 +32,19 @@ impl AudioPipeline {
|
|||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("wav")
|
.arg("wav")
|
||||||
.arg(&output_path)
|
.arg(&output_path)
|
||||||
.status()
|
.output()
|
||||||
.context("failed to launch ffmpeg, please install ffmpeg and ensure it is in PATH")?;
|
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
|
||||||
|
|
||||||
if !status.success() {
|
if !output.status.success() {
|
||||||
return Err(anyhow!("ffmpeg exited with status: {status}"));
|
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)
|
Ok(output_path)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ mod translate;
|
|||||||
mod vad;
|
mod vad;
|
||||||
mod whisper;
|
mod whisper;
|
||||||
|
|
||||||
use models::{StartTaskPayload, SubtitleSegment, SubtitleTask};
|
use models::{DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
||||||
@ -59,6 +59,11 @@ fn export_subtitles(
|
|||||||
task::export_task(state, task_id, format).map_err(error_to_string)
|
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 {
|
fn error_to_string(error: anyhow::Error) -> String {
|
||||||
format!("{error:#}")
|
format!("{error:#}")
|
||||||
}
|
}
|
||||||
@ -78,7 +83,8 @@ pub fn run() {
|
|||||||
start_subtitle_task,
|
start_subtitle_task,
|
||||||
list_tasks,
|
list_tasks,
|
||||||
update_segment_text,
|
update_segment_text,
|
||||||
export_subtitles
|
export_subtitles,
|
||||||
|
get_default_model_paths
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -111,3 +111,10 @@ pub struct TranslationConfig {
|
|||||||
pub batch_size: usize,
|
pub batch_size: usize,
|
||||||
pub context_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::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@ -10,8 +10,8 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
audio::AudioPipeline,
|
audio::AudioPipeline,
|
||||||
models::{
|
models::{
|
||||||
ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent, StartTaskPayload,
|
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent,
|
||||||
SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
StartTaskPayload, SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
subtitle::{render, SubtitleFormat},
|
subtitle::{render, SubtitleFormat},
|
||||||
@ -20,10 +20,12 @@ use crate::{
|
|||||||
whisper::WhisperEngine,
|
whisper::WhisperEngine,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WHISPER_MODEL: &str =
|
const DEFAULT_WHISPER_MODEL: &str = "model/ggml-small-q5_1.bin";
|
||||||
"/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/ggml-small-q5_1.bin";
|
const DEFAULT_VAD_MODEL: &str = "model/silero_vad.onnx";
|
||||||
const DEFAULT_VAD_MODEL: &str =
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
"/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/silero_vad.onnx";
|
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(
|
pub async fn start_task(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
@ -32,10 +34,10 @@ pub async fn start_task(
|
|||||||
mut payload: StartTaskPayload,
|
mut payload: StartTaskPayload,
|
||||||
) -> Result<SubtitleTask> {
|
) -> Result<SubtitleTask> {
|
||||||
if payload.whisper_model_path.as_deref().is_none_or(str::is_empty) {
|
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) {
|
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) {
|
if payload.source_lang.as_deref().is_none_or(str::is_empty) {
|
||||||
payload.source_lang = Some("auto".to_string());
|
payload.source_lang = Some("auto".to_string());
|
||||||
@ -83,6 +85,58 @@ pub async fn start_task(
|
|||||||
Ok(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(
|
async fn run_pipeline(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
window: Window,
|
window: Window,
|
||||||
@ -92,10 +146,13 @@ async fn run_pipeline(
|
|||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id);
|
let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id);
|
||||||
let should_translate = matches!(payload.output_mode, OutputMode::Translate);
|
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, "正在抽取音频")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 8.0, "正在抽取音频")?;
|
||||||
emit_log(&window, &task.id, format!("task: input file={}", payload.file_path))?;
|
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()))?;
|
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 22.0, "正在分析语音片段")?;
|
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> {
|
fn load_translation_config() -> Option<TranslationConfig> {
|
||||||
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
||||||
let api_key = std::env::var("OPENAI_API_KEY").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 {
|
Some(TranslationConfig {
|
||||||
api_base,
|
api_base,
|
||||||
api_key,
|
api_key,
|
||||||
|
|||||||
@ -27,7 +27,16 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"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": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "10.15"
|
"minimumSystemVersion": "10.15"
|
||||||
}
|
}
|
||||||
|
|||||||