Compare commits
76 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e38c29794 | ||
|
|
2e9716d85d | ||
|
|
ee7ecd34bd | ||
|
|
6fcdf357f7 | ||
|
|
5988785d57 | ||
|
|
e02bfefd72 | ||
|
|
6c9b8a8d82 | ||
|
|
d01cdcb449 | ||
|
|
16edf3c693 | ||
| db7cbf93bb | |||
| e7a83ba472 | |||
| c3459bee4d | |||
| 3cc718fa69 | |||
| 425a9b1003 | |||
| 836bf0bf76 | |||
| 71e95e656c | |||
| b201561442 | |||
| 343032cef6 | |||
| f41b827b41 | |||
| cc6512edb7 | |||
| 20f9773080 | |||
| 6e114cb3ff | |||
| e2c5350770 | |||
| 78261af1be | |||
| 82b16caa2e | |||
| 3753d0c6e5 | |||
| 7230e50135 | |||
| 634703827e | |||
| f9c7ab772e | |||
| 8831c501a1 | |||
| adc27e3190 | |||
| 4d28bb7ed5 | |||
| 0925b65d63 | |||
| a14902dcb0 | |||
| 605c1792c8 | |||
| 8caf54fbcf | |||
| 9058cc3f5d | |||
| 31ee97503e | |||
| db77fb986b | |||
| 9e1d6250cb | |||
| 79d64321f2 | |||
| 91f603918d | |||
| b2d18297da | |||
| aa1c6643b8 | |||
| 67178a636f | |||
| f34116ec02 | |||
| 21c7405993 | |||
| 837e7cae73 | |||
| f469cf550e | |||
| 9d12c1ae58 | |||
| 7dd1769524 | |||
| 1e224edceb | |||
| 5a1e503508 | |||
| 0e620e2658 | |||
| a341565c5a | |||
| f0117451e7 | |||
| d14a7c99d1 | |||
| 033c0dc158 | |||
| f487212f07 | |||
| 491eb18cf6 | |||
| cd76da177b | |||
| cc140a4f82 | |||
| fb93972769 | |||
| 96a58e6375 | |||
| 87ed15e010 | |||
| 20a9ee0015 | |||
| 271a488d25 | |||
| a29c43a49f | |||
| f190f331dd | |||
| d5687bb884 | |||
| 4a23b62de9 | |||
| 1c3f09c6fb | |||
| 22e3dbda4c | |||
| b7a0257b03 | |||
| d3a7f2e709 | |||
| c5b15888f0 |
113
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: "1"
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Linux (check, test, build)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config clang libva-dev \
|
||||
libavcodec-dev libavformat-dev libavutil-dev \
|
||||
libswresample-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||
libpipewire-0.3-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev \
|
||||
libasound2-dev
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo registry and target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: linux-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: linux-
|
||||
|
||||
- name: Format check (advisory)
|
||||
continue-on-error: true
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Type check
|
||||
run: cargo check --locked --all-targets
|
||||
|
||||
- name: Unit tests
|
||||
run: cargo test --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --locked --all-targets -- -D warnings
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --locked --release
|
||||
|
||||
- name: Verify binary
|
||||
run: |
|
||||
./target/release/scythe --help | head -3
|
||||
test -x target/release/scythe
|
||||
ls -la target/release/scythe
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scythe-linux-x86_64
|
||||
path: target/release/scythe
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Windows (cross build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
update: true
|
||||
path-type: minimal
|
||||
install: >-
|
||||
mingw-w64-x86_64-toolchain
|
||||
mingw-w64-x86_64-rust
|
||||
mingw-w64-x86_64-ffmpeg
|
||||
mingw-w64-x86_64-pkg-config
|
||||
mingw-w64-x86_64-clang
|
||||
|
||||
- name: Build release
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
LIBCLANG_PATH: /mingw64/bin
|
||||
BINDGEN_EXTRA_CLANG_ARGS: "--target=x86_64-w64-windows-gnu"
|
||||
run: |
|
||||
cargo build --release
|
||||
ls -la target/release/scythe.exe
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scythe-windows-x86_64
|
||||
path: target/release/scythe.exe
|
||||
if-no-files-found: error
|
||||
43
.github/workflows/release.yml
vendored
|
|
@ -33,14 +33,15 @@ jobs:
|
|||
- name: Build Release Binaries
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package Linux Tarball (Generic Binaries)
|
||||
- name: Package Linux Tarball (Unified Binary)
|
||||
run: |
|
||||
mkdir -p dist/scythe-linux-x86_64
|
||||
cp target/release/scythe-daemon target/release/scythe-ui dist/scythe-linux-x86_64/
|
||||
ln -sf scythe-daemon dist/scythe-linux-x86_64/vrec-daemon
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/vrec-ui
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/scythe
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/vrec
|
||||
cp target/release/scythe dist/scythe-linux-x86_64/
|
||||
ln -sf scythe dist/scythe-linux-x86_64/scythe-daemon
|
||||
ln -sf scythe dist/scythe-linux-x86_64/scythe-ui
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec-daemon
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec-ui
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec
|
||||
cp packaging/scythe.desktop dist/scythe-linux-x86_64/
|
||||
tar -czvf dist/scythe-linux-x86_64.tar.gz -C dist scythe-linux-x86_64
|
||||
|
||||
|
|
@ -63,12 +64,12 @@ jobs:
|
|||
Description: High-performance GPU hardware screen recorder and instant replay overlay
|
||||
Depends: libgtk-3-0, libgtk-layer-shell0, libpipewire-0.3-0, libva2, libasound2
|
||||
EOF
|
||||
cp target/release/scythe-daemon "deb/scythe_${VERSION}/usr/bin/"
|
||||
cp target/release/scythe-ui "deb/scythe_${VERSION}/usr/bin/"
|
||||
ln -sf scythe-daemon "deb/scythe_${VERSION}/usr/bin/vrec-daemon"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec-ui"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/scythe"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec"
|
||||
cp target/release/scythe "deb/scythe_${VERSION}/usr/bin/scythe"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/scythe-daemon"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/scythe-ui"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec-daemon"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec-ui"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec"
|
||||
cp packaging/scythe.desktop "deb/scythe_${VERSION}/usr/share/applications/"
|
||||
dpkg-deb --build "deb/scythe_${VERSION}" "dist/scythe_${VERSION}_amd64.deb"
|
||||
|
||||
|
|
@ -81,17 +82,18 @@ jobs:
|
|||
- name: Package Standalone AppImage
|
||||
run: |
|
||||
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/scalable/apps
|
||||
cp target/release/scythe-ui AppDir/usr/bin/
|
||||
cp target/release/scythe-daemon AppDir/usr/bin/
|
||||
ln -sf scythe-ui AppDir/usr/bin/scythe
|
||||
ln -sf scythe-ui AppDir/usr/bin/vrec-ui
|
||||
ln -sf scythe-daemon AppDir/usr/bin/vrec-daemon
|
||||
cp target/release/scythe AppDir/usr/bin/
|
||||
ln -sf scythe AppDir/usr/bin/scythe-ui
|
||||
ln -sf scythe AppDir/usr/bin/scythe-daemon
|
||||
ln -sf scythe AppDir/usr/bin/vrec-ui
|
||||
ln -sf scythe AppDir/usr/bin/vrec-daemon
|
||||
ln -sf scythe AppDir/usr/bin/vrec
|
||||
cp packaging/scythe.desktop AppDir/usr/share/applications/
|
||||
|
||||
cat > AppDir/AppRun << 'EOF'
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
||||
exec "${HERE}/usr/bin/scythe-ui" "$@"
|
||||
exec "${HERE}/usr/bin/scythe" "$@"
|
||||
EOF
|
||||
chmod +x AppDir/AppRun
|
||||
|
||||
|
|
@ -140,8 +142,7 @@ jobs:
|
|||
run: |
|
||||
cargo build --release
|
||||
mkdir -p dist/bundle
|
||||
cp target/release/scythe-daemon.exe dist/bundle/
|
||||
cp target/release/scythe-ui.exe dist/bundle/
|
||||
cp target/release/scythe.exe dist/bundle/
|
||||
python3 packaging/windows/bundle_deps.py dist/bundle
|
||||
strip dist/bundle/*.exe || true
|
||||
strip --strip-unneeded dist/bundle/*.dll || true
|
||||
|
|
@ -206,4 +207,4 @@ jobs:
|
|||
artifacts/windows-packages/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
generate_release_notes: false
|
||||
|
|
|
|||
105
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
stages:
|
||||
- check
|
||||
- build
|
||||
- package
|
||||
|
||||
variables:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: "1"
|
||||
|
||||
# Cache the target dir — Rust builds are slow, everything after the first is fast.
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- Cargo.lock
|
||||
prefix: cargo
|
||||
paths:
|
||||
- target/
|
||||
|
||||
# ---------- check ----------
|
||||
# The codebase is not rustfmt-clean yet; this job reports drift without blocking
|
||||
# builds. Run `cargo fmt` in a dedicated commit before promoting it to a hard gate.
|
||||
fmt:
|
||||
stage: check
|
||||
image: rust:latest
|
||||
script:
|
||||
- rustup component add rustfmt
|
||||
- cargo fmt --check
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
allow_failure: true
|
||||
|
||||
clippy:
|
||||
stage: check
|
||||
image: rust:latest
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y pkg-config libssl-dev
|
||||
- rustup component add clippy
|
||||
script:
|
||||
- cargo clippy --all-targets -- --deny warnings
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
allow_failure: true
|
||||
|
||||
test:
|
||||
stage: check
|
||||
image: rust:latest
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y pkg-config libssl-dev
|
||||
script:
|
||||
- cargo test --locked
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ---------- build ----------
|
||||
build-linux:
|
||||
stage: build
|
||||
image: rust:latest
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y
|
||||
pkg-config libssl-dev
|
||||
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev
|
||||
libavdevice-dev libswscale-dev libswresample-dev
|
||||
libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev
|
||||
libxkbcommon-dev libgtk-3-dev
|
||||
script:
|
||||
- cargo build --release --locked
|
||||
artifacts:
|
||||
name: "scythe-linux-x86_64"
|
||||
paths:
|
||||
- target/release/scythe
|
||||
expire_in: 30 days
|
||||
|
||||
build-windows:
|
||||
stage: build
|
||||
image: rust:latest
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y
|
||||
mingw-w64
|
||||
pkg-config libssl-dev
|
||||
gcc-mingw-w64-x86-64
|
||||
script:
|
||||
- rustup target add x86_64-pc-windows-gnu
|
||||
- cargo build --release --locked --target x86_64-pc-windows-gnu --bin scythe
|
||||
artifacts:
|
||||
name: "scythe-windows-x86_64"
|
||||
paths:
|
||||
- target/x86_64-pc-windows-gnu/release/scythe.exe
|
||||
expire_in: 30 days
|
||||
|
||||
# ---------- package ----------
|
||||
# Real releases only on tags, so a main-branch push doesn't burn CI minutes.
|
||||
release:
|
||||
stage: package
|
||||
image: rust:latest
|
||||
needs: [build-linux]
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
script:
|
||||
- echo "Release build for $CI_COMMIT_TAG"
|
||||
- cargo build --release --locked
|
||||
artifacts:
|
||||
name: "scythe-$CI_COMMIT_TAG"
|
||||
paths:
|
||||
- target/release/scythe
|
||||
expire_in: 90 days
|
||||
618
Cargo.lock
generated
|
|
@ -414,29 +414,6 @@ dependencies = [
|
|||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -672,31 +649,6 @@ version = "1.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cairo-sys-rs",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-sys-rs"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -769,16 +721,6 @@ dependencies = [
|
|||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.12.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.9"
|
||||
|
|
@ -786,7 +728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.5",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1372,16 +1314,6 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
|
|
@ -1441,32 +1373,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
|
|
@ -1525,64 +1437,6 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"gdk-pixbuf",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
|
||||
dependencies = [
|
||||
"gdk-pixbuf-sys",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
||||
dependencies = [
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
|
|
@ -1637,51 +1491,6 @@ dependencies = [
|
|||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"gio-sys 0.18.1",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.22.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5"
|
||||
dependencies = [
|
||||
"glib-sys 0.22.9",
|
||||
"gobject-sys 0.22.9",
|
||||
"libc",
|
||||
"system-deps 9.0.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
|
|
@ -1693,96 +1502,6 @@ dependencies = [
|
|||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-macros 0.18.5",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.22.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18b9b8d350db41f690ec1b87109f202782748bbd8ab2f2ede17cb40d29e2838c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys 0.22.9",
|
||||
"glib-macros 0.22.9",
|
||||
"glib-sys 0.22.9",
|
||||
"gobject-sys 0.22.9",
|
||||
"libc",
|
||||
"memchr",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.22.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.22.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 9.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
|
|
@ -1884,28 +1603,6 @@ dependencies = [
|
|||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.22.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834"
|
||||
dependencies = [
|
||||
"glib-sys 0.22.9",
|
||||
"libc",
|
||||
"system-deps 9.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-alloc"
|
||||
version = "0.6.2"
|
||||
|
|
@ -1945,86 +1642,6 @@ dependencies = [
|
|||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
|
||||
dependencies = [
|
||||
"atk",
|
||||
"cairo-rs",
|
||||
"field-offset",
|
||||
"futures-channel",
|
||||
"gdk",
|
||||
"gdk-pixbuf",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"gtk-sys",
|
||||
"gtk3-macros",
|
||||
"libc",
|
||||
"pango",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk-layer-shell"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc759b3184830a547b31549ab40c4b54450ab702bba79ba23f049bc1d1e3ca98"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"gdk",
|
||||
"glib 0.18.5",
|
||||
"glib-sys 0.18.1",
|
||||
"gtk",
|
||||
"gtk-layer-shell-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk-layer-shell-sys"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4eee067e022416d53a70de69d3d3929d8a6e687f3278b8934faa671750fa6eb"
|
||||
dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys 0.18.1",
|
||||
"gtk-sys",
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gdk-sys",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk3-macros"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
|
|
@ -2051,12 +1668,6 @@ version = "0.17.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
|
|
@ -2381,7 +1992,7 @@ dependencies = [
|
|||
"libspa-sys",
|
||||
"nom 8.0.0",
|
||||
"rustix 1.1.4",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2392,7 +2003,7 @@ checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
|
|||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2734,7 +2345,7 @@ version = "0.7.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
|
@ -3123,31 +2734,6 @@ dependencies = [
|
|||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
||||
dependencies = [
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pango-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -3248,7 +2834,7 @@ checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
|
|||
dependencies = [
|
||||
"bindgen",
|
||||
"libspa-sys",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3332,57 +2918,13 @@ dependencies = [
|
|||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
|
||||
dependencies = [
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3700,7 +3242,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "scythe"
|
||||
version = "0.2.9"
|
||||
version = "0.2.18"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"cpal",
|
||||
|
|
@ -3710,11 +3252,8 @@ dependencies = [
|
|||
"egui",
|
||||
"ffmpeg-next",
|
||||
"ffmpeg-sys-next",
|
||||
"gdk",
|
||||
"glib 0.22.9",
|
||||
"global-hotkey",
|
||||
"gtk",
|
||||
"gtk-layer-shell",
|
||||
"image",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"mp4",
|
||||
|
|
@ -3788,15 +3327,6 @@ dependencies = [
|
|||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -4012,23 +3542,13 @@ version = "0.26.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
|
|
@ -4062,51 +3582,19 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr 0.15.8",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.8.2",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr 0.20.9",
|
||||
"heck 0.5.0",
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"toml",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f"
|
||||
dependencies = [
|
||||
"cfg-expr 0.20.9",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
|
|
@ -4252,18 +3740,6 @@ dependencies = [
|
|||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
|
|
@ -4272,20 +3748,11 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
|||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4297,30 +3764,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
|
|
@ -4328,9 +3771,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4339,7 +3782,7 @@ version = "1.1.3+spec-1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5227,15 +4670,6 @@ dependencies = [
|
|||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
|
|
@ -5426,7 +4860,7 @@ dependencies = [
|
|||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
"zbus_macros 5.19.0",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
|
|
@ -5462,7 +4896,7 @@ version = "4.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
|
@ -5475,7 +4909,7 @@ version = "5.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
|
|
@ -5502,7 +4936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
|
|
@ -5651,7 +5085,7 @@ dependencies = [
|
|||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
"zcheapstr",
|
||||
"zvariant_derive 5.15.0",
|
||||
"zvariant_utils 4.2.0",
|
||||
|
|
@ -5663,7 +5097,7 @@ version = "4.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
|
@ -5676,7 +5110,7 @@ version = "5.15.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
|
|
@ -5704,5 +5138,5 @@ dependencies = [
|
|||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.4",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
]
|
||||
|
|
|
|||
23
Cargo.toml
|
|
@ -1,24 +1,17 @@
|
|||
[package]
|
||||
name = "scythe"
|
||||
version = "0.2.9"
|
||||
version = "0.2.18"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "scythe-daemon"
|
||||
path = "src/bin/scythe-daemon.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "scythe-ui"
|
||||
path = "src/bin/scythe-ui.rs"
|
||||
name = "scythe"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel = "0.5.16"
|
||||
ffmpeg-next = "9.0.0"
|
||||
ffmpeg-sys-next = "9.0.0"
|
||||
gdk = "0.18.2"
|
||||
glib = "0.22.9"
|
||||
global-hotkey = "0.8.0"
|
||||
gtk = { version = "0.18.2", package = "gtk" }
|
||||
libc = "0.2.189"
|
||||
mp4 = "0.14.0"
|
||||
ringbuf = "0.5.1"
|
||||
|
|
@ -30,14 +23,21 @@ cpal = "0.18.2"
|
|||
rubato = "5.0.0"
|
||||
eframe = "0.31"
|
||||
egui = "0.31"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd = { version = "0.13.13", features = ["screencast"] }
|
||||
gtk-layer-shell = { version = "0.8.2", features = ["v0_5"] }
|
||||
pipewire = "0.10.1"
|
||||
xcb = { version = "1.7.1", features = ["shm", "xfixes"] }
|
||||
memmap2 = "0.9.11"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 4
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58.0", features = [
|
||||
"Win32_Foundation",
|
||||
|
|
@ -54,5 +54,6 @@ windows = { version = "0.58.0", features = [
|
|||
"Win32_System_Threading",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
|
|
|||
202
LICENSE-APACHE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 katR (oiupoyt)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
22
LICENSE-MIT
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 katR (oiupoyt)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
431
PROJECT_GUIDE.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Scythe: Comprehensive Project & Architecture Guide
|
||||
|
||||
This document is the definitive technical reference for developers and AI agents working on the Scythe codebase. It outlines the project's design philosophy, unified binary architecture, hardware encoding pathways, audio subsystem, power management, HUD overlay, and development standards.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview & Current Status
|
||||
|
||||
**Current Version:** `v0.2.18`
|
||||
**License:** Dual MIT / Apache 2.0
|
||||
|
||||
### Repository Topology
|
||||
|
||||
| Remote | URL | Role | Status |
|
||||
|---|---|---|---|
|
||||
| `forgejo` | `https://git.oiupoyt.space/oiupoyt/scythe.git` | Self-hosted primary; source of truth, release assets | Active |
|
||||
| `origin` | `https://spearzificial@github.com/spearzificial/scythe.git` | **GitHub development mirror** (currently maintained account) | Active |
|
||||
| `gitlab` | `https://gitlab.com/oiupoyt.cat/scythe.git` | Optional mirror | Optional |
|
||||
|
||||
**GitHub account policy:** GitHub pushes go to the `spearzificial` account, and `origin` points at that account's repository. The `oiupoyt` GitHub OAuth token is expired, so the old `oiupoyt` repository is not a push target. Always name the account in the remote URL (`https://spearzificial@github.com/...`) so Git selects the correct stored credential when multiple GitHub accounts exist in the credential store.
|
||||
|
||||
### Dual-Push Rule
|
||||
|
||||
Every push must reach both active remotes:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git push forgejo main
|
||||
```
|
||||
|
||||
|
||||
Scythe is a cross-platform, ultra-low-overhead desktop screen recorder, instant replay buffer, and livestreamer written in Rust. It delivers functionality comparable to NVIDIA ShadowPlay and GPU Screen Recorder with minimal CPU and GPU overhead.
|
||||
|
||||
### Core Capabilities:
|
||||
1. **Unified Multicall Binary Architecture**: UI overlay, CLI client, and headless daemon engine are consolidated into a single executable ([`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs)) with subcommands (`scythe daemon`, `scythe record`, `scythe save`, etc.) and backward-compatible symlinks (`scythe-daemon`, `scythe-ui`).
|
||||
2. **Zero-Copy Hardware Video Encoding**:
|
||||
- **Linux**: FFmpeg VA-API (`h264_vaapi`, `hevc_vaapi`, `av1_vaapi`) with direct DRM DMA-BUF import for Intel and AMD GPUs. Automatic low-power hardware fallback handles drivers lacking `VAEntrypointEncSliceLP`.
|
||||
- **Windows**: Native NVENC, AMD AMF, Intel QuickSync (QSV), and multithreaded software fallbacks via DirectX Graphics Infrastructure (DXGI) desktop duplication.
|
||||
3. **High-Fidelity Audio Engine**:
|
||||
- Multi-source routing: System Audio, Microphone, Both, or Muted.
|
||||
- Dual codec support: Native Opus (`AV_CODEC_ID_OPUS`, 48 kHz stereo, 160 kbps, `AV_SAMPLE_FMT_FLT`) as recommended default, with native AAC (`AV_CODEC_ID_AAC`, 48 kHz stereo, 192 kbps, `AV_SAMPLE_FMT_FLTP`) as universal fallback.
|
||||
- Interactive sound verification suite with live dB / percentage VU meters and test tone playback.
|
||||
4. **Lock-Free Circular RAM Replay Buffer**:
|
||||
- Continuous lock-free packet ring buffer in system RAM (configurable from 5 seconds to 30 minutes).
|
||||
- Keyframe-aware draining ensures saved MP4 clips begin cleanly on IDR boundaries with zero decoder corruption.
|
||||
5. **Smart Power Management**:
|
||||
- AC power connectivity monitoring via Linux `/sys/class/power_supply` and Windows Win32 `GetSystemPowerStatus`.
|
||||
- Dedicated 1-second background power poller thread.
|
||||
- "Power-only" replay mode automatically drains and pauses the replay buffer on battery power to conserve battery life, and seamlessly resumes with keyframe forced resync upon AC connection.
|
||||
6. **KAT Square Minimalist Interface**:
|
||||
- Strictly conforms to `DESIGN.md` guidelines: `border-radius: 0 !important;` across all windows, inputs, buttons, and badges.
|
||||
- Obsidian dark palette (`#0b0b0b` / `#111111`) with hairline `#222222` framing and restrained functional accents.
|
||||
- Borderless, shadowless terminal-style notification toasts.
|
||||
- Monospace typography option with integrated `Adwaita Mono` font.
|
||||
7. **Screenshot Resolution Presets**:
|
||||
- Intuitive presets: `Native (Source)`, `1080p (FHD)`, `1440p (QHD)`, `2160p (4K UHD)`, `720p (HD)` with hardware-accelerated Lanczos scaling via FFmpeg.
|
||||
8. **In-App Auto-Updater**:
|
||||
- Synchronizes against Forgejo releases API (`https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`) with automated binary replacement.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture & Process Model
|
||||
|
||||
Scythe uses a unified multicall binary dispatch model:
|
||||
|
||||
```
|
||||
+-----------------------------------+
|
||||
| scythe binary |
|
||||
+-----------------+-----------------+
|
||||
|
|
||||
Inspects argv[0] and first CLI argument
|
||||
|
|
||||
+---------------------------------+---------------------------------+
|
||||
| |
|
||||
v v
|
||||
"scythe daemon" "scythe [command]"
|
||||
or argv[0] == "scythe-daemon" or argv[0] == "scythe-ui"
|
||||
| |
|
||||
+--------v-------------------------+ +---------v-----------------------+
|
||||
| Headless Daemon Engine | | Client CLI & Overlay |
|
||||
| (src/daemon.rs) | <--- Unix Domain Socket / --- | (src/overlay_egui.rs) |
|
||||
| - Screen Capture (PipeWire/DXGI) | Local TCP IPC Loop | - egui HUD Overlay (Alt+Z) |
|
||||
| - Hardware Video Encoder | | - Audio Diagnostics & VU Meters |
|
||||
| - Native Opus/AAC Audio Encoder | | - Clip Trimmer & Gallery |
|
||||
| - Lock-Free Circular Ring Buffer | | - Terminal Notification Toasts |
|
||||
| - AC Power Watcher Thread | | - Global Hotkey Listener Pump |
|
||||
| - MP4 Muxer & RTMP Streamer | +---------------------------------+
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
### Multicall Dispatching:
|
||||
- **Daemon Mode**: Invoked when `args[1] == "daemon"`, `args[1] == "--mock"`, or when executed via symlink `scythe-daemon` / `vrec-daemon`. Runs the headless capture and recording engine ([`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs)).
|
||||
- **Dispatch uses argv[0], not `current_exe()`**: `current_exe()` reads `/proc/self/exe`, which resolves symlinks to the real file. Every installed alias would therefore report `scythe` and dispatch to the client, so `scythe-daemon` would open the HUD instead of starting the engine. `invoked_stem()` reads argv[0] and falls back to `current_exe()` only when argv[0] has no usable stem.
|
||||
- **Client / UI Mode**: Invoked when executed as `scythe`, `scythe-ui`, or with client arguments (`save`, `record`, `screenshot`, `status`, `menu`, etc.). Runs [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and launches [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
|
||||
---
|
||||
|
||||
## 3. Inter-Process Communication (IPC)
|
||||
|
||||
The protocol is implemented in [`src/ipc.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ipc.rs).
|
||||
|
||||
### Transport Layer:
|
||||
- **Linux**: Unix Domain Socket at `$XDG_RUNTIME_DIR/scythe.sock` (with legacy fallback to `/run/user/<UID>/vrec.sock`).
|
||||
- **Windows**: Local TCP socket on `127.0.0.1` binding to candidate ports `42069`–`42072` and persisting the active port in `%TEMP%\scythe-ipc.port`.
|
||||
|
||||
### Message Payload:
|
||||
Packets are framed with a 4-byte little-endian length prefix followed by JSON-serialized structs:
|
||||
- **Command (`Command`)**:
|
||||
- `SaveReplay`: Drains the circular buffer and muxes an MP4 replay.
|
||||
- `StartRecording` / `StopRecording` / `ToggleRecording`: Controls manual recording.
|
||||
- `StartStreaming` / `StopStreaming` / `ToggleStreaming`: Controls RTMP broadcasting.
|
||||
- `ToggleAudio`: Toggles microphone/system audio mute state.
|
||||
- `CycleAudioMode`: Cycles through System -> Mic -> Both -> Muted.
|
||||
- `ToggleCursor`: Inverts cursor visibility state and triggers capture synchronization.
|
||||
- `ShowOverlay`: Opens the HUD overlay via the daemon if it is not already running (`scythe --show-overlay`). Distinct from `--menu`, which toggles an already-open overlay closed.
|
||||
- `ReloadConfig`: Live-reloads configuration changes without restarting the daemon unless video parameters change.
|
||||
- `PowerStateChanged(bool)`: Internal notification dispatched by the power poller when AC state transitions.
|
||||
- `GetStatus`: Requests the current `DaemonStatus`.
|
||||
- `StopDaemon`: Triggers clean daemon shutdown.
|
||||
- **DaemonStatus (`DaemonStatus`)**:
|
||||
- `is_recording`: bool
|
||||
- `recording_duration_sec`: u64
|
||||
- `is_replay_active`: bool
|
||||
- `audio_muted`: bool
|
||||
- `audio_mode`: String ("system", "mic", "both", "muted")
|
||||
- `show_cursor`: bool
|
||||
- `mic_volume` / `system_volume`: f32
|
||||
- `mic_level_peak` / `system_level_peak`: f32 (0.0 to 1.0 real-time peak values)
|
||||
- `is_streaming`: bool
|
||||
- `streaming_duration_sec`: u64
|
||||
|
||||
---
|
||||
|
||||
## 4. Video Capture Subsystem
|
||||
|
||||
Capture sources implement the [`FrameSource`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mod.rs#L21) trait located in [`src/capture/mod.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mod.rs).
|
||||
|
||||
### Frame Representation:
|
||||
```rust
|
||||
pub enum Frame {
|
||||
Raw {
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
data: Arc<Vec<u8>>,
|
||||
},
|
||||
DmaBuf {
|
||||
fd: std::os::fd::RawFd,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
format: u32,
|
||||
modifier: u64,
|
||||
offset: u32,
|
||||
},
|
||||
D3D11Texture {
|
||||
handle: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Implementations:
|
||||
1. **Wayland ([`src/capture/wayland.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland.rs) & [`src/capture/wayland_stream.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland_stream.rs))**:
|
||||
- Uses `xdg-desktop-portal` and PipeWire screencast stream.
|
||||
- Negotiates DMA-BUF memory buffers (`SPA_DATA_DmaBuf`) for zero-copy GPU video encoding.
|
||||
- Falls back to shared memory buffers (`SPA_DATA_MemPtr`) when DMA-BUF is unavailable.
|
||||
2. **X11 ([`src/capture/x11.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/x11.rs))**:
|
||||
- Uses MIT-SHM (`xcb::shm`) for direct shared-memory frame transfer from the X server.
|
||||
3. **Windows ([`src/capture/windows.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs))**:
|
||||
- Uses the DirectX Graphics Infrastructure (DXGI) Desktop Duplication API (`IDXGIOutputDuplication`).
|
||||
- Iterates through all display adapters and outputs to probe and select the active desktop.
|
||||
- Uses a single Direct3D 11 staging texture (`D3D11_USAGE_STAGING`) with `D3D11_CPU_ACCESS_READ` for low-latency GPU readback.
|
||||
- Tightly packs rows into BGRA format if GPU pitch padding is detected.
|
||||
- Ensures fallback / idle frames generate opaque black (`[0, 0, 0, 255]`) to prevent uninitialized green chroma artifacts.
|
||||
4. **Mock Capture ([`src/capture/mock.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mock.rs))**:
|
||||
- Generates simulated 1080p frames for headless unit tests and CI runners where no display server is attached.
|
||||
|
||||
---
|
||||
|
||||
## 5. Video Encoding Pipeline
|
||||
|
||||
The video encoder module is located in [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs).
|
||||
|
||||
### Linux Encoder (`VaapiEncoder`):
|
||||
- Uses FFmpeg libavcodec with VA-API hardware acceleration (`h264_vaapi`, `hevc_vaapi`, `av1_vaapi`).
|
||||
- Direct DRM DMA-BUF Import: Zero-copy surface creation via `vaCreateSurfacesFromProperties` for Intel and AMD GPUs.
|
||||
- Color Standards: ITU-R BT.709 color primaries (`AVCOL_PRI_BT709`), transfer characteristics (`AVCOL_TRC_BT709`), and MPEG limited range (`AVCOL_RANGE_MPEG`).
|
||||
- SIMD Color Space Conversion: When software fallback is required, `sws_scale` operates with `SWS_FAST_BILINEAR` and explicit ITU-709 coefficient mapping.
|
||||
|
||||
### Windows Encoder (`WindowsHwEncoder`):
|
||||
- Hardware Probing Priority:
|
||||
- H.264: `h264_nvenc` -> `h264_amf` -> `h264_qsv` -> `libx264`
|
||||
- HEVC: `hevc_nvenc` -> `hevc_amf` -> `hevc_qsv` -> `libx265`
|
||||
- AV1: `av1_nvenc` -> `av1_amf` -> `av1_qsv` -> `libsvtav1`
|
||||
- Standardized Pixel Format: NV12 (`AV_PIX_FMT_NV12`) across hardware encoders, with YUV420P fallback.
|
||||
- Color Conversion: Converts captured BGRA to NV12 using `sws_scale` configured with ITU-R BT.709 matrix and full-range to limited-range mapping (`sws_setColorspaceDetails`), eliminating washed-out colors and green screen bugs.
|
||||
- Low Latency Settings: Rate control VBR with burst ceiling, zero B-frames (`max_b_frames = 0`), ultrafast tuning, and 1-second GOP size (`gop_size = fps`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Audio Subsystem
|
||||
|
||||
Audio capture and conversion is implemented in [`src/capture/audio.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/audio.rs) and [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs).
|
||||
|
||||
### Pipeline:
|
||||
1. **Capture**:
|
||||
- **Linux**: Uses PulseAudio/PipeWire `parec` targeting `@DEFAULT_MONITOR@` (for system audio loopback) and `@DEFAULT_SOURCE@` (for microphone input) with a 20ms low-latency buffer.
|
||||
- **Windows**: Direct WASAPI loopback capture using the `cpal` crate.
|
||||
2. **Channel Downmixing & Resampling**:
|
||||
- Downmixes 5.1 and 7.1 surround sound to stereo using center and surround coefficients:
|
||||
```
|
||||
L = FL + 0.707 * C + 0.707 * SL + 0.5 * LFE
|
||||
R = FR + 0.707 * C + 0.707 * SR + 0.5 * LFE
|
||||
```
|
||||
- Resamples arbitrary input sample rates to 48,000 Hz using linear interpolation and soft-clipping limiting (`tanh` curve).
|
||||
3. **Encoding (`AudioEncoder`)**:
|
||||
- **Opus (`AV_CODEC_ID_OPUS`)**: Native Opus stereo at 160 kbps, 48 kHz with float sample format (`AV_SAMPLE_FMT_FLT`). Primary recommended standard.
|
||||
- **AAC (`AV_CODEC_ID_AAC`)**: Native AAC stereo at 192 kbps, 48 kHz with planar float format (`AV_SAMPLE_FMT_FLTP`). Universal fallback standard.
|
||||
- Microsecond-accurate PTS rebasing aligns audio packets against video IDR keyframes.
|
||||
4. **Interactive Verification & Live VU Meters**:
|
||||
- The UI includes live decibel / percentage VU meters for both system sound and microphone.
|
||||
- Integrated test suite (`test mic` and `test sys` with automated chime playback) allows users to immediately verify sound capture without running external applications.
|
||||
|
||||
---
|
||||
|
||||
## 7. Lock-Free RAM Ring Buffer & Power Management
|
||||
|
||||
Implemented in [`src/ring.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ring.rs), [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs), and [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs).
|
||||
|
||||
### Buffer Mechanics:
|
||||
- Keeps encoded video and audio packets in RAM without writing to disk during active buffer mode.
|
||||
- Fixed Capacity: Capacity is calculated dynamically from duration (seconds) * target framerate * safety factor. On low-power hardware, capacity is tightly bounded to conserve memory.
|
||||
- **Keyframe-Aware Pruning**:
|
||||
- Replay clips must always begin with an IDR keyframe to prevent visual corruption.
|
||||
- When the circular buffer reaches maximum capacity, packets are dropped starting from the oldest non-keyframe sequence up to the next IDR keyframe boundary.
|
||||
- Drain Operation: When `Command::SaveReplay` is received, the buffer packets are cloned into an asynchronous channel without blocking the capture and encoding threads.
|
||||
|
||||
### AC Power Management:
|
||||
- **Connectivity Detection**: `ScytheConfig::is_ac_power_connected()` inspects `/sys/class/power_supply/*/online` on Linux and calls `GetSystemPowerStatus` on Windows.
|
||||
- **Poller Thread**: A dedicated 1-second background thread continuously monitors AC power changes.
|
||||
- **Automatic Buffer Gating**: When `autostart_power_only` is enabled:
|
||||
- On battery power loss: The replay buffer is automatically paused, drained of existing frames, and a toast notification (`power lost, replay paused`) is dispatched.
|
||||
- On AC connection restore: Replay buffering automatically resumes, forces an encoder keyframe, and dispatches a notification (`power restored, replay resumed`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Muxer & Livestreaming
|
||||
|
||||
Implemented in [`src/muxer.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/muxer.rs).
|
||||
|
||||
### MP4 Container Muxing (`Muxer`):
|
||||
- Wraps FFmpeg libavformat to produce universal MP4 files (`.mp4`) containing H.264/HEVC/AV1 video and Opus/AAC audio.
|
||||
- Emits faststart headers (`+faststart`) to place the `moov` atom at the beginning of the file for instant streaming and playback.
|
||||
- Rebases PTS and DTS timestamps to zero for both video and audio streams at file initialization.
|
||||
- Automatic Collision Handling: Formats filenames as `Replay-HH-MM-SS_DD-MM-YYYY.mp4` or `Recording-HH-MM-SS_DD-MM-YYYY.mp4` and resolves conflicts via incremental suffixes (`_1`, `_2`).
|
||||
|
||||
### RTMP Livestreaming (`spawn_stream_worker`):
|
||||
- Connects to RTMP endpoints (Twitch, YouTube, Kick, Custom RTMP).
|
||||
- Encapsulates video and audio NAL units into FLV/RTMP transport on a dedicated worker thread with non-blocking channel dispatch.
|
||||
|
||||
---
|
||||
|
||||
## 9. In-Game HUD Overlay & Notifications
|
||||
|
||||
Implemented in [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
|
||||
### Design System: KAT Square Minimalist:
|
||||
- Strictly adheres to `DESIGN.md`:
|
||||
- **`border-radius: 0 !important;`**: Zero rounded corners or pill badges anywhere in the interface.
|
||||
- **Obsidian Dark Palette**: `#0b0b0b` root background, `#111111` surface panels, `#222222` hairline borders.
|
||||
- **Restrained Accents**: Functional emerald (`#10b981`), red (`#ef4444`), cyan (`#38bdf8`), and vendor themes (AMD, NVIDIA, Intel).
|
||||
- **Monospace Typography**: Full support for `Adwaita Mono` / `JetBrains Mono` with an interface toggle.
|
||||
|
||||
### Views:
|
||||
- **Main HUD**:
|
||||
- Rolldown / swipe animated dropdown menus (`ui.ctx().animate_bool(...)` over 180ms) for Replay, Record, and Broadcast.
|
||||
- Quick actions with drop shadow framing.
|
||||
- **Settings View**:
|
||||
- File Information: Output directory with native folder pickers.
|
||||
- Video Parameters: Resolution scaling, FPS (15 to 360), bitrate (5 to 150 Mbps), codec (H.264, HEVC, AV1), color range (Full vs Limited), low-power recording mode.
|
||||
- Audio: Mode routing, volume sliders (0-150%), Opus/AAC codec choice, and live verification test suite.
|
||||
- Screenshot: Resolution presets (`Native`, `1080p`, `1440p`, `2160p`, `720p`), format (`PNG` vs `JPEG`), and cursor capture toggle.
|
||||
- General: Theme accents, 8 global keybinds with interactive recording listener, compact `Reset Hotkeys` button, and typography toggle.
|
||||
- **Notification Toasts (`ShadowPlayToastApp`)**:
|
||||
- Borderless, shadowless terminal-style card.
|
||||
- Obsidian `#111111` fill with 2px vertical accent rule and 6px status dot.
|
||||
- Uppercase monospace title, lowercase metadata subtitle, and 2px hairline progress countdown line.
|
||||
|
||||
---
|
||||
|
||||
## 10. Global Hotkeys & Window Hooks
|
||||
|
||||
Implemented in [`src/hotkey.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/hotkey.rs) and [`src/hyprland_binds.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/hyprland_binds.rs).
|
||||
|
||||
### Keybind Registry:
|
||||
1. Menu Overlay: `Alt+Z`
|
||||
2. Save Instant Replay: `Ctrl+Shift+R`
|
||||
3. Start / Stop Recording: `Ctrl+Shift+F9`
|
||||
4. Pause / Resume Recording: `Ctrl+Shift+F8`
|
||||
5. Take Screenshot: `Ctrl+Shift+S`
|
||||
6. Toggle Mouse Cursor: `Ctrl+Shift+F10`
|
||||
7. Mute / Unmute Microphone: `Ctrl+Shift+M`
|
||||
8. Mute / Unmute System Audio: `Ctrl+Shift+N`
|
||||
|
||||
### Dispatch Strategies:
|
||||
1. **Hyprland Direct IPC**: Registers binds dynamically using `hyprctl keyword bind` to bypass Wayland global hotkey restrictions. Watches `hyprland.conf` modifications and auto-reapplies.
|
||||
2. **Universal Desktop**: Uses the `global-hotkey` crate for standard Windows and X11 hotkey registration.
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration Management
|
||||
|
||||
Implemented in [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs).
|
||||
|
||||
### File Locations:
|
||||
- Linux: `~/.config/scythe/config.json`
|
||||
- Windows: `%APPDATA%\scythe\config.json`
|
||||
|
||||
### Autostart Integration:
|
||||
`ScytheConfig::apply_autostart_system(enable_replay, enable_overlay)` manages:
|
||||
1. Systemd user service: `systemctl --user enable/disable scythe-daemon.service`.
|
||||
2. Standard XDG autostart desktop entries in `~/.config/autostart/scythe-daemon.desktop` and `scythe-overlay.desktop`.
|
||||
|
||||
Linux only. The Windows path is a no-op: there is no registry `Run` key write in either the overlay toggle or the NSIS installer, so the autostart switch in the UI currently has no effect on Windows.
|
||||
|
||||
### Retired Settings (v0.2.18 cleanup):
|
||||
These keys were accepted by the settings UI and persisted to disk, but nothing in the capture, encoder, or daemon paths ever read them. They were removed from `ScytheConfig` and the corresponding UI controls were deleted, so the overlay no longer presents options that do nothing:
|
||||
|
||||
| Retired key | Why it was removed |
|
||||
|---|---|
|
||||
| `capture_source` | Capture always selected the focused monitor; window/region modes were never implemented |
|
||||
| `replay_storage` | Replay buffer is always RAM-backed; the disk path was never built |
|
||||
| `change_resolution`, `resolution_w`, `resolution_h` | Encoder always used native capture dimensions |
|
||||
| `container_format` | Muxer always writes MP4 |
|
||||
| `show_scroll_lock_led` | No LED driver integration existed |
|
||||
| `autostart_on_game` | No game-detection layer existed |
|
||||
| `save_folder_by_game`, `name_by_game` | No game detection; filenames are timestamp-based |
|
||||
| `language`, `ui_color_theme` | No translation layer or theme engine; `accent_color` drives the real palette |
|
||||
| `record_enabled` | Dead field superseded by runtime `is_recording` state |
|
||||
|
||||
Unknown keys in an existing `config.json` are ignored by serde, so upgrading does not break saved configurations. `scythe` rewrites the file without these keys on the next save.
|
||||
|
||||
---
|
||||
|
||||
## 12. In-App Auto-Updater
|
||||
|
||||
Implemented in [`src/updater.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs).
|
||||
|
||||
### Workflow:
|
||||
1. Queries the self-hosted Forgejo release API:
|
||||
`GET https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`
|
||||
(Falls back to GitHub API if Forgejo is unreachable).
|
||||
2. Parses SemVer strings and checks if `is_newer(latest, CURRENT_VERSION)`.
|
||||
3. Downloads the singular platform package:
|
||||
- Linux: `scythe-v<version>-linux-x86_64.tar.gz`
|
||||
- Windows: `scythe-v<version>-windows-x86_64.zip` (includes standalone portable FFmpeg DLLs).
|
||||
4. Atomically swaps the executable in `~/.local/bin/scythe` or `%USERPROFILE%\.local\bin\scythe.exe`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Verification, Testing & Cross-Compilation
|
||||
|
||||
### Local Linux Commands:
|
||||
```bash
|
||||
# Type check all targets
|
||||
cargo check --all-targets
|
||||
|
||||
# Run the complete unit test suite (27 tests)
|
||||
cargo test
|
||||
|
||||
# Strict clippy lint verification
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Build optimized Linux release binary
|
||||
cargo build --release
|
||||
|
||||
# Install release binary and symlinks to ~/.local/bin
|
||||
bash install.sh
|
||||
|
||||
# Run end-to-end integration test suite
|
||||
bash tests/e2e_test.sh
|
||||
```
|
||||
|
||||
`tests/e2e_test.sh` treats a running daemon as a resource it borrows rather than deletes. Unix sockets cannot be re-linked: removing the path entry of a live daemon leaves the kernel socket bound but unreachable, and the session stays broken until that process dies.
|
||||
|
||||
The script therefore:
|
||||
1. probes liveness by connecting to the socket directly (`scythe --status` cannot be used for this, because it calls `ensure_daemon_running()` and would spawn a daemon while probing);
|
||||
2. records the running daemon's command line and working directory;
|
||||
3. sends `StopDaemon` and waits for the socket to disappear, aborting without unlinking if it will not stop;
|
||||
4. restores that daemon on exit, so a developer's recorder survives a test run.
|
||||
|
||||
### Windows Cross-Compilation (from Linux Host):
|
||||
```bash
|
||||
FFMPEG_DIR="$HOME/.local/share/ffmpeg-win64/ffmpeg-n9.0-latest-win64-gpl-shared-9.0" \
|
||||
PKG_CONFIG_PATH="$HOME/.local/share/ffmpeg-win64/ffmpeg-n9.0-latest-win64-gpl-shared-9.0/lib/pkgconfig" \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
### GitHub Actions:
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml) | push to `main`, pull request, manual | Compiles and tests both targets on every push. Linux job: `cargo check --all-targets`, `cargo test`, `cargo clippy -D warnings`, `cargo build --release`, then runs `scythe --help` to prove the binary starts. Windows job: MSYS2 MINGW64 `cargo build --release`. Both binaries upload as artifacts. |
|
||||
| [`release.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/release.yml) | push of a `v*` tag | Packages `.tar.gz`, `.deb`, `.rpm`, `.AppImage` and the NSIS Windows installer, then publishes a GitHub Release. |
|
||||
|
||||
The CI workflow is the only thing that compiles the Windows target on a routine basis, and it exists because the Windows build had already rotted: `src/config.rs` calls `GetSystemPowerStatus` but the `windows` crate feature list never enabled `Win32_System_Power`, so `cargo build --target x86_64-pc-windows-gnu` failed with `E0432`. Tag pushes were the only previous trigger, so nothing caught it before a release was attempted.
|
||||
|
||||
The `cargo fmt --check` step is advisory (`continue-on-error: true`). The tree has formatting drift that is intentionally not mass-fixed, because reformatting every file would bury the real changes in a diff.
|
||||
|
||||
Tests that need real hardware (a live audio server) are opt-in:
|
||||
|
||||
```bash
|
||||
SCYTHE_HW_TESTS=1 cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Behavioral Rules & Coding Standards
|
||||
|
||||
1. **ABSOLUTELY ZERO EMOJIS**: Under no circumstances should emojis be added to code, comments, log outputs, commit messages, or communication.
|
||||
2. **File & Symbol Links**: Every file path or code symbol mentioned in communication must be formatted as a clickable markdown link using the `file://` scheme (e.g., [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs)).
|
||||
3. **KAT Square Minimalist**: Strict `border-radius: 0 !important;` on all visual elements.
|
||||
4. **Dual-Push Rule**: When pushing commits or tags, always push to both active remotes (`origin` for GitHub and `forgejo` for self-hosted):
|
||||
```bash
|
||||
git push origin <branch>
|
||||
git push forgejo <branch>
|
||||
```
|
||||
5. **Memory Safety & Process Isolation**: Avoid blocking the audio or video capture loops. Use unbounded or high-capacity crossbeam channels for frame dispatch.
|
||||
49
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> High-performance, zero-copy GPU hardware screen recorder and instant replay overlay for Linux (Wayland & X11) and Windows.
|
||||
|
||||
[](https://github.com/oiupoyt/scythe/releases)
|
||||
[](https://github.com/spearzificial/scythe/releases)
|
||||
[](LICENSE)
|
||||
|
||||
Scythe is a native, ultra-lightweight alternative to NVIDIA ShadowPlay and GPU Screen Recorder. It records gameplay and desktop screens with near-zero CPU and GPU overhead by leveraging hardware video acceleration and zero-copy direct memory pipelines.
|
||||
|
|
@ -17,15 +17,17 @@ Scythe is a native, ultra-lightweight alternative to NVIDIA ShadowPlay and GPU S
|
|||
- **X11**: XCB Shared Memory (MIT-SHM) capture with per-frame pool reuse.
|
||||
- **Windows**: Desktop Duplication API via Direct3D 11 (DXGI) texture binding.
|
||||
- **Hardware-Accelerated Encoding**:
|
||||
- NVIDIA NVENC (`h264_nvenc`, `hevc_nvenc`)
|
||||
- AMD & Intel VAAPI (`h264_vaapi`, `hevc_vaapi`)
|
||||
- Automatic CPU fallback (`libx264`)
|
||||
- NVIDIA NVENC (`h264_nvenc`, `hevc_nvenc`, `av1_nvenc`) with direct hardware BGR0 input
|
||||
- AMD AMF (`h264_amf`, `hevc_amf`, `av1_amf`) with native hardware format support
|
||||
- Intel QuickSync (`h264_qsv`, `hevc_qsv`, `av1_qsv`)
|
||||
- AMD & Intel VAAPI (`h264_vaapi`, `hevc_vaapi`, `av1_vaapi`) with DMA-BUF zero-copy
|
||||
- Universal SIMD-accelerated CPU fallback (`libx264`, `libx265`, `libsvtav1`)
|
||||
- **ShadowPlay-Style Stealth Overlay**:
|
||||
- Native GTK 3 Layer-Shell overlay with real background blur for Wayland compositors (Hyprland, Sway, etc.).
|
||||
- Cross-platform hardware-rendered `egui` overlay for Windows, GNOME, KDE, and X11.
|
||||
- Translucent frosted glass HUD dock, centered controls, and contextual dropdowns.
|
||||
- **Dynamic Hyprland Integration**: Automatically registers overlay blur rules and binds hotkeys at runtime without modifying `hyprland.conf`.
|
||||
- **Multi-Channel Audio Routing**: Record system audio, microphone, or both simultaneously with individual volume controls via native PulseAudio/PipeWire and WASAPI.
|
||||
- **Multi-Channel Audio Routing**: Record system audio, microphone, or both simultaneously with individual volume controls via native PulseAudio/PipeWire and WASAPI/CPAL.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,10 +35,11 @@ Scythe is a native, ultra-lightweight alternative to NVIDIA ShadowPlay and GPU S
|
|||
|
||||
| Action | Shortcut | CLI Command |
|
||||
|---|---|---|
|
||||
| Open / Close HUD Overlay | `Alt + Z` | `scythe-ui --menu` |
|
||||
| Save Instant Replay | `Ctrl + Shift + R` | `scythe-ui --save` |
|
||||
| Toggle Normal Recording | `Ctrl + Shift + F9` | `scythe-ui --record` |
|
||||
| Toggle Mouse Cursor | `Ctrl + Shift + F10` | `scythe-ui --cursor` |
|
||||
| Open / Close HUD Overlay | `Alt + Z` | `scythe --menu` |
|
||||
| Save Instant Replay | `Ctrl + Shift + R` | `scythe --save` |
|
||||
| Toggle Normal Recording | `Ctrl + Shift + F9` | `scythe --record` |
|
||||
| Toggle Mouse Cursor | `Ctrl + Shift + F10` | `scythe --cursor` |
|
||||
| Take Screenshot | `Ctrl + Shift + S` | `scythe --screenshot` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -52,12 +55,14 @@ makepkg -si
|
|||
|
||||
### Pre-Built Packages (Releases)
|
||||
|
||||
Download the latest packages directly from [GitHub Releases](https://github.com/oiupoyt/scythe/releases):
|
||||
Download the latest packages directly from [GitHub Releases](https://github.com/spearzificial/scythe/releases) or [Forgejo Releases](https://git.oiupoyt.space/oiupoyt/scythe/releases):
|
||||
|
||||
- **Debian / Ubuntu / Linux Mint**: `scythe_0.1.0_amd64.deb`
|
||||
- **Fedora / RHEL / openSUSE**: `scythe-0.1.0-2.x86_64.rpm`
|
||||
- **Windows Setup**: `scythe-setup.exe` (Official installer with Start Menu & Desktop shortcuts)
|
||||
- **Windows Portable**: `scythe.exe` (Standalone self-contained executable) & `scythe-windows-x86_64.zip`
|
||||
- **Debian / Ubuntu / Linux Mint**: `scythe_*_amd64.deb`
|
||||
- **Fedora / RHEL / openSUSE**: `*.rpm`
|
||||
- **Universal Linux**: `scythe-x86_64.AppImage`
|
||||
- **Windows**: `scythe-setup.exe`
|
||||
- **Generic Linux**: `scythe-linux-x86_64.tar.gz`
|
||||
|
||||
### Build from Source
|
||||
|
||||
|
|
@ -71,7 +76,7 @@ Download the latest packages directly from [GitHub Releases](https://github.com/
|
|||
#### Compile & Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiupoyt/scythe.git
|
||||
git clone https://github.com/spearzificial/scythe.git
|
||||
cd scythe
|
||||
bash install.sh
|
||||
```
|
||||
|
|
@ -81,13 +86,13 @@ bash install.sh
|
|||
## CLI Usage
|
||||
|
||||
```bash
|
||||
scythe-ui --menu # Open the interactive overlay UI menu
|
||||
scythe-ui --status # Query background daemon state and metrics
|
||||
scythe-ui --save # Save instant replay clip to disk
|
||||
scythe-ui --record # Toggle normal recording on/off
|
||||
scythe-ui --cursor # Toggle mouse cursor capture
|
||||
scythe-ui --reload # Reload daemon settings
|
||||
scythe-ui --quit # Stop background recording daemon
|
||||
scythe --menu # Open the interactive overlay UI menu
|
||||
scythe --status # Query background daemon state and metrics
|
||||
scythe --save # Save instant replay clip to disk
|
||||
scythe --record # Toggle normal recording on/off
|
||||
scythe --cursor # Toggle mouse cursor capture
|
||||
scythe --reload # Reload daemon settings
|
||||
scythe --quit # Stop background recording daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -102,7 +107,7 @@ Settings are saved in JSON format at `~/.config/scythe/config.json`:
|
|||
"record_bitrate_kbps": 18000,
|
||||
"replay_duration_sec": 60,
|
||||
"video_codec": "h264",
|
||||
"output_directory": "~/Videos/Scythe",
|
||||
"output_directory": "~/Videos",
|
||||
"show_cursor": true,
|
||||
"audio_mode": "system",
|
||||
"mic_volume": 0.6,
|
||||
|
|
|
|||
345
TODO_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
# Scythe: Maintainer Todo & Audit Checklist
|
||||
|
||||
This checklist tracks known bugs, UI refinements, optimizations, and technical debt items across the Scythe codebase.
|
||||
|
||||
> **Historical note:** entries recorded before v0.2.18 reference `src/bin/scythe-daemon.rs` and `src/bin/scythe-ui.rs`. Those files were merged into [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs) by the multicall consolidation; the paths are kept as an accurate record of where the work happened at the time.
|
||||
|
||||
---
|
||||
|
||||
## 1. UI Refinements & Imperfections
|
||||
|
||||
- [x] **Singular Multicall Binary Architecture (`scythe`)**:
|
||||
- **Issue**: Distributing separate `scythe-daemon` and `scythe-ui` binaries required cross-process path discovery in `$PATH`, complicated Windows bundling with multiple executables, and prevented atomic single-file updates.
|
||||
- **Resolution**:
|
||||
- Consolidated daemon engine and UI overlay/CLI into a single binary (`scythe`) in [src/main.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and [src/daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs).
|
||||
- Implemented multicall dispatch: inspects `argv[0]` and subcommands (`scythe daemon`, `scythe [options]`, or symlinks `scythe-daemon` / `scythe-ui`).
|
||||
- Configured [install.sh](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh) with backward-compatible symlinks.
|
||||
- Updated release packages: both Linux and Windows distributions now ship a single executable.
|
||||
|
||||
- [x] **Screenshot Resolution Presets (Native, 1080p, 1440p, 2160p, 720p) & Lanczos Downscaling**:
|
||||
- **Issue**: Vague compression percentages and presets confused users instead of providing clear resolution controls.
|
||||
- **Resolution**: Replaced vague compression sliders with clean resolution presets (`Native (Source)`, `1080p (FHD)`, `1440p (QHD)`, `2160p (4K UHD)`, `720p (HD)`) in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). When a target resolution is selected, [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs) executes pristine Lanczos hardware-accelerated scaling via FFmpeg.
|
||||
|
||||
- [x] **Removed Obsolete Game Folder Option**:
|
||||
- **Issue**: The "Save video in a folder based on the game's name" toggle in Instant Replay settings was redundant.
|
||||
- **Resolution**: Removed the toggle from the Replay settings panel in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
|
||||
- [x] **Low-End Hardware Memory and Pipeline Optimizations**:
|
||||
- **Issue**: Budget systems and laptops with iGPUs or low RAM suffered high memory pressure and command-queue stalls during replay buffering.
|
||||
- **Resolution**:
|
||||
- Reduced `async_depth` from 4 to 2 and enabled low-power compression preset in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) when `record_low_power` is enabled.
|
||||
- Reduced circular packet ring buffer capacity allocation in [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs), saving ~30% buffer RAM on low-end hardware.
|
||||
|
||||
- [x] **Dropdown Rolldown Animation & Frosted Blur Styling**:
|
||||
- **Issue**: Dropdowns previously appeared abruptly without animation, and background shadow/framing lacked smooth transition.
|
||||
- **Resolution**: Implemented smooth cubic ease-out rolldown animation (`ui.ctx().animate_bool(...)` over 180ms) in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). Content smoothly slides into position while clipped to the expanding frame, with multi-layered drop shadows (`draw_window_shadow`) and softened frosted background blur (`#10141a` at 96% opacity).
|
||||
|
||||
- [x] **Instant Replay Sound Capture Parity & Autostart Layout Fix**:
|
||||
- **Issue**: Instant replay settings were missing sound capture options, and the Autostart section overflowed beyond the panel width.
|
||||
- **Resolution**:
|
||||
- Added full audio capture controls to Instant Replay settings (audio mode selection, volume sliders, live VU peak meters, and Opus/AAC codec choice).
|
||||
- Refactored the Autostart section in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs) into a vertical button list matching the available container width.
|
||||
|
||||
- [x] **Functional Audio Settings Integration**:
|
||||
- **Issue**: Audio settings in normal recording previously contained non-functional mockup buttons.
|
||||
- **Resolution**: Replaced placeholder elements with fully functional controls for source routing (`System Audio`, `Microphone`, `Both`, `Muted`), live volume adjustment (0-150%), audio codec selection (`Opus` vs `AAC`), and live stereo peak meters.
|
||||
|
||||
- [x] **Screenshot Quality, Compression Preset & Keybind Integration**:
|
||||
- **Issue**: Screenshot settings lacked quality/compression tuning and keybind rebinding in the general settings view.
|
||||
- **Resolution**: Added compression quality slider (10% to 100%), compression preset selector (`Fast`, `Balanced`, `Maximum Compression`), and integrated screenshot keybind (`Ctrl+Shift+S`) into the general settings hotkey rebinding list.
|
||||
|
||||
- [x] **Comprehensive QoL Quick Keybinds & Compact Reset Button**:
|
||||
- **Issue**: Hotkey settings lacked common shortcuts (such as pause recording, muting microphone, muting system audio), and the reset button in general settings took up too much width.
|
||||
- **Resolution**:
|
||||
- Added interactive rebinding for Pause Recording (`Ctrl+Shift+F8`), Mute Microphone (`Ctrl+Shift+M`), and Mute System Audio (`Ctrl+Shift+N`) in [src/config.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs), [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), and [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs).
|
||||
- Compacted the "Reset to Defaults" button in the hotkeys card to a compact `Reset Hotkeys` button (`96px x 24px`).
|
||||
|
||||
- [x] **Monospace Interface Font Support (Adwaita Mono)**:
|
||||
- **Issue**: Interface typography was limited to proportional font without monospace option.
|
||||
- **Resolution**: Integrated `AdwaitaMono-Regular.ttf` into [assets/fonts/](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/assets/fonts) and added a working toggle in General Settings allowing instant switching between Proportional and Monospace typography.
|
||||
|
||||
- [x] **Square Buttons, Zero-Roundness Dropdown Frames & Persistent Options**:
|
||||
- **Issue**: The dropdown menu still had rounded styling and top accent bars, plus/minus buttons in numeric input boxes had rounded corners (`CornerRadius::same(3)`), and several options (such as changing resolution, scroll lock LED status, container format selection, and game naming rules) were dummy placeholders that didn't persist to configuration.
|
||||
- **Resolution**:
|
||||
- Completely stripped all roundness (`CornerRadius::ZERO`) from dropdown frames, dropdown items, keycap badges, and plus/minus stepper buttons in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
- Removed all top horizontal accent bars from dropdown frames and added realistic drop shadows (`draw_window_shadow`).
|
||||
- Wired every single setting (`container_format`, `change_resolution`, `resolution_w`, `resolution_h`, `save_folder_by_game`, `name_by_game`, `restart_replay_on_save`, `show_scroll_lock_led`, `autostart_on_game`, `autostart_power_only`, `capture_source`, `video_quality_mode`, `color_range`, `audio_codec`, `replay_storage`, `record_low_power`, `show_replay_notifications`) to persist cleanly into [src/config.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs) and `~/.config/scythe/config.json`.
|
||||
- Added automated unit test `test_overlay_app_options_synchronization` (27/27 passing) verifying save, apply, and reset for every single option.
|
||||
- Fixed low-power hardware encoder fallback in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) on drivers without `VAEntrypointEncSliceLP`.
|
||||
|
||||
- [x] **Sharp Dark Slate Window Architecture with Drop Shadows (Matching GSR Screenshots)**:
|
||||
- **Issue**: The UI previously had curved/rounded edges and embedded horizontal accent bars that felt uncharacteristic of native GPU screen recorder overlays.
|
||||
- **Resolution**: Redesigned all overlay windows in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs) to match the visual language of the provided GPU Screen Recorder screenshots:
|
||||
- Deep charcoal slate background (`#15191E`), crisp `#1D2228` section cards, and `#14181E` inset boxes.
|
||||
- Zero roundness (`CornerRadius::ZERO`) across all windows, buttons, segmented pills, toggle switches, and inputs.
|
||||
- Added multi-layered deep drop shadow effects (`draw_window_shadow`) behind the floating settings window and dropdown menus.
|
||||
- Removed all top horizontal accent bars from the settings modal and card dropdowns.
|
||||
|
||||
- [x] **Dedicated Screenshot Feature & Settings Panel**:
|
||||
- **Issue**: Screenshot functionality lacked a dedicated settings panel and CLI access.
|
||||
- **Resolution**:
|
||||
- Added a dedicated **Screenshot Settings** menu (`SettingsTab::Screenshot`) with destination folder configuration (Browse & Open), format selection (`PNG (Lossless)` vs `JPEG`), cursor capture toggle, hotkey display, CLI command reference (`scythe-ui --screenshot`), and instant capture trigger.
|
||||
- Added `scythe-ui --screenshot` CLI command and registered global hotkey (`Ctrl+Shift+S`) across Hyprland and Windows/X11.
|
||||
|
||||
- [x] **Comprehensive Customizability & Options Expansion**:
|
||||
- **Issue**: Settings lacked granular capture and audio controls shown in GPU Screen Recorder.
|
||||
- **Resolution**: Expanded settings across all 5 menus:
|
||||
- **Instant Replay**: Replay duration (sec/formatted time), RAM vs Disk storage selection, low-power mode toggle, game-based naming, replay notification toggles, autostart rules (on startup, on game launch, power supply condition), and video bitrate in Kbps/Mbps.
|
||||
- **Normal Recording**: Capture source selection (`Focused monitor`, `Monitor eDP-1`, `Window`, `Region`), resolution change toggle, audio track management with output/input/application audio routing, audio codecs (`Opus` vs `AAC`), volume sliders with live meters, and color range (`Full` vs `Limited`).
|
||||
- **Broadcast LIVE**: Streaming services (`Twitch`, `YouTube`, `Kick`, `Custom RTMP`), URL, masked stream key with eye toggle, and streaming quality parameters.
|
||||
- **General Settings**: Interface accent palette chips, global keyboard shortcuts rebinding, and autostart toggles.
|
||||
|
||||
- [x] **Four Dedicated Independent Settings Menus**:
|
||||
- **Issue**: Settings options were previously mixed across different tabs rather than dedicated to the specific feature chosen.
|
||||
- **Resolution**: Refactored `SettingsTab` into 4 dedicated menus:
|
||||
1. **Instant Replay**: Buffer Duration (numeric box + RAM footprint badge), Replay FPS, Replay Bitrate, Codec, Cursor toggle, Save Directory, Audio Source.
|
||||
2. **Normal Recording**: Record FPS, Record Bitrate, Codec, Cursor toggle, Save Directory, Audio Source & Volume sliders with live VU meters.
|
||||
3. **Broadcast LIVE**: Service selector, Server URL, Stream Key (masked with unmask toggle), Stream FPS, Stream Bitrate, Audio Source.
|
||||
4. **General Settings**: Accent Color Theme chips, Keyboard Shortcuts (interactive listener cards), Startup integration, Software Updates.
|
||||
|
||||
- [x] **Floating Frosted Glass Dropdowns on HUD Cards**:
|
||||
- **Issue**: Dropdowns beneath HUD cards were harsh black rectangles flush against the cards.
|
||||
- **Resolution**: Rebuilt dropdowns with floating glass containers (`CornerRadius::same(10)`, `#10141A` at 97% opacity, 1px soft stroke, 2.5px top accent line). Dropdown items feature smooth `CornerRadius::same(6)` hover pills, clean typography, right-aligned hotkey badges, and direct links to each feature's dedicated settings menu.
|
||||
|
||||
- [x] **Silky Ease-In and Smooth Haste Exit Notification Animations**:
|
||||
- **Issue**: Notifications felt sluggish on exit and lacked snappy responsiveness.
|
||||
- **Resolution**: Implemented silky cubic ease-out entrance (`1.0 - (1.0 - t).powi(3)`) over 280ms, a legible hold, and a swift smooth-haste exit slide over 200ms with smoothstep acceleration (`t * t * (3.0 - 2.0 * t)`). Styled notification cards with `CornerRadius::same(10)` and curved top accent bar.
|
||||
|
||||
- [x] **NVIDIA ShadowPlay Settings Modal Complete Ground-Up Rewrite**:
|
||||
- **Issue**: The previous settings page used an unwieldy 3-column freestanding layout that was visually fragmented, cluttered, and confusing to navigate across different categories.
|
||||
- **Resolution**: Rebuilt the entire settings view in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs) as a centered, obsidian-glass ShadowPlay split modal with a dedicated left category navigation sidebar (`Video`, `Audio`, `Shortcuts`, `Replay & Storage`, `Livestream`, `Themes & System`). Replaced cluttered pill grids and sliders with direct, smooth numeric input fields with unit tags and stepper controls.
|
||||
|
||||
- [x] **In-Overlay Notification Animation Engine Overhaul**:
|
||||
- **Issue**: Spawning separate OS processes for each toast notification created startup delays and visual popping.
|
||||
- **Resolution**: Implemented in-process `InOverlayToast` rendering directly inside egui with cubic ease-out slide-in, accent progress indicator line, and smooth alpha fade-out, running at 0ms latency with zero process spawn overhead.
|
||||
|
||||
- [x] **Settings Page Reset to Defaults Button Over-sizing**:
|
||||
- **Issue**: In [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), the "Defaults" button in the right-hand action stack was allocated the same massive primary button height (`btn_h` = 52.0px to 64.0px, width `side_w` = 160.0px to 192.0px) as the primary "Back" and "Save" buttons. This took up excessive vertical space in the sidebar, competing with primary actions.
|
||||
- **Resolution**: Resized `def_rect` height from `btn_h` down to a compact secondary button height (`def_btn_h = (btn_h * 0.58).clamp(32.0, 36.0)`). Applied a dedicated subtle border and secondary font sizing with a clear "Reset to Defaults" label.
|
||||
- **Resolution (Hotkeys Card)**: Streamlined the "Reset Hotkeys to Defaults" button in the keyboard hotkeys card from 180.0px down to 130.0px width for visual balance.
|
||||
|
||||
- [x] **Adaptive Content Box Scaling on Compact Displays**:
|
||||
- **Issue**: On sub-1080p viewports (720p, 768p, 800p handhelds such as Steam Deck or ROG Ally), content box padding and card spacing previously caused unwanted vertical scrollbars.
|
||||
- **Resolution**: Implemented dynamic scaling in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs) in both `render_section_card` and `render_settings_view`. On displays with height under 850px, inner padding adjusts from 16px to 12px horizontal and 12px to 8px vertical, section gaps compress, and `max_content_h` expands to 78% of viewport height.
|
||||
|
||||
- [x] **HUD Toast Notification Smooth Handoff & Queuing**:
|
||||
- **Issue**: Rapid sequential toast alerts previously killed the prior toast process abruptly via `SIGKILL` / `TerminateProcess`, clipping the entry animation.
|
||||
- **Resolution**: Implemented smooth handoff in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). On Windows, dispatches `WM_CLOSE` to the prior toast window before terminating; on Linux, issues `SIGTERM` with a 50ms graceful settling delay.
|
||||
|
||||
- [x] **Windows Event-Driven Hotkey Message Pump (`MsgWaitForMultipleObjectsEx`)**:
|
||||
- **Issue**: In [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs), the hotkey loop previously polled `PeekMessageW` with a 20ms sleep, burning CPU cycles in an idle polling loop and introducing up to 20ms of shortcut response latency.
|
||||
- **Resolution**: Replaced with `MsgWaitForMultipleObjectsEx(None, 200, QS_ALLINPUT, MWMO_INPUTAVAILABLE)`. The listener thread sleeps with 0% CPU consumption until a Win32 hotkey message arrives, immediately waking with sub-millisecond response latency.
|
||||
|
||||
- [x] **Windows DXGI Display Output Dynamic Re-enumeration**:
|
||||
- **Issue**: When displays are hot-plugged or resolution changes occur, capture can lose sync.
|
||||
- **Resolution**: In [src/capture/windows.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs), added `AttachedToDesktop` checks and filtering for software rasterizers (`DXGI_ADAPTER_FLAG_SOFTWARE`), ensuring the active physical display is dynamically prioritized and re-enumerated.
|
||||
|
||||
- [x] **Linux PipeWire Portal Stream Reconnect on Compositor Crash**:
|
||||
- **Issue**: If Hyprland, Sway, or `xdg-desktop-portal` restarts, the existing PipeWire stream FD disconnects, causing capture to stall.
|
||||
- **Resolution**: In [src/capture/wayland.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland.rs), implemented `try_reconnect()` with backoff. When the PipeWire stream disconnects, it re-negotiates portal sessions and resumes live frames without taking down the recording daemon.
|
||||
|
||||
---
|
||||
|
||||
## 2. Compiler & Linter Warnings
|
||||
|
||||
- [x] **Clippy `chunks_exact_to_as_chunks` Lints**:
|
||||
- **Issue**: Newer Rust toolchains (Rust 1.88+) flag `chunks_exact` and `chunks_exact_mut` on constant chunk sizes (4 in mock capture, 2/3/4 in audio processing, 4 in Windows DXGI texture mapping).
|
||||
- **Resolution**: Added `#![allow(clippy::chunks_exact_to_as_chunks)]` at crate roots in [src/lib.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/lib.rs), [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs), and [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs) to ensure strict zero-warning builds (`cargo clippy --all-targets -- -D warnings`) across all Rust compiler versions (1.80 through current).
|
||||
|
||||
---
|
||||
|
||||
## 3. Reliability & Bug Fixes
|
||||
|
||||
- [x] **Gray Screen Recording & Mock Fallback Startup Race Condition**:
|
||||
- **Issue**: At system boot or when logging into the session, `scythe-daemon` started before `xdg-desktop-portal` finished registering on D-Bus. After failing to acquire portals within 3 seconds, the daemon silently fell back to `MockCapture` (hardcoded to emit flat gray `RGB(64, 64, 64)` frames), permanently capturing a blank gray screen on subsequent recordings and replays.
|
||||
- **Resolution**:
|
||||
- Updated [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs) so that the daemon never silently traps the user into `MockCapture` unless the explicit `--mock` CLI flag is passed for automated testing.
|
||||
- Added systemd service dependencies (`After=graphical-session.target xdg-desktop-portal.service xdg-desktop-portal-hyprland.service` and `Wants=xdg-desktop-portal.service`) with `Restart=always` and `RestartSec=2` backoff in [install.sh](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh).
|
||||
|
||||
- [x] **GTK3 Retirement, Compile-Time Optimization & Windows Cross-Compilation**:
|
||||
- **Issue**: Legacy GTK3 C-bindings (`gtk`, `gdk`, `glib`, `gtk-layer-shell`) inflated compile times to over 1 minute, consumed heavy build memory, and completely blocked cross-compiling for Windows due to missing MinGW GTK headers.
|
||||
- **Resolution**:
|
||||
- Removed `gtk`, `gdk`, `glib`, `gtk-layer-shell`, and retired [src/settings_ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/settings_ui.rs) and the legacy GTK overlay code. Moved `ToastIcon` directly into [src/ipc.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ipc.rs).
|
||||
- Reduced release compile time by over 60% and unlocked Windows release cross-compilation (`x86_64-pc-windows-gnu`).
|
||||
- Added optimized release profile with thin LTO and symbol stripping in [Cargo.toml](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/Cargo.toml).
|
||||
|
||||
- [x] **Forgejo Releases Integration & Auto-Updater Synchronization**:
|
||||
- **Issue**: The auto-updater was hardcoded to GitHub API endpoints, and release binaries were not packaged for both Windows and Linux on the self-hosted Forgejo instance.
|
||||
- **Resolution**:
|
||||
- Updated [src/updater.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs) to prioritize Forgejo API releases (`https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`) with GitHub fallback.
|
||||
- Cross-compiled release binaries for both Linux (`scythe-v0.2.18-linux-x86_64.tar.gz`) and Windows (`scythe-v0.2.18-windows-x86_64.zip` including full standalone portable FFmpeg DLLs).
|
||||
- Published Release `v0.2.18` directly to Forgejo with assets uploaded.
|
||||
|
||||
- [x] **Opus Audio Encoder Implementation & AAC Fallback**:
|
||||
- **Issue**: Previously, only AAC audio encoding was supported, despite Opus being the industry standard for low-latency streaming and high-fidelity game recording.
|
||||
- **Resolution**: Implemented native `Opus` audio encoding in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) (`AudioEncoder::new_with_codec`) using `AV_SAMPLE_FMT_FLT` at 160 kbps with automatic fallback to AAC when unavailable. Dynamically wired into [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs) based on `config.audio_codec`. Verified 60 FPS recording with native Opus audio streams in MP4 containers.
|
||||
|
||||
- [x] **Hardware Video Encoder Quality Mode (CBR/VBR), Color Range & Low Power Mode**:
|
||||
- **Issue**: Hardware video encoders previously hardcoded VBR rate control, MPEG limited color range, and default GPU performance profiles regardless of user settings.
|
||||
- **Resolution**: Updated `VaapiEncoder` and `WindowsHwEncoder` in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) (`new_with_options`):
|
||||
- Configured explicit `rc_mode` (CBR with strict bitrate caps vs VBR with 2x burst headroom).
|
||||
- Configured color range (`AVCOL_RANGE_JPEG` full range 0-255 vs `AVCOL_RANGE_MPEG` limited range 16-235) in both codec contexts and `sws_setColorspaceDetails`.
|
||||
- Implemented hardware low-power mode (`low_power` on VAAPI and `p1` energy-efficient preset on NVENC) to conserve battery during background replay buffering.
|
||||
|
||||
- [x] **Video Timestamp Pacing Judder (Lag & Micro-Stutter Fix)**:
|
||||
- **Issue**: In [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs), video packet presentation timestamps (PTS) were calculated using wall-clock floating point math with small timing jitter. When a frame was processed a few milliseconds early or late, PTS deltas jumped between 0, 16ms, and 33ms, creating perceptible visual micro-stutter (judder) and making smooth 60 FPS video look choppy in video players.
|
||||
- **Resolution**: Implemented smooth constant framerate (CFR) pacing. Each consecutive encoded video frame now receives an exact, uniform PTS step (`last_video_pts + 1`), providing an exact 16.666ms delta (verified with `ffprobe`). Video players and web browsers now play back smoothly without frame pacing judder.
|
||||
|
||||
- [x] **Hardware Video Encoder Rate Control & Quality Bottleneck**:
|
||||
- **Issue**: In [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs), `rc_buffer_size` was restricted to `rate / 2` (only 0.5 seconds), `rc_mode` was unconfigured, and quantizer was capped with `qmin = 16`. On fast-motion or complex gaming scenes, the VAAPI and NVENC rate control buffers choked within 10 frames, forcing aggressive compression (macroblocking, muddy text, and visual blur).
|
||||
- **Resolution**: Sized `rc_buffer_size` and `rc_max_rate` to `rate * 2` (2 full seconds of headroom), unlocked `qmin = 1` for crystal-clear fine details, set explicit `rc_mode = VBR`, and enabled `async_depth = 4` for hardware pipelining. Matches GPU Screen Recorder quality standards.
|
||||
|
||||
- [x] **Daemon Config Reload Process Deadlock**:
|
||||
- **Issue**: In [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs), when video settings changed, the daemon spawned a replacement process and called `exit(0)`. Because the old daemon was still holding `flock` on `scythe.lock`, the child process failed with "Another instance is already running" and aborted, while systemd saw `exit(0)` and marked the service dead, leaving no daemon running.
|
||||
- **Resolution**: Added up to 1.2s retries on lock acquisition for reloaded processes, removed `replay_enabled` and bitrates from restart triggers (they are now dynamically resized in RAM), and delegated service restarts to systemd (`systemctl --user restart`) when running under systemd.
|
||||
|
||||
- [x] **Manual Recording Start Error Propagation**:
|
||||
- **Issue**: In [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs), `Muxer::new` for manual recording previously discarded errors with `.ok()`. If opening the file failed (e.g., read-only filesystem or invalid path), the daemon remained in `is_recording = true` state while silently dropping video frames.
|
||||
- **Resolution**: Replaced `.ok()` with explicit pattern matching. On failure, logs the exact error, marks recording inactive, and resets start timestamps cleanly.
|
||||
|
||||
- [x] **Windows Multi-Monitor Detection for Overlay and Notifications**:
|
||||
- **Issue**: In [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), `run_egui_overlay()` and `run_egui_toast()` previously hardcoded `(0.0, 0.0)` with `SM_CXSCREEN`/`SM_CYSCREEN`. On multi-monitor setups, the overlay and notification toasts always appeared on the primary monitor even if the active game or foreground window was on monitor 2 or 3.
|
||||
- **Resolution**: Implemented Windows `query_focused_monitor_rect()` using `GetForegroundWindow()` -> `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` -> `GetMonitorInfoW()`. Seamlessly falls back to `MonitorFromPoint` via cursor coordinates and primary metrics. Both overlay and toast now target the active monitor.
|
||||
|
||||
- [x] **Windows DXGI Desktop Duplication Buffer Pool Memory Churn**:
|
||||
- **Issue**: In [src/capture/windows.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs), `buffer_pool` checked `Arc::strong_count(buf) == 1` and took entries with `slot.take()`, but never pushed reused entries back once `len == 4`. Within 4 frames of launch, the pool was permanently emptied, causing fresh 8.3 MB `Arc::new(vec![0u8; total_bytes])` allocations every frame (~500 MB/s allocation thrashing at 1080p60).
|
||||
- **Resolution**: Refactored `buffer_pool` to a true reusable `Vec<Arc<Vec<u8>>>` with capacity 8. Entries are borrowed when `strong_count == 1`, mutated in place, cloned for dispatch, and maintained in the pool. Allocation churn drops to zero after startup.
|
||||
|
||||
- [x] **Windows DXGI Invalid `ReleaseFrame` on Error Codes**:
|
||||
- **Issue**: In [src/capture/windows.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs), `ReleaseFrame()` was called on error paths (`ACCESS_LOST`, `ACCESS_DENIED`, etc.). Per Microsoft DXGI specification, calling `ReleaseFrame` when `AcquireNextFrame` failed returns `DXGI_ERROR_INVALID_CALL` and corrupts state.
|
||||
- **Resolution**: Removed invalid `ReleaseFrame()` invocations from error branches.
|
||||
|
||||
- [x] **Windows Overlay Ungraceful Process Hard-Kill**:
|
||||
- **Issue**: In [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs), toggling the overlay closed previously invoked `TerminateProcess(h, 0)` immediately, bypassing all Rust destructors, Direct3D context teardown, and configuration saving.
|
||||
- **Resolution**: Replaced with graceful `WM_CLOSE` message dispatch via `EnumWindows` and `PostMessageW`, using a 150ms timeout before falling back to `TerminateProcess` only if frozen.
|
||||
|
||||
- [x] **Windows Native Folder Picker Latency**:
|
||||
- **Issue**: In [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), clicking "Change" directory previously spawned `powershell.exe` with a 2-4 second cold-start delay.
|
||||
- **Resolution**: Replaced with native Windows COM `IFileOpenDialog` (`FOS_PICKFOLDERS`), opening in under 5 milliseconds with PowerShell retained as fallback.
|
||||
|
||||
- [x] **Windows WASAPI / CPAL Audio Format Expansion**:
|
||||
- **Issue**: In [src/capture/audio.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/audio.rs), CPAL only handled `F32` and `I16` sample formats. Systems with DACs or audio drivers defaulting to `I32` (32-bit signed integer) failed with "Unsupported audio format", resulting in no audio.
|
||||
- **Resolution**: Added normalization handlers for `cpal::SampleFormat::I32` and `cpal::SampleFormat::U16`.
|
||||
|
||||
- [x] **Windows In-App Auto-Updater Missing DLL Synchronization**:
|
||||
- **Issue**: In [src/updater.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs), portable zip extraction only copied `scythe-ui.exe` and `scythe-daemon.exe`, leaving updated FFmpeg/MinGW DLLs uncopied and causing potential version mismatches.
|
||||
- **Resolution**: Expanded extraction to scan all `.exe` and `.dll` binaries recursively and swap them atomically using the `.old` staging pattern.
|
||||
|
||||
- [x] **Windows IPC Candidate Port Deduplication**:
|
||||
- **Issue**: In [src/ipc.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ipc.rs), candidate ports array repeated `active_port`, causing redundant connection timeout delays when probing daemon status.
|
||||
- **Resolution**: Deduplicated candidate ports in `get_candidate_ipc_ports()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Performance & Memory Optimizations
|
||||
|
||||
- [x] **Repository Cleanup & Scratch Code Removal**:
|
||||
- **Issue**: Obsolete exploratory files in `scythe/scratch/` (`test_shm.rs`, `test_x11_shm.rs`) and ad-hoc python scripts in the project root (`test_ipc.py`, `test_record.py`, `test_reload.py`, `test_stop.py`, `send_ipc.py`) cluttered the workspace.
|
||||
- **Resolution**: Removed `scratch/` directory and relocated testing utilities into [tests/manual_ipc/](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/tests/manual_ipc).
|
||||
|
||||
- [x] **Dynamic PipeWire DRM Format Negotiation (Zero-Copy Pipeline)**:
|
||||
- **Issue**: In [src/capture/wayland_stream.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland_stream.rs), the DRM format code was hardcoded to `0x34325241` (`DRM_FORMAT_ARGB8888`), causing driver mismatches when compositors negotiated `XRGB8888`, `XBGR8888`, or `NV12`.
|
||||
- **Resolution**: Dynamically mapped `user_data.format.format()` to its matching DRM fourcc (`DRM_FORMAT_XRGB8888`, `DRM_FORMAT_XBGR8888`, `DRM_FORMAT_NV12`). When `NV12` buffers arrive, they are passed directly into VAAPI without CPU color conversion.
|
||||
|
||||
- [x] **Audio Resampling Fast Path for 48,000 Hz Sources**:
|
||||
- **Status**: Implemented in `convert_to_stereo_48k` via early return passthrough, avoiding interpolation when input matches 48 kHz.
|
||||
|
||||
- [x] **Windows Desktop Duplication Buffer Pool Recycling**:
|
||||
- **Status**: Implemented in [src/capture/windows.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs) with 8-slot reusable buffer pool, eliminating heap allocations in the 60 FPS capture loop.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Binary Dispatch
|
||||
|
||||
- [x] **Symlink Aliases Dispatched to the UI Instead of the Daemon**:
|
||||
- **Issue**: [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) decided daemon mode from `std::env::current_exe()`, which reads `/proc/self/exe` and resolves symlinks. Because every installed alias points at the real `scythe` file, all of them reported the stem `scythe`, so `scythe-daemon` and `vrec-daemon` fell through to client mode and opened the HUD instead of starting the recording engine. The aliases shipped by [`install.sh`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh), the `.deb`/`.rpm`/Flatpak packages, and the Hyprland `exec_cmd("scythe-daemon")` autostart line were all affected. A stale pre-consolidation `target/release/scythe-daemon` from an older build masked the bug during local testing.
|
||||
- **Resolution**: Added `invoked_stem()`, which reads argv[0] and only falls back to `current_exe()` when argv[0] has no usable stem. Verified against a packaged tarball: `scythe-daemon` and `vrec-daemon` now report the running-instance guard, while `scythe`, `scythe-ui`, `vrec` and `vrec-ui` open the client.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Release Pipeline & Packaging Consolidation
|
||||
|
||||
- [x] **CI/CD Referenced Binaries That No Longer Build**:
|
||||
- **Issue**: The multicall consolidation removed `scythe-daemon` and `scythe-ui` crate targets, but the release pipeline still copied `target/release/scythe-daemon` and `target/release/scythe-ui`. A tag push would fail at the very first packaging step, so no release could be produced. The same stale paths existed in the GitLab CI artifacts, RPM spec, Flatpak manifest, and both NSIS installers.
|
||||
- **Resolution**:
|
||||
- [`.github/workflows/release.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/release.yml): tarball, `.deb`, and AppImage now ship the single `scythe` binary plus `scythe-daemon` / `scythe-ui` / `vrec-*` symlinks. The AppImage `AppRun` and the Windows bundle copy only `scythe` / `scythe.exe`.
|
||||
- [`.gitlab-ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.gitlab-ci.yml): artifacts publish `target/release/scythe`; the Windows job builds `--bin scythe`.
|
||||
- [`packaging/rpm/scythe.spec`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/rpm/scythe.spec), [`packaging/flatpak/com.scythe.Scythe.yaml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/flatpak/com.scythe.Scythe.yaml): install one binary, link the aliases; Flatpak `command` is now `scythe`.
|
||||
- [`packaging/windows/scythe-installer.nsi`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/windows/scythe-installer.nsi), [`packaging/windows/scythe-standalone.nsi`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/windows/scythe-standalone.nsi): shortcuts, finish-page runner, `DisplayIcon`, and forwarded arguments all target `scythe.exe`. Legacy process names remain in the `taskkill` upgrade path so old installs shut down cleanly.
|
||||
|
||||
- [x] **GitLab `fmt` Job Blocked Every Build**:
|
||||
- **Issue**: `cargo fmt --check` reports drift across 20 files, and `build-linux` declared `needs: [fmt]`, so a formatting mismatch prevented any build from running.
|
||||
- **Resolution**: Marked the `fmt` job `allow_failure: true` and dropped the hard `needs` edge, matching how `clippy` is already treated. A whole-tree reformat belongs in its own commit rather than being mixed into functional changes.
|
||||
|
||||
- [x] **GitHub Push Target & Credentials**:
|
||||
- **Issue**: GitHub pushes targeted `oiupoyt/scythe`, but that account's OAuth token is expired, so the mirror had fallen 31 commits behind and pushes failed.
|
||||
- **Resolution**: Added the `spearzificial` remote and pinned the username in the URL (`https://spearzificial@github.com/spearzificial/scythe.git`) so Git selects the correct entry from the multi-account credential store. Repository topology and the dual-push rule are documented in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md).
|
||||
|
||||
---
|
||||
|
||||
## 4c. Dead Configuration Removal
|
||||
|
||||
- [x] **Settings That Persisted But Did Nothing**:
|
||||
- **Issue**: Fourteen `ScytheConfig` keys were exposed in the settings UI and written to `config.json`, but no capture, encoder, muxer, or daemon path ever read them. Users could change them with no observable effect.
|
||||
- **Resolution**: Removed the keys from [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs), deleted their overlay state fields and controls in [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), and pruned the assertions in both test suites. The retired list and rationale live in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md). Unknown JSON keys are ignored by serde, so existing configs still load.
|
||||
|
||||
- [x] **`Command::ShowOverlay` Was Declared But Ignored**:
|
||||
- **Issue**: The IPC enum defined `ShowOverlay`, but the daemon's dispatcher had no arm for it and it fell through to `_ => {}`. Nothing could open the HUD once closed except the `--menu` toggle path.
|
||||
- **Resolution**: Added `show_overlay_once()` in [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs) — it checks the overlay pid file for a live process and spawns `scythe --menu` only when none is running, so repeated requests cannot stack overlays. Exposed as `scythe --show-overlay`, with a direct-launch fallback when the daemon is unreachable.
|
||||
|
||||
- [x] **E2E Test Unlinked a Live Daemon's Socket**:
|
||||
- **Issue**: [`tests/e2e_test.sh`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/tests/e2e_test.sh) ran `rm -f "$SOCKET_PATH"` unconditionally and only stopped the systemd unit. A daemon started any other way (Hyprland `execs.lua`, manual launch) kept the flock; the test removed its socket path entry, leaving the kernel socket bound but unreachable. The session stayed broken until the process was killed.
|
||||
- **Resolution**: The script now probes liveness with a direct Unix-socket connect (not `--status`, which calls `ensure_daemon_running()` and spawns a daemon while probing), records the running daemon's command line and working directory, sends `StopDaemon`, waits for the socket to disappear, aborts without unlinking if it will not stop, and restores the original daemon on exit.
|
||||
|
||||
---
|
||||
|
||||
## 4d. Build & CI
|
||||
|
||||
- [x] **No CI Ran on Routine Commits**:
|
||||
- **Issue**: GitHub Actions only triggered on `v*` tag pushes. Nothing compiled the project between releases, so breakage accumulated unnoticed and every release was the first real build in weeks.
|
||||
- **Resolution**: Added [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml), which runs on every push to `main`, on pull requests, and manually. The Linux job type-checks, tests, lints with `-D warnings`, builds the release binary, runs `scythe --help` to prove the binary starts, and uploads it as an artifact. The Windows job builds through MSYS2 MINGW64 and uploads `scythe.exe`. Both binaries are downloadable from the run.
|
||||
|
||||
- [x] **The Windows Target Did Not Compile**:
|
||||
- **Issue**: [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs) reads AC line status through `GetSystemPowerStatus`, but the `windows` crate feature list in [`Cargo.toml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/Cargo.toml) never enabled `Win32_System_Power`. `cargo build --target x86_64-pc-windows-gnu` failed with `E0432: unresolved import windows::Win32::System::Power`. The build had been broken since the power-monitoring feature landed, and only tag pushes could have revealed it.
|
||||
- **Resolution**: Added the `Win32_System_Power` feature. The Windows job now builds green on every push.
|
||||
|
||||
- [x] **Unknown CLI Arguments Became a Background Hotkey Daemon**:
|
||||
- **Issue**: The dispatch match in [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) ended in `_ => {}`, so an unrecognised flag fell through into the global hotkey loop. `scythe --version` registered the Hyprland binds and then blocked forever instead of reporting the bad flag.
|
||||
- **Resolution**: The catch-all arm now prints the offending argument with a pointer to `--help` and exits with status 2.
|
||||
|
||||
- [x] **The Audio Test Needed Hardware CI Could Not Provide**:
|
||||
- **Issue**: `test_audio_probe` asserts that a system audio device opens and emits samples. A headless runner has no audio server, so the unit suite could never pass in CI.
|
||||
- **Resolution**: The test is opt-in through `SCYTHE_HW_TESTS=1` and still runs on a real desktop session. The remaining 27 tests run everywhere.
|
||||
|
||||
- [x] **Windows Autostart Does Nothing**:
|
||||
- **Issue**: `apply_autostart_system` has only a Linux body (systemd user unit plus XDG autostart entries). On Windows the call is a no-op, and the NSIS installer writes no registry `Run` key either, so the autostart switch in the overlay has no effect there.
|
||||
- **Resolution**: Parameters marked intentionally unused off Linux so the Windows build is warning-free, and the gap is recorded in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md). Implementing the registry entry is still open.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Run these commands before any commit or release to verify project health:
|
||||
|
||||
```bash
|
||||
# Type check all platform targets
|
||||
cargo check --all-targets
|
||||
|
||||
# Run the complete unit test suite (27 tests)
|
||||
cargo test
|
||||
|
||||
# Strict zero-warning clippy verification
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Build the unified release binary
|
||||
cargo build --release
|
||||
|
||||
# Run the automated end-to-end pipeline test (mock capture, ffprobe validation)
|
||||
bash tests/e2e_test.sh
|
||||
|
||||
# Hardware-dependent tests (needs a live audio server)
|
||||
SCYTHE_HW_TESTS=1 cargo test
|
||||
```
|
||||
|
||||
GitHub Actions runs the same checks on every push to `main`, for both the Linux and the Windows target. See [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml).
|
||||
|
||||
Formatting is currently advisory: `cargo fmt --check` still reports drift across the tree. Run `cargo fmt` as an isolated commit before turning it into a release gate.
|
||||
BIN
assets/fonts/AdwaitaMono-Regular.ttf
Normal file
BIN
assets/fonts/AdwaitaSans-Regular.ttf
Normal file
BIN
assets/images/checkbox_background.png
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
assets/images/checkbox_circle.png
Normal file
|
After Width: | Height: | Size: 727 B |
BIN
assets/images/combobox_arrow.png
Normal file
|
After Width: | Height: | Size: 330 B |
BIN
assets/images/cross.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/images/delete.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
assets/images/folder.png
Normal file
|
After Width: | Height: | Size: 738 B |
BIN
assets/images/gpu_screen_recorder_logo.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/images/info.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/masked.png
Normal file
|
After Width: | Height: | Size: 930 B |
BIN
assets/images/pause.png
Normal file
|
After Width: | Height: | Size: 392 B |
BIN
assets/images/play.png
Normal file
|
After Width: | Height: | Size: 872 B |
BIN
assets/images/ps4_cross.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/images/ps4_dpad_down.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/images/ps4_dpad_left.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/images/ps4_dpad_right.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/images/ps4_dpad_up.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/ps4_home.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/images/ps4_options.png
Normal file
|
After Width: | Height: | Size: 801 B |
BIN
assets/images/ps4_triangle.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/question_mark.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/record.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
assets/images/replay.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
assets/images/save.png
Normal file
|
After Width: | Height: | Size: 614 B |
BIN
assets/images/screenshot.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
assets/images/settings.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/images/settings_extra_small.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
assets/images/settings_small.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/images/stop.png
Normal file
|
After Width: | Height: | Size: 333 B |
BIN
assets/images/stream.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
assets/images/trash.png
Normal file
|
After Width: | Height: | Size: 410 B |
BIN
assets/images/unmasked.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/images/up_arrow.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/warning.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -0,0 +1,292 @@
|
|||
# Settings UI and Notification Engine Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild the Scythe settings UI into a clean, centered NVIDIA ShadowPlay-style split-panel modal with smooth direct numeric input fields and implement a zero-latency in-overlay toast animation system with graceful standalone toast handoff.
|
||||
|
||||
**Architecture:** Replace the legacy 3-column freestanding page stack in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs) with a unified `ShadowPlayView::Settings(SettingsTab)` modal. Implement numeric inputs with bounds clamping and inline stepper buttons. Replace external toast process killing with in-overlay egui animation banners and graceful window messaging.
|
||||
|
||||
**Tech Stack:** Rust 2024 edition, egui / eframe 0.31, Win32 DWM / Windows COM, Wayland layer-shell.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-10-04-settings-ui-and-notifications-redesign.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/docs/superpowers/specs/2026-10-04-settings-ui-and-notifications-redesign.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Absolutely zero emojis across code, comments, logs, test outputs, and commits.
|
||||
- Clickable markdown links formatted with the `file://` scheme.
|
||||
- Complete conditional compilation safety across Linux and Windows.
|
||||
- No blocking operations on UI rendering loops.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Sub-1080p display clipping: Modal must adaptively scale below 850px viewport heights without overflowing the screen.
|
||||
2. Direct numeric string parsing: Typing non-numeric characters in FPS, Bitrate, or Duration fields must not crash or corrupt settings state.
|
||||
3. Rapid hotkey keycap binding: Entering key listener mode must capture multi-modifier shortcuts (`Ctrl+Shift+R`, `Alt+Z`) cleanly and allow cancellation with `Escape`.
|
||||
4. Simultaneous in-overlay notifications: Multiple sequential alerts must reset duration or smoothly update without graphical popping.
|
||||
5. Multi-monitor Windows geometry: Standalone toasts and modals must respect active monitor bounds when `monitor_x` is negative or non-zero.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data Structures, Enums, and Numeric Input Component
|
||||
|
||||
**Files:**
|
||||
- Modify: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
- Test: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `pub enum SettingsTab { Video, Audio, Shortcuts, ReplayAndStorage, Livestream, AppearanceAndSystem }`
|
||||
- `pub enum ShadowPlayView { MainHud, Settings(SettingsTab), Gallery }`
|
||||
- `pub fn numeric_input_box(ui: &mut egui::Ui, value_str: &mut String, val: &mut u32, min: u32, max: u32, step: u32, unit: &str, accent: Color32) -> bool`
|
||||
- `pub struct InOverlayToast { pub title: String, pub subtitle: String, pub icon: ToastIcon, pub created_at: Instant, pub duration: Duration }`
|
||||
|
||||
- [ ] **Step 1: Write unit tests for numeric input bounds and Tab enums**
|
||||
|
||||
In `src/overlay_egui.rs` tests module:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_numeric_input_clamping_and_validation() {
|
||||
let mut val = 60u32;
|
||||
let mut text = "60".to_string();
|
||||
assert!((15..=360).contains(&val));
|
||||
|
||||
// Test text update
|
||||
text = "144".to_string();
|
||||
if let Ok(parsed) = text.trim().parse::<u32>() {
|
||||
val = parsed.clamp(15, 360);
|
||||
}
|
||||
assert_eq!(val, 144);
|
||||
|
||||
// Test out of bounds clamping
|
||||
text = "999".to_string();
|
||||
if let Ok(parsed) = text.trim().parse::<u32>() {
|
||||
val = parsed.clamp(15, 360);
|
||||
}
|
||||
assert_eq!(val, 360);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes or fails**
|
||||
|
||||
Run: `cargo test --lib test_numeric_input_clamping_and_validation`
|
||||
|
||||
- [ ] **Step 3: Define `SettingsTab` and `numeric_input_box` in `src/overlay_egui.rs`**
|
||||
|
||||
Implement:
|
||||
- `SettingsTab` enum with `Video`, `Audio`, `Shortcuts`, `ReplayAndStorage`, `Livestream`, `AppearanceAndSystem`.
|
||||
- `numeric_input_box` rendering a dark input box with `[-]` and `[+]` stepper buttons and unit label.
|
||||
- Add `active_tab: SettingsTab` and `in_overlay_toast: Option<InOverlayToast>` to `ScytheOverlayApp`.
|
||||
|
||||
- [ ] **Step 4: Verify compilation**
|
||||
|
||||
Run: `cargo check --all-targets`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/overlay_egui.rs
|
||||
git commit -m "feat(ui): add SettingsTab enum, InOverlayToast struct, and numeric input component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Unified Centered ShadowPlay Settings Modal Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
- Test: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SettingsTab`, `ScytheOverlayApp`, `accent_color()`.
|
||||
- Produces: `render_settings_modal(&mut self, ctx: &egui::Context, ui: &mut egui::Ui)`.
|
||||
|
||||
- [ ] **Step 1: Write layout geometry test for settings modal**
|
||||
|
||||
In `src/overlay_egui.rs` tests module:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_settings_modal_geometry_bounds() {
|
||||
for (screen_w, screen_h) in [(1280.0, 720.0), (1920.0, 1080.0), (2560.0, 1440.0), (3840.0, 2160.0)] {
|
||||
let is_compact_h = screen_h < 850.0;
|
||||
let modal_w = if screen_w < 1400.0 { 680.0 } else { 760.0 };
|
||||
let modal_h = if is_compact_h { 450.0 } else { 520.0 };
|
||||
let pos_x = (screen_w - modal_w) * 0.5;
|
||||
let pos_y = (screen_h - modal_h) * 0.5;
|
||||
|
||||
assert!(pos_x >= 0.0);
|
||||
assert!(pos_y >= 0.0);
|
||||
assert!(pos_x + modal_w <= screen_w);
|
||||
assert!(pos_y + modal_h <= screen_h);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test --lib test_settings_modal_geometry_bounds`
|
||||
|
||||
- [ ] **Step 3: Implement `render_settings_modal`**
|
||||
|
||||
- Centered modal container (`modal_w x modal_h`) with obsidian glass fill `#101418` and top accent line.
|
||||
- Left sidebar (`190px` width) with vertical tab buttons for `Video`, `Audio`, `Shortcuts`, `Replay & Storage`, `Livestream`, `Appearance & System`.
|
||||
- Active tab highlight with accent pill border and crisp text.
|
||||
- Compact "Reset to Defaults" button and version tag at sidebar footer.
|
||||
- Close button (`X`) and "Done" button at top-right.
|
||||
- Clicking outside or pressing Escape returns to `ShadowPlayView::MainHud`.
|
||||
|
||||
- [ ] **Step 4: Run compiler check**
|
||||
|
||||
Run: `cargo check --all-targets`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/overlay_egui.rs
|
||||
git commit -m "feat(ui): implement centered ShadowPlay settings modal with tab navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Category Settings Panels Implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `render_tab_video`
|
||||
- `render_tab_audio`
|
||||
- `render_tab_shortcuts`
|
||||
- `render_tab_replay_storage`
|
||||
- `render_tab_livestream`
|
||||
- `render_tab_appearance_system`
|
||||
|
||||
- [ ] **Step 1: Implement `render_tab_video`**
|
||||
- Numeric input box for Target Framerate (`15` to `360` FPS) with `[-]` and `[+]` steppers.
|
||||
- Numeric input box for Recording Bitrate (`5` to `150` Mbps).
|
||||
- Codec selector (`H.264`, `HEVC`, `AV1`).
|
||||
- Toggle switch for "Record mouse cursor".
|
||||
|
||||
- [ ] **Step 2: Implement `render_tab_audio`**
|
||||
- Source selector (`System Audio`, `Microphone`, `Both`, `Muted`).
|
||||
- Sliders for System Volume (0-100%) and Mic Volume (0-100%).
|
||||
- Live stereo VU meters with soft-limit indicators.
|
||||
|
||||
- [ ] **Step 3: Implement `render_tab_shortcuts`**
|
||||
- Interactive cards for `Menu Overlay`, `Save Replay`, `Start/Stop Record`, `Toggle Cursor`.
|
||||
- Click to listen for key combinations, with instant rebind and Esc to cancel.
|
||||
|
||||
- [ ] **Step 4: Implement `render_tab_replay_storage`**
|
||||
- Numeric input box for Replay Buffer Duration (`5` to `1800` seconds).
|
||||
- Calculated RAM footprint badge.
|
||||
- Path input with "Browse" button (invoking native Windows COM `IFileOpenDialog` / Linux portal) and "Open Folder".
|
||||
|
||||
- [ ] **Step 5: Implement `render_tab_livestream` and `render_tab_appearance_system`**
|
||||
- Service dropdown (`Twitch`, `YouTube`, `Kick`, `Custom RTMP`), URL, and masked stream key field.
|
||||
- 8 accent color chips with instant preview.
|
||||
- Startup toggles ("Start replay on login", "Open overlay on login").
|
||||
- In-app version checker with "Check for Updates" button.
|
||||
|
||||
- [ ] **Step 6: Run verification and tests**
|
||||
|
||||
Run: `cargo check --all-targets && cargo test`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/overlay_egui.rs
|
||||
git commit -m "feat(ui): implement clean settings tab panels with numeric input boxes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: In-Overlay Notification Animation Engine & Standalone Toast Overhaul
|
||||
|
||||
**Files:**
|
||||
- Modify: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
- Test: [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `ScytheOverlayApp::show_hud_notification` (queues in-process `InOverlayToast`)
|
||||
- `render_in_overlay_toast(&mut self, ctx: &egui::Context, ui: &mut egui::Ui)`
|
||||
- Overhauled `run_egui_toast` with cubic ease-out animation
|
||||
|
||||
- [ ] **Step 1: Write test for cubic ease-out calculation**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_cubic_ease_out_progress() {
|
||||
let ease_out = |t: f32| -> f32 {
|
||||
let inv = 1.0 - t.clamp(0.0, 1.0);
|
||||
1.0 - inv * inv * inv
|
||||
};
|
||||
assert_eq!(ease_out(0.0), 0.0);
|
||||
assert_eq!(ease_out(1.0), 1.0);
|
||||
assert!(ease_out(0.5) > 0.5); // Fast start, gentle deceleration
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test --lib test_cubic_ease_out_progress`
|
||||
|
||||
- [ ] **Step 3: Implement `render_in_overlay_toast`**
|
||||
- Paints floating notification banner in top-right corner of overlay.
|
||||
- `0.0s – 0.22s`: Slide in from right viewport margin using cubic ease-out.
|
||||
- `0.22s – 2.4s`: Fully visible with icon, bold title, subtitle, and discharging accent progress line.
|
||||
- `2.4s – 2.7s`: Smooth alpha fade-out to zero.
|
||||
- Renders directly in the egui viewport with zero process spawning overhead.
|
||||
|
||||
- [ ] **Step 4: Refactor `run_egui_toast` standalone window**
|
||||
- Uses the same cubic ease-out curve and ShadowPlay aesthetic card.
|
||||
- Positioned at `(active_mon_x + active_mon_w - 364, active_mon_y + 24)`.
|
||||
- Clean automatic exit after 2.7s duration.
|
||||
|
||||
- [ ] **Step 5: Verify compiler and tests**
|
||||
|
||||
Run: `cargo check --all-targets && cargo test`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/overlay_egui.rs
|
||||
git commit -m "feat(ui): implement in-overlay toast animation engine and overhaul standalone toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integration, End-to-End Verification, and Build Deployment
|
||||
|
||||
**Files:**
|
||||
- Modify: [`TODO_CHECKLIST.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/TODO_CHECKLIST.md)
|
||||
- Test: [`tests/e2e_test.sh`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/tests/e2e_test.sh)
|
||||
|
||||
- [ ] **Step 1: Run clippy and check across all targets**
|
||||
|
||||
Run: `cargo clippy --all-targets -- -D warnings`
|
||||
Expected: 0 warnings, 0 errors.
|
||||
|
||||
- [ ] **Step 2: Run full unit test suite**
|
||||
|
||||
Run: `cargo test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Run end-to-end integration test**
|
||||
|
||||
Run: `bash tests/e2e_test.sh`
|
||||
Expected: Recording and replay succeed without corruption.
|
||||
|
||||
- [ ] **Step 4: Compile release binaries and install locally**
|
||||
|
||||
Run: `cargo build --release && bash install.sh`
|
||||
Expected: Fresh binaries deployed to `~/.local/bin/` and daemon restarted.
|
||||
|
||||
- [ ] **Step 5: Commit final changes**
|
||||
|
||||
```bash
|
||||
git add TODO_CHECKLIST.md
|
||||
git commit -m "chore: update maintainer checklist with settings modal and notification overhaul"
|
||||
```
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
# Design Specification: Scythe Settings UI and Notification Engine Redesign
|
||||
|
||||
## 1. Overview & Objective
|
||||
|
||||
This specification details the comprehensive redesign of the Settings User Interface and Notification Engine in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). The objective is to replace the legacy 3-column freestanding settings pages and fragmented external toast process spawning with:
|
||||
|
||||
1. **A Unified NVIDIA ShadowPlay-Inspired Settings Modal**:
|
||||
- Centered, frosted-glass modal (`780px x 520px`) with split-panel layout.
|
||||
- Left-hand category sidebar (`Video`, `Audio`, `Shortcuts`, `Replay & Storage`, `Livestream`, `Appearance & System`).
|
||||
- Clean, direct numeric input boxes for Bitrate, Framerate, and Replay Duration (with inline unit tags, instant validation, and increment/decrement steppers), replacing cluttered pill grids and sliders.
|
||||
- Live calculated buffer metrics and instant folder browser buttons.
|
||||
2. **Fluid, Multi-Tier Notification Engine**:
|
||||
- In-Overlay Notification Banner: Rendered in-process by egui with zero process spawn latency and cubic ease-out animations.
|
||||
- Standalone Toast Window: Clean, single-window presentation on active monitors across Linux and Windows with graceful lifecycle teardown.
|
||||
3. **Cross-Platform Zero-Defect Architecture**:
|
||||
- Full parity and bug-free execution across Linux (Wayland layer-shell, X11) and Windows 10/11 (DWM per-pixel glass transparency, multi-monitor geometry detection).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Component Decomposition
|
||||
|
||||
### 2.1 UI State Machine & Views
|
||||
In [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), replace the disjointed view variants (`ReplaySettings`, `RecordSettings`, `StreamSettings`, `GlobalSettings`, `ScreenshotSettings`) with a cohesive modal state:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShadowPlayView {
|
||||
MainHud,
|
||||
Settings(SettingsTab),
|
||||
Gallery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsTab {
|
||||
Video,
|
||||
Audio,
|
||||
Shortcuts,
|
||||
ReplayAndStorage,
|
||||
Livestream,
|
||||
AppearanceAndSystem,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Modal Window Geometry & Styling
|
||||
- **Modal Dimensions**: Width `780.0px`, Height `520.0px` (adaptively scaled on viewports with height `< 850.0px` to `680.0px x 450.0px`).
|
||||
- **Dimmed Backdrop**: Dimming scrim covering the screen (`rgba(0, 0, 0, 0.70)` on Linux, transparent backbuffer on Windows with DWM glass). Clicking outside the modal or pressing `Escape` saves settings and returns cleanly to `MainHud`.
|
||||
- **Card Framing**:
|
||||
- Fill: Obsidian Glass `Color32::from_rgb(16, 20, 24)`.
|
||||
- Border: 1px subtle stroke `Color32::from_rgba_unmultiplied(255, 255, 255, 18)`.
|
||||
- Accent Line: Top 3px highlight band colored by user's active theme accent.
|
||||
|
||||
### 2.3 Left-Hand Sidebar Navigation (Width `200.0px`)
|
||||
Displays a vertical stack of category buttons:
|
||||
- Tab item height: `38.0px`, rounded corners (`CornerRadius::same(4)`).
|
||||
- Active tab styling: Accent-tinted background (`rgba(accent, 0.20)`), 1.5px accent left border pill, and bold white text.
|
||||
- Inactive tab styling: Transparent background with `#94A3B8` text, shifting to `#CBD5E1` on hover.
|
||||
- Footer controls: Compact secondary button: "Reset to Defaults" (`height: 30.0px`) and version identifier ("Scythe v0.2.17").
|
||||
|
||||
### 2.4 Right-Hand Content Panel (Width `540.0px`)
|
||||
Displays the content for the currently active `SettingsTab`:
|
||||
|
||||
#### Tab 1: Video Settings
|
||||
- **Target Framerate (FPS)**:
|
||||
- Numeric input box with unit `FPS`. Range `15` to `360`. Stepper buttons `[-]` and `[+]` by 5/10 steps.
|
||||
- **Recording Video Bitrate**:
|
||||
- Numeric input box with unit `Mbps`. Range `5` to `150`.
|
||||
- **Video Codec**:
|
||||
- Clean horizontal segment pills `[ H.264 ]`, `[ HEVC (H.265) ]`, `[ AV1 ]`.
|
||||
- **Mouse Cursor Recording**:
|
||||
- Sleek toggle switch: "Record mouse cursor".
|
||||
|
||||
#### Tab 2: Audio Settings
|
||||
- **Audio Capture Source**:
|
||||
- Segment pills `[ System Audio ]`, `[ Microphone ]`, `[ Both ]`, `[ Muted ]`.
|
||||
- **Microphone Selection**:
|
||||
- Dropdown populated from system input devices.
|
||||
- **Volume Inputs & Controls**:
|
||||
- System Volume (`0% – 100%`) and Microphone Volume (`0% – 100%`) with numeric fields and live visual VU meters.
|
||||
|
||||
#### Tab 3: Shortcuts Settings
|
||||
- **Hotkeys Cards**:
|
||||
- Menu Overlay (Default: `Alt+Z`)
|
||||
- Save Instant Replay (Default: `Ctrl+Shift+R`)
|
||||
- Start/Stop Recording (Default: `Ctrl+Shift+F9`)
|
||||
- Toggle Mouse Cursor (Default: `Ctrl+Shift+F10`)
|
||||
- **Key Listening Mode**:
|
||||
- Displays interactive keycap badge. When clicked, enters listening state; pressing any key combination captures and re-binds instantly. Pressing `Escape` cancels without modifying.
|
||||
|
||||
#### Tab 4: Replay & Storage Settings
|
||||
- **Replay Buffer Duration**:
|
||||
- Numeric input box with unit `seconds`. Range `5` to `1800` (up to 30 minutes).
|
||||
- Displays human-readable label: e.g. `60 sec (1 min 00 sec)`.
|
||||
- **Estimated RAM Footprint**:
|
||||
- Calculated dynamically: `(seconds * bitrate_mbps * 1000) / 8192 MB`.
|
||||
- **Save Location**:
|
||||
- Path text field with "Browse" button (invokes Windows COM `IFileOpenDialog` or Linux portal) and "Open" folder button.
|
||||
|
||||
#### Tab 5: Livestream Settings
|
||||
- **Streaming Service**: Dropdown for `Twitch`, `YouTube`, `Kick`, `Custom RTMP`.
|
||||
- **Server URL & Stream Key**:
|
||||
- Stream URL text input.
|
||||
- Stream Key masked input with eye toggle to unmask.
|
||||
|
||||
#### Tab 6: Appearance & System Settings
|
||||
- **Accent Color Theme**:
|
||||
- 8 palette chips (AMD Red, NVIDIA Green, Intel Blue, Charming Blue, Emerald Green, Solar Yellow, Royal Purple, Neon Pink).
|
||||
- **Startup Integration**:
|
||||
- Toggles for "Launch background replay engine on login" and "Launch HUD overlay on login".
|
||||
- **Application Updater**:
|
||||
- Status display and "Check for Updates" trigger invoking [src/updater.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs).
|
||||
|
||||
---
|
||||
|
||||
## 3. Notification Animation & Rendering Architecture
|
||||
|
||||
### 3.1 In-Overlay Notification System (Overlay Open)
|
||||
- Stored as an optional field in `ScytheOverlayApp`:
|
||||
```rust
|
||||
pub struct InOverlayToast {
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub icon: ToastIcon,
|
||||
pub created_at: Instant,
|
||||
pub duration: Duration,
|
||||
}
|
||||
```
|
||||
- **Lifecycle & Easing**:
|
||||
- `0.0s – 0.22s`: Slide in from right viewport edge (`x` offset interpolated with `cubic_ease_out`).
|
||||
- `0.22s – 2.4s`: Fully visible top-right card with accent progress bar discharging to 0.
|
||||
- `2.4s – 2.7s`: Alpha fade out to `0.0`.
|
||||
- Handled directly in egui frame drawing with zero additional OS windows or process spawns.
|
||||
|
||||
### 3.2 Standalone Floating Toast System (Overlay Closed)
|
||||
- Invoked via `scythe-ui --toast <title> <subtitle> <icon>`.
|
||||
- Displays a dedicated single-purpose viewport card (`340px x 56px`):
|
||||
- Borderless, per-pixel transparent background, positioned at `(active_mon_x + active_mon_w - 364, active_mon_y + 24)`.
|
||||
- Icon on left, bold title, message, and subtle accent bottom border.
|
||||
- Smooth 2.6-second duration with auto-shutdown.
|
||||
- Replaces process hard-kills with clean window messages (`WM_CLOSE` on Windows, graceful `SIGTERM` settling on Linux).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification & Testing Plan
|
||||
|
||||
1. **Unit Tests**:
|
||||
- Numeric input bounds validation (FPS 15-360, Bitrate 5-150, Replay 5-1800).
|
||||
- Layout geometry assertions ensuring modal cards fit across 720p, 1080p, 1440p, and 4K displays.
|
||||
- Color palette mapping and theme serialization.
|
||||
2. **Integration Verification**:
|
||||
- Verify settings persistence to `~/.config/scythe/config.json`.
|
||||
- Verify daemon dynamic reload on parameter changes.
|
||||
- Verify in-overlay notification display and standalone toast execution.
|
||||
3. **Compiler Checks**:
|
||||
- `cargo check --all-targets` with zero errors.
|
||||
- `cargo clippy --all-targets -- -D warnings` with zero warnings.
|
||||
- `cargo test` with all tests passing.
|
||||
55
install.sh
|
|
@ -15,19 +15,54 @@ APP_DIR="$HOME/.local/share/applications"
|
|||
mkdir -p "$BIN_DIR"
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
echo "[2/4] Installing binaries to $BIN_DIR..."
|
||||
install -m 755 target/release/scythe-daemon "$BIN_DIR/scythe-daemon"
|
||||
install -m 755 target/release/scythe-ui "$BIN_DIR/scythe-ui"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/scythe"
|
||||
echo "[2/4] Installing singular binary to $BIN_DIR..."
|
||||
install -m 755 target/release/scythe "$BIN_DIR/scythe"
|
||||
|
||||
# Backward-compatible symlinks
|
||||
ln -sf "$BIN_DIR/scythe-daemon" "$BIN_DIR/vrec-daemon"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec-ui"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec"
|
||||
# Backward-compatible multicall symlinks
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-daemon"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-ui"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-daemon"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-ui"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec"
|
||||
|
||||
if pgrep -x scythe-daemon > /dev/null; then
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cat > "$SYSTEMD_USER_DIR/scythe-daemon.service" << EOF
|
||||
[Unit]
|
||||
Description=Scythe Screen Recorder Background Engine
|
||||
After=graphical-session.target xdg-desktop-portal.service xdg-desktop-portal-hyprland.service
|
||||
Wants=xdg-desktop-portal.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$BIN_DIR/scythe daemon
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
Environment="XDG_CURRENT_DESKTOP=Hyprland"
|
||||
Environment="XDG_SESSION_TYPE=wayland"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
if [ -f "$SYSTEMD_USER_DIR/vrec-daemon.service" ] || systemctl --user is-active --quiet vrec-daemon.service 2>/dev/null; then
|
||||
echo "Migrating legacy vrec-daemon systemd service to scythe-daemon.service..."
|
||||
systemctl --user stop vrec-daemon.service 2>/dev/null || true
|
||||
systemctl --user disable vrec-daemon.service 2>/dev/null || true
|
||||
rm -f "$SYSTEMD_USER_DIR/vrec-daemon.service"
|
||||
rm -f "$SYSTEMD_USER_DIR/default.target.wants/vrec-daemon.service"
|
||||
fi
|
||||
|
||||
if [ -f "$SYSTEMD_USER_DIR/scythe-daemon.service" ] || systemctl --user is-enabled --quiet scythe-daemon.service 2>/dev/null; then
|
||||
echo "Restarting active scythe-daemon systemd service..."
|
||||
systemctl --user reset-failed scythe-daemon.service 2>/dev/null || true
|
||||
systemctl --user restart scythe-daemon.service
|
||||
elif pgrep -x scythe-daemon > /dev/null || pgrep -x vrec-daemon > /dev/null; then
|
||||
echo "Restarting active scythe-daemon engine..."
|
||||
pkill -x scythe-daemon || true
|
||||
pkill -x scythe-daemon 2>/dev/null || true
|
||||
pkill -x vrec-daemon 2>/dev/null || true
|
||||
sleep 0.5
|
||||
nohup "$BIN_DIR/scythe-daemon" > /dev/null 2>&1 &
|
||||
disown
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ runtime-version: '46'
|
|||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: scythe-ui
|
||||
command: scythe
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
|
|
@ -26,11 +26,11 @@ modules:
|
|||
buildsystem: simple
|
||||
build-commands:
|
||||
- cargo build --release --verbose
|
||||
- install -Dm755 target/release/scythe-daemon /app/bin/scythe-daemon
|
||||
- install -Dm755 target/release/scythe-ui /app/bin/scythe-ui
|
||||
- install -Dm755 target/release/scythe /app/bin/scythe
|
||||
- ln -sf /app/bin/scythe-daemon /app/bin/vrec-daemon
|
||||
- ln -sf /app/bin/scythe-ui /app/bin/vrec-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/scythe-daemon
|
||||
- ln -sf /app/bin/scythe /app/bin/scythe-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec-daemon
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec
|
||||
- install -Dm644 packaging/scythe.desktop /app/share/applications/com.scythe.Scythe.desktop
|
||||
sources:
|
||||
|
|
|
|||
|
|
@ -32,19 +32,19 @@ cargo build --release
|
|||
|
||||
%install
|
||||
rm -rf $RPM_BUILD_ROOT
|
||||
install -D -m 0755 target/release/scythe-daemon %{buildroot}%{_bindir}/scythe-daemon
|
||||
install -D -m 0755 target/release/scythe-ui %{buildroot}%{_bindir}/scythe-ui
|
||||
install -D -m 0755 target/release/scythe %{buildroot}%{_bindir}/scythe
|
||||
ln -sf %{_bindir}/scythe-daemon %{buildroot}%{_bindir}/vrec-daemon
|
||||
ln -sf %{_bindir}/scythe-ui %{buildroot}%{_bindir}/vrec-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/scythe-daemon
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/scythe-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec-daemon
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec
|
||||
install -D -m 0644 packaging/scythe.desktop %{buildroot}%{_datadir}/applications/scythe.desktop
|
||||
ln -sf %{_datadir}/applications/scythe.desktop %{buildroot}%{_datadir}/applications/vrec.desktop
|
||||
|
||||
%files
|
||||
%{_bindir}/scythe
|
||||
%{_bindir}/scythe-daemon
|
||||
%{_bindir}/scythe-ui
|
||||
%{_bindir}/scythe
|
||||
%{_bindir}/vrec-daemon
|
||||
%{_bindir}/vrec-ui
|
||||
%{_bindir}/vrec
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ RequestExecutionLevel user
|
|||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\scythe-ui.exe"
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\scythe.exe"
|
||||
!define MUI_FINISHPAGE_RUN_PARAMETERS "--menu"
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Launch Scythe"
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
|
@ -27,11 +27,12 @@ RequestExecutionLevel user
|
|||
|
||||
Section "Scythe" SecMain
|
||||
; Terminate any running instances so locked executables and DLLs can be updated cleanly
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
SetOverwrite try
|
||||
|
|
@ -40,23 +41,24 @@ Section "Scythe" SecMain
|
|||
|
||||
; Create Start Menu and Desktop shortcuts
|
||||
CreateDirectory "$SMPROGRAMS\scythe"
|
||||
CreateShortcut "$SMPROGRAMS\scythe\scythe.lnk" "$INSTDIR\scythe-ui.exe" "--menu"
|
||||
CreateShortcut "$DESKTOP\scythe.lnk" "$INSTDIR\scythe-ui.exe" "--menu"
|
||||
CreateShortcut "$SMPROGRAMS\scythe\scythe.lnk" "$INSTDIR\scythe.exe" "--menu"
|
||||
CreateShortcut "$DESKTOP\scythe.lnk" "$INSTDIR\scythe.exe" "--menu"
|
||||
|
||||
; Register uninstaller with Windows Control Panel / Settings
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayName" "Scythe Screen Recorder"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayIcon" "$INSTDIR\scythe-ui.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayIcon" "$INSTDIR\scythe.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "Publisher" "oiupoyt"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
Delete "$DESKTOP\scythe.lnk"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ RequestExecutionLevel user
|
|||
|
||||
Section
|
||||
; Terminate any running instances so locked executables and DLLs can be updated cleanly
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
SetOverwrite try
|
||||
|
|
@ -21,11 +22,11 @@ Section
|
|||
SetOutPath "$LOCALAPPDATA\scythe"
|
||||
File /r "..\..\dist\bundle\*.*"
|
||||
|
||||
; Execute scythe-ui with any forwarded command-line arguments, or default to overlay menu
|
||||
; Execute scythe with any forwarded command-line arguments, or default to overlay menu
|
||||
${GetParameters} $R0
|
||||
${If} $R0 == ""
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe-ui.exe" --menu'
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe.exe" --menu'
|
||||
${Else}
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe-ui.exe" $R0'
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe.exe" $R0'
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
|
|
|||
|
|
@ -1,742 +0,0 @@
|
|||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
|
||||
use scythe::capture::{Frame, FrameSource};
|
||||
use scythe::encoder::VideoEncoder;
|
||||
use scythe::ring::Packet;
|
||||
use scythe::muxer::Muxer;
|
||||
use scythe::ipc::{Command, DaemonStatus};
|
||||
use ringbuf::HeapRb;
|
||||
use ringbuf::traits::{RingBuffer, Consumer, Observer};
|
||||
use crossbeam_channel::bounded;
|
||||
use std::thread;
|
||||
use std::env;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn ensure_wayland_env() {
|
||||
scythe::overlay::ensure_wayland_env();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
#[allow(unused_assignments)]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
use windows::Win32::System::Console::FreeConsole;
|
||||
let _ = FreeConsole();
|
||||
}
|
||||
|
||||
ensure_wayland_env();
|
||||
|
||||
#[cfg(unix)]
|
||||
let _lock_file = {
|
||||
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
|
||||
let lock_path = format!("{}/scythe.lock", runtime_dir);
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(file) => {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let res = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if res != 0 {
|
||||
eprintln!("Another instance of scythe-daemon is already running. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
Some(file)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
let initial_config = scythe::config::ScytheConfig::load();
|
||||
|
||||
// Automatically register dynamic keybindings on Hyprland if active (no config edits needed)
|
||||
scythe::hyprland_binds::register_hyprland_binds(&initial_config);
|
||||
scythe::hyprland_binds::spawn_hyprland_reload_watcher();
|
||||
|
||||
let (frame_tx, frame_rx) = bounded::<Frame>(5);
|
||||
let (cmd_tx, cmd_rx) = bounded::<Command>(32);
|
||||
let (mux_tx, mux_rx) = bounded::<Vec<Packet>>(4);
|
||||
let (audio_tx, audio_rx) = bounded::<Vec<f32>>(500);
|
||||
|
||||
let is_recording_state = Arc::new(AtomicBool::new(false));
|
||||
let record_start_state = Arc::new(AtomicU64::new(0));
|
||||
let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled));
|
||||
let audio_muted_state = Arc::new(AtomicBool::new(false));
|
||||
let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new());
|
||||
|
||||
#[cfg(unix)]
|
||||
let listener = {
|
||||
let socket_path = scythe::ipc::get_socket_path();
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let l = std::os::unix::net::UnixListener::bind(&socket_path)?;
|
||||
println!("Daemon listening on IPC socket: {}", socket_path);
|
||||
l
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
let listener = {
|
||||
let ports = [42069, 42070, 42071, 42072];
|
||||
let mut bound = None;
|
||||
for &port in &ports {
|
||||
match std::net::TcpListener::bind(format!("127.0.0.1:{}", port)) {
|
||||
Ok(l) => {
|
||||
bound = Some((l, port));
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
if scythe::ipc::query_status_port(port).is_ok() {
|
||||
println!("Another instance of scythe-daemon is already running on port {}. Exiting cleanly.", port);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match bound {
|
||||
Some((l, p)) => {
|
||||
println!("Daemon listening on TCP IPC: 127.0.0.1:{}", p);
|
||||
let _ = std::fs::write(scythe::ipc::get_ipc_port_path(), p.to_string());
|
||||
let _ = std::fs::write(std::env::temp_dir().join("vrec-ipc.port"), p.to_string());
|
||||
l
|
||||
}
|
||||
None => {
|
||||
return Err("Failed to bind any TCP IPC port (all ports 42069-42072 in use)".into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cmd_tx_ipc = cmd_tx.clone();
|
||||
let is_recording_state_ipc = Arc::clone(&is_recording_state);
|
||||
let record_start_state_ipc = Arc::clone(&record_start_state);
|
||||
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state);
|
||||
let audio_muted_state_ipc = Arc::clone(&audio_muted_state);
|
||||
let audio_levels_ipc = Arc::clone(&audio_levels);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(mut stream) => {
|
||||
let cmd_tx_ipc = cmd_tx_ipc.clone();
|
||||
let is_recording_state_ipc = Arc::clone(&is_recording_state_ipc);
|
||||
let record_start_state_ipc = Arc::clone(&record_start_state_ipc);
|
||||
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state_ipc);
|
||||
let audio_muted_state_ipc = Arc::clone(&audio_muted_state_ipc);
|
||||
let audio_levels_ipc = Arc::clone(&audio_levels_ipc);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(1500)));
|
||||
let _ = stream.set_write_timeout(Some(std::time::Duration::from_millis(1500)));
|
||||
let mut len_buf = [0u8; 4];
|
||||
if stream.read_exact(&mut len_buf).is_ok() {
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut payload = vec![0u8; len];
|
||||
if stream.read_exact(&mut payload).is_ok()
|
||||
&& let Ok(cmd) = serde_json::from_slice::<Command>(&payload) {
|
||||
match cmd {
|
||||
Command::GetStatus => {
|
||||
let rec = is_recording_state_ipc.load(Ordering::SeqCst);
|
||||
let start_ts = record_start_state_ipc.load(Ordering::SeqCst);
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let duration = if rec && start_ts > 0 {
|
||||
now.saturating_sub(start_ts)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
let status = DaemonStatus {
|
||||
is_recording: rec,
|
||||
recording_duration_sec: duration,
|
||||
is_replay_active: replay_enabled_state_ipc.load(Ordering::SeqCst),
|
||||
audio_muted: audio_muted_state_ipc.load(Ordering::SeqCst),
|
||||
audio_mode: cfg.audio_mode,
|
||||
show_cursor: cfg.show_cursor,
|
||||
mic_volume: cfg.mic_volume,
|
||||
system_volume: cfg.system_volume,
|
||||
mic_level_peak: audio_levels_ipc.get_mic_peak(),
|
||||
system_level_peak: audio_levels_ipc.get_system_peak(),
|
||||
};
|
||||
if let Ok(resp) = serde_json::to_vec(&status) {
|
||||
let len_resp = (resp.len() as u32).to_le_bytes();
|
||||
let _ = stream.write_all(&len_resp);
|
||||
let _ = stream.write_all(&resp);
|
||||
}
|
||||
},
|
||||
Command::ToggleCursor => {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
cfg.show_cursor = !cfg.show_cursor;
|
||||
let _ = cfg.save();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_default();
|
||||
if session_type.to_lowercase() == "wayland" {
|
||||
println!("Cursor display toggled to: {}. Restarting Wayland session...", cfg.show_cursor);
|
||||
let _ = cmd_tx_ipc.send(Command::StopRecording);
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let _ = std::process::Command::new(exe)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
println!("Cursor display toggled to: {}", cfg.show_cursor);
|
||||
},
|
||||
Command::CycleAudioMode => {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
cfg.audio_mode = match cfg.audio_mode.as_str() {
|
||||
"system" => "mic",
|
||||
"mic" => "both",
|
||||
"both" => "muted",
|
||||
_ => "system",
|
||||
}.to_string();
|
||||
let _ = cfg.save();
|
||||
println!("Audio mode cycled to: {}", cfg.audio_mode);
|
||||
let _ = cmd_tx_ipc.try_send(Command::ReloadConfig);
|
||||
},
|
||||
Command::StopDaemon => {
|
||||
println!("StopDaemon requested: Finalizing active recordings...");
|
||||
scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load());
|
||||
let _ = cmd_tx_ipc.send(Command::StopRecording);
|
||||
std::thread::sleep(std::time::Duration::from_millis(350));
|
||||
std::process::exit(0);
|
||||
},
|
||||
other => {
|
||||
let _ = cmd_tx_ipc.send(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Socket error: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cmd_tx_sig = cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
println!("\nReceived shutdown signal. Finalizing recordings...");
|
||||
let _ = cmd_tx_sig.send(Command::StopRecording);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load());
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string());
|
||||
println!("Detected session type: {}", session_type);
|
||||
|
||||
let audio_capture_initial = scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
audio_tx.clone(),
|
||||
Some(&initial_config.audio_device),
|
||||
&initial_config.audio_mode,
|
||||
initial_config.mic_volume,
|
||||
initial_config.system_volume,
|
||||
Some(Arc::clone(&audio_levels)),
|
||||
).ok();
|
||||
|
||||
let mut source_opt: Option<Box<dyn FrameSource>> = None;
|
||||
|
||||
if std::env::args().any(|a| a == "--mock") {
|
||||
println!("Initializing MOCK capture...");
|
||||
source_opt = Some(Box::new(scythe::capture::mock::MockCapture::new()));
|
||||
} else if cfg!(target_os = "windows") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!("Initializing Windows DXGI capture...");
|
||||
if let Ok(c) = scythe::capture::windows::WindowsCapture::new() {
|
||||
source_opt = Some(Box::new(c));
|
||||
}
|
||||
}
|
||||
} else if session_type.to_lowercase() == "wayland" {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
for attempt in 1..=5 {
|
||||
println!("Initializing Wayland capture (cursor: {}, attempt {}/5)...", initial_config.show_cursor, attempt);
|
||||
// Ensure desktop portal is running
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "xdg-desktop-portal-hyprland"])
|
||||
.status();
|
||||
|
||||
match scythe::capture::wayland::WaylandCapture::new_with_cursor(initial_config.show_cursor).await {
|
||||
Ok(cap) => {
|
||||
source_opt = Some(Box::new(cap));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Wayland capture attempt {} failed: {}", attempt, e);
|
||||
if attempt < 5 {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "restart", "xdg-desktop-portal"])
|
||||
.status();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!("Initializing X11 capture...");
|
||||
if let Ok(c) = scythe::capture::x11::X11Capture::new() {
|
||||
source_opt = Some(Box::new(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut source: Box<dyn FrameSource> = match source_opt {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Warning: Hardware capture unavailable; falling back to mock capture to preserve daemon IPC.");
|
||||
Box::new(scythe::capture::mock::MockCapture::new())
|
||||
}
|
||||
};
|
||||
|
||||
let first_frame = match source.next_frame_timeout(std::time::Duration::from_millis(600)) {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
Frame::Raw {
|
||||
data: vec![0u8; 1920 * 1080 * 4],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
stride: 1920 * 4,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (width, height) = match &first_frame {
|
||||
Frame::Raw { width, height, .. } => (*width, *height),
|
||||
Frame::DmaBuf { width, height, .. } => (*width, *height),
|
||||
#[cfg(target_os = "windows")]
|
||||
Frame::D3D11Texture { width, height, .. } => (*width, *height),
|
||||
};
|
||||
println!("Capture stream active: resolution {}x{}", width, height);
|
||||
|
||||
let _ = frame_tx.send(first_frame);
|
||||
|
||||
let capture_tx = frame_tx.clone();
|
||||
thread::spawn(move || {
|
||||
let mut err_count = 0;
|
||||
loop {
|
||||
match source.next_frame() {
|
||||
Ok(frame) => {
|
||||
err_count = 0;
|
||||
let _ = capture_tx.try_send(frame);
|
||||
}
|
||||
Err(e) => {
|
||||
err_count += 1;
|
||||
if err_count % 60 == 1 {
|
||||
eprintln!("Capture transient error: {}", e);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let rec_state_clone = Arc::clone(&is_recording_state);
|
||||
let rec_start_clone = Arc::clone(&record_start_state);
|
||||
let replay_state_clone = Arc::clone(&replay_enabled_state);
|
||||
let audio_muted_clone = Arc::clone(&audio_muted_state);
|
||||
let audio_tx_clone = audio_tx.clone();
|
||||
let audio_info = audio_capture_initial.as_ref().map(|(_, sr, ch)| (*sr, *ch));
|
||||
#[allow(unused_variables, unused_assignments)]
|
||||
let mut audio_capture = audio_capture_initial.map(|(c, _, _)| c);
|
||||
|
||||
let recorder_handle = thread::spawn(move || {
|
||||
let mut config = scythe::config::ScytheConfig::load();
|
||||
replay_state_clone.store(config.replay_enabled, Ordering::SeqCst);
|
||||
|
||||
let mut encoder = VideoEncoder::new_with_params(width, height, config.record_bitrate_kbps, config.fps, &config.video_codec)
|
||||
.expect("Failed to init encoder");
|
||||
let codec_ctx_ptr = encoder.codec_ctx() as usize;
|
||||
println!("Background recorder engine active and ready ({} fps, replay: {}).", config.fps, config.replay_enabled);
|
||||
|
||||
let mut audio_encoder = if let Some((sr, ch)) = audio_info {
|
||||
scythe::encoder::AudioEncoder::new(sr as i32, ch as i32).ok()
|
||||
} else {
|
||||
scythe::encoder::AudioEncoder::new(48000, 2).ok()
|
||||
};
|
||||
let audio_codec_ctx_ptr = audio_encoder.as_ref().map(|e| e.codec_ctx() as usize);
|
||||
|
||||
let video_time_base = unsafe { (*(codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext)).time_base };
|
||||
let audio_time_base = audio_codec_ctx_ptr.map(|p| unsafe { (*(p as *mut ffmpeg_next::ffi::AVCodecContext)).time_base });
|
||||
|
||||
let mut ring = HeapRb::<Packet>::new((config.replay_duration_sec as usize) * (config.fps as usize + 60) + 120);
|
||||
let mut normal_muxer: Option<Muxer> = None;
|
||||
let mut normal_recording = false;
|
||||
let mut normal_waiting_keyframe = false;
|
||||
let mut rec_base_video_pts: i64 = 0;
|
||||
let mut rec_base_audio_pts: i64 = -1;
|
||||
|
||||
thread::spawn(move || {
|
||||
while let Ok(drain) = mux_rx.recv() {
|
||||
let filename = scythe::config::ScytheConfig::format_video_filename("Replay", "mp4");
|
||||
let full_path = scythe::config::ScytheConfig::load().resolve_save_path(&filename);
|
||||
println!("Saving replay to {}...", full_path);
|
||||
|
||||
// Find the first video IDR keyframe (ignore audio packets which always have keyframe flag set)
|
||||
let start_video_idx = drain.iter().position(|p| p.stream_index() == 0 && p.is_keyframe());
|
||||
let first_v_idx = match start_video_idx {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
println!("No video keyframe found in buffer, skipping save.");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let first_video_pts = drain[first_v_idx].pts();
|
||||
let first_video_time_us = unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
first_video_pts,
|
||||
video_time_base,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
};
|
||||
|
||||
// Find the audio packet in the ring buffer closest in real time to the first video keyframe
|
||||
let first_audio_pts = drain
|
||||
.iter()
|
||||
.filter(|p| p.stream_index() == 1)
|
||||
.min_by_key(|p| {
|
||||
let a_time_us = if let Some(a_tb) = audio_time_base {
|
||||
unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
p.pts(),
|
||||
a_tb,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(a_time_us - first_video_time_us).abs()
|
||||
})
|
||||
.map(|p| p.pts())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut prepared: Vec<(i64, Packet)> = Vec::with_capacity(drain.len());
|
||||
for (i, p) in drain.iter().enumerate() {
|
||||
if p.stream_index() == 0 {
|
||||
if i >= first_v_idx {
|
||||
let rebased = p.rebased(first_video_pts);
|
||||
let time_us = unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
rebased.pts(),
|
||||
video_time_base,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
};
|
||||
prepared.push((time_us, rebased));
|
||||
}
|
||||
} else if p.stream_index() == 1
|
||||
&& p.pts() >= first_audio_pts {
|
||||
let rebased = p.rebased(first_audio_pts);
|
||||
let time_us = if let Some(a_tb) = audio_time_base {
|
||||
unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
rebased.pts(),
|
||||
a_tb,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
prepared.push((time_us, rebased));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort packets by presentation timestamp so muxer receives strictly chronological stream
|
||||
prepared.sort_by_key(|(t, _)| *t);
|
||||
|
||||
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
|
||||
let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext);
|
||||
match unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx) } {
|
||||
Ok(mut muxer) => {
|
||||
for (_, p) in prepared {
|
||||
if let Err(e) = muxer.write_packet(&p) {
|
||||
eprintln!("Error writing packet to replay muxer: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Err(e) = muxer.finalize() {
|
||||
eprintln!("Error finalizing replay muxer: {}", e);
|
||||
} else {
|
||||
println!("Replay saved to {}!", full_path);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create muxer for {}: {}", full_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream_start = std::time::Instant::now();
|
||||
let mut last_video_pts: i64 = -1;
|
||||
let mut ticker = crossbeam_channel::tick(std::time::Duration::from_nanos(1_000_000_000 / config.fps.max(1) as u64));
|
||||
let mut latest_frame: Option<Frame> = None;
|
||||
let mut has_new_frame = false;
|
||||
|
||||
loop {
|
||||
crossbeam_channel::select! {
|
||||
recv(cmd_rx) -> cmd_res => {
|
||||
if let Ok(cmd) = cmd_res {
|
||||
match cmd {
|
||||
Command::ReloadConfig => {
|
||||
let new_config = scythe::config::ScytheConfig::load();
|
||||
if new_config.show_cursor != config.show_cursor {
|
||||
println!("Cursor display changed ({} -> {}). Restarting daemon for new capture session...", config.show_cursor, new_config.show_cursor);
|
||||
if let Some(mut m) = normal_muxer.take() {
|
||||
let _ = m.finalize();
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = cmd.spawn();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::process::exit(0);
|
||||
}
|
||||
config = new_config;
|
||||
replay_state_clone.store(config.replay_enabled, Ordering::SeqCst);
|
||||
audio_muted_clone.store(config.audio_mode == "muted", Ordering::SeqCst);
|
||||
println!("Daemon config reloaded! Audio mode: {}", config.audio_mode);
|
||||
let new_capacity = (config.replay_duration_sec as usize) * (config.fps as usize + 60) + 120;
|
||||
if ring.capacity().get() != new_capacity {
|
||||
ring = HeapRb::<Packet>::new(new_capacity);
|
||||
println!("Replay buffer resized to {} packets.", new_capacity);
|
||||
}
|
||||
scythe::hyprland_binds::register_hyprland_binds(&config);
|
||||
audio_capture = match scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
audio_tx_clone.clone(),
|
||||
Some(&config.audio_device),
|
||||
&config.audio_mode,
|
||||
config.mic_volume,
|
||||
config.system_volume,
|
||||
Some(Arc::clone(&audio_levels)),
|
||||
) {
|
||||
Ok((c, _, _)) => {
|
||||
println!("Audio capture reloaded for mode: {} (mic: {:.0}%, sys: {:.0}%)", config.audio_mode, config.mic_volume * 100.0, config.system_volume * 100.0);
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to reload audio capture: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
ticker = crossbeam_channel::tick(std::time::Duration::from_nanos(1_000_000_000 / config.fps.max(1) as u64));
|
||||
},
|
||||
Command::SaveReplay => {
|
||||
if config.replay_enabled {
|
||||
let drain = ring.iter().cloned().collect::<Vec<_>>();
|
||||
println!("SaveReplay triggered: {} packets in ring buffer", drain.len());
|
||||
if drain.is_empty() {
|
||||
eprintln!("Warning: Replay buffer is empty (no frames encoded yet).");
|
||||
} else {
|
||||
let _ = mux_tx.send(drain);
|
||||
}
|
||||
} else {
|
||||
println!("SaveReplay requested but replay is disabled in daemon config.");
|
||||
}
|
||||
},
|
||||
Command::StartRecording => {
|
||||
if !normal_recording {
|
||||
normal_recording = true;
|
||||
normal_waiting_keyframe = true;
|
||||
rec_state_clone.store(true, Ordering::SeqCst);
|
||||
rec_base_audio_pts = -1;
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
rec_start_clone.store(now, Ordering::SeqCst);
|
||||
println!("StartRecording requested, waiting for keyframe...");
|
||||
}
|
||||
},
|
||||
Command::StopRecording => {
|
||||
if let Some(mut m) = normal_muxer.take() {
|
||||
let _ = m.finalize();
|
||||
println!("Stopped normal recording.");
|
||||
}
|
||||
normal_recording = false;
|
||||
normal_waiting_keyframe = false;
|
||||
rec_base_video_pts = 0;
|
||||
rec_base_audio_pts = -1;
|
||||
rec_state_clone.store(false, Ordering::SeqCst);
|
||||
rec_start_clone.store(0, Ordering::SeqCst);
|
||||
},
|
||||
Command::ToggleRecording => {
|
||||
if normal_recording {
|
||||
if let Some(mut m) = normal_muxer.take() {
|
||||
let _ = m.finalize();
|
||||
println!("Stopped normal recording.");
|
||||
}
|
||||
normal_recording = false;
|
||||
normal_waiting_keyframe = false;
|
||||
rec_base_video_pts = 0;
|
||||
rec_base_audio_pts = -1;
|
||||
rec_state_clone.store(false, Ordering::SeqCst);
|
||||
rec_start_clone.store(0, Ordering::SeqCst);
|
||||
} else {
|
||||
normal_recording = true;
|
||||
normal_waiting_keyframe = true;
|
||||
rec_state_clone.store(true, Ordering::SeqCst);
|
||||
rec_base_audio_pts = -1;
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
rec_start_clone.store(now, Ordering::SeqCst);
|
||||
println!("ToggleRecording: StartRecording requested, waiting for keyframe...");
|
||||
}
|
||||
},
|
||||
Command::ToggleAudio => {
|
||||
let cur = audio_muted_clone.load(Ordering::SeqCst);
|
||||
audio_muted_clone.store(!cur, Ordering::SeqCst);
|
||||
println!("Audio mute toggled: {}", !cur);
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
},
|
||||
recv(audio_rx) -> audio_res => {
|
||||
if let Ok(mut audio_chunk) = audio_res {
|
||||
if audio_muted_clone.load(Ordering::Relaxed) {
|
||||
for sample in audio_chunk.iter_mut() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
}
|
||||
if let Some(enc) = audio_encoder.as_mut()
|
||||
&& let Ok(audio_packets) = enc.encode_pcm(&audio_chunk) {
|
||||
for mut pkt in audio_packets {
|
||||
pkt.set_stream_index(1);
|
||||
if normal_recording && !normal_waiting_keyframe
|
||||
&& let Some(muxer) = normal_muxer.as_mut() {
|
||||
let rec_base_video_time_us = unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
rec_base_video_pts,
|
||||
video_time_base,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
};
|
||||
let a_time_us = if let Some(a_tb) = audio_time_base {
|
||||
unsafe {
|
||||
ffmpeg_next::ffi::av_rescale_q(
|
||||
pkt.pts(),
|
||||
a_tb,
|
||||
ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if a_time_us >= rec_base_video_time_us {
|
||||
if rec_base_audio_pts < 0 {
|
||||
rec_base_audio_pts = pkt.pts();
|
||||
}
|
||||
let rebased = pkt.rebased(rec_base_audio_pts);
|
||||
let _ = muxer.write_packet(&rebased);
|
||||
}
|
||||
}
|
||||
if config.replay_enabled {
|
||||
ring.push_overwrite(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
recv(frame_rx) -> frame_res => {
|
||||
if let Ok(f) = frame_res {
|
||||
latest_frame = Some(f);
|
||||
has_new_frame = true;
|
||||
}
|
||||
while let Ok(f) = frame_rx.try_recv() {
|
||||
latest_frame = Some(f);
|
||||
has_new_frame = true;
|
||||
}
|
||||
},
|
||||
recv(ticker) -> _ => {
|
||||
while ticker.try_recv().is_ok() {}
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed = now.duration_since(stream_start);
|
||||
let raw_pts = (elapsed.as_secs_f64() * config.fps as f64).round() as i64;
|
||||
let pts = if raw_pts > last_video_pts { raw_pts } else { last_video_pts + 1 };
|
||||
last_video_pts = pts;
|
||||
|
||||
let packets_res = if has_new_frame {
|
||||
has_new_frame = false;
|
||||
if let Some(ref f) = latest_frame {
|
||||
encoder.encode_frame(f, pts)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
} else {
|
||||
let cached = encoder.encode_cached_frame(pts);
|
||||
match cached {
|
||||
Ok(pkts) if !pkts.is_empty() => Ok(pkts),
|
||||
_ => {
|
||||
if let Some(ref f) = latest_frame {
|
||||
encoder.encode_frame(f, pts)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match packets_res {
|
||||
Ok(packets) => {
|
||||
for mut pkt in packets {
|
||||
pkt.set_stream_index(0);
|
||||
if normal_recording {
|
||||
if normal_waiting_keyframe && pkt.is_keyframe() {
|
||||
normal_waiting_keyframe = false;
|
||||
rec_base_video_pts = pkt.pts();
|
||||
rec_base_audio_pts = -1;
|
||||
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
|
||||
let filename = scythe::config::ScytheConfig::format_video_filename("Recording", "mp4");
|
||||
let full_path = config.resolve_save_path(&filename);
|
||||
let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext);
|
||||
normal_muxer = unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx).ok() };
|
||||
println!("Started normal recording to {}", full_path);
|
||||
}
|
||||
|
||||
if !normal_waiting_keyframe
|
||||
&& let Some(muxer) = normal_muxer.as_mut() {
|
||||
let rebased = pkt.rebased(rec_base_video_pts);
|
||||
let _ = muxer.write_packet(&rebased);
|
||||
}
|
||||
}
|
||||
if config.replay_enabled {
|
||||
ring.push_overwrite(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Video encoding error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = recorder_handle.join();
|
||||
#[cfg(windows)]
|
||||
let _ = std::fs::remove_file(scythe::ipc::get_ipc_port_path());
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -143,6 +143,102 @@ pub fn soft_limit(x: f32) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResampleState {
|
||||
pub phase: f64,
|
||||
}
|
||||
|
||||
pub fn convert_to_stereo_48k(
|
||||
raw_data: &[f32],
|
||||
in_channels: u16,
|
||||
in_sample_rate: u32,
|
||||
gain: f32,
|
||||
state: &mut ResampleState,
|
||||
) -> Vec<f32> {
|
||||
let ch = (in_channels as usize).max(1);
|
||||
let num_frames = raw_data.len() / ch;
|
||||
if num_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Step 1: Up/downmix any input channel layout into stereo (2 channels)
|
||||
let mut stereo_in = Vec::with_capacity(num_frames * 2);
|
||||
match ch {
|
||||
1 => {
|
||||
for &sample in raw_data {
|
||||
let s = sample * gain;
|
||||
stereo_in.push(s);
|
||||
stereo_in.push(s);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
for pair in raw_data.chunks_exact(2) {
|
||||
stereo_in.push(pair[0] * gain);
|
||||
stereo_in.push(pair[1] * gain);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
for frame in raw_data.chunks_exact(3) {
|
||||
let center = frame[2] * std::f32::consts::FRAC_1_SQRT_2;
|
||||
stereo_in.push((frame[0] + center) * gain);
|
||||
stereo_in.push((frame[1] + center) * gain);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Surround downmix (4, 5.1, 7.1)
|
||||
for frame in raw_data.chunks_exact(ch) {
|
||||
let center = if ch > 2 { frame[2] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let l_surround = if ch > 4 { frame[4] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let r_surround = if ch > 5 { frame[5] * std::f32::consts::FRAC_1_SQRT_2 } else { 0.0 };
|
||||
let l = (frame[0] + center + l_surround) * gain;
|
||||
let r = (frame[1] + center + r_surround) * gain;
|
||||
stereo_in.push(l);
|
||||
stereo_in.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Sample rate conversion to 48,000 Hz
|
||||
if in_sample_rate == 48000 || in_sample_rate == 0 {
|
||||
for sample in stereo_in.iter_mut() {
|
||||
*sample = soft_limit(*sample);
|
||||
}
|
||||
return stereo_in;
|
||||
}
|
||||
|
||||
let in_frames = stereo_in.len() / 2;
|
||||
let ratio = in_sample_rate as f64 / 48000.0;
|
||||
let est_out = ((in_frames as f64) / ratio).ceil() as usize;
|
||||
let mut out = Vec::with_capacity(est_out * 2 + 8);
|
||||
|
||||
while state.phase < in_frames as f64 {
|
||||
let idx = state.phase.floor() as usize;
|
||||
let frac = (state.phase - idx as f64) as f32;
|
||||
|
||||
let (cur_l, cur_r) = (stereo_in[idx * 2], stereo_in[idx * 2 + 1]);
|
||||
let (next_l, next_r) = if idx + 1 < in_frames {
|
||||
(stereo_in[(idx + 1) * 2], stereo_in[(idx + 1) * 2 + 1])
|
||||
} else {
|
||||
(cur_l, cur_r)
|
||||
};
|
||||
|
||||
let l = cur_l + (next_l - cur_l) * frac;
|
||||
let r = cur_r + (next_r - cur_r) * frac;
|
||||
|
||||
out.push(soft_limit(l));
|
||||
out.push(soft_limit(r));
|
||||
|
||||
state.phase += ratio;
|
||||
}
|
||||
|
||||
state.phase -= in_frames as f64;
|
||||
if state.phase < 0.0 {
|
||||
state.phase = 0.0;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_parec_available() -> bool {
|
||||
std::process::Command::new("parec")
|
||||
|
|
@ -382,6 +478,13 @@ impl AudioCapture {
|
|||
let host = cpal::default_host();
|
||||
|
||||
let find_system_device = || -> Option<cpal::Device> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(out_dev) = host.default_output_device() {
|
||||
return Some(out_dev);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Ok(devs) = host.input_devices() {
|
||||
for d in devs {
|
||||
let n = get_device_name(&d).to_lowercase();
|
||||
|
|
@ -417,21 +520,28 @@ impl AudioCapture {
|
|||
|
||||
let levels_for_cpal = Arc::clone(&levels);
|
||||
let build_stream = move |dev: &cpal::Device, tx: Sender<Vec<f32>>, gain: f32, is_mic: bool| -> Result<(cpal::Stream, u32, u16), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = dev.default_input_config()?;
|
||||
let sample_rate = config.sample_rate();
|
||||
let channels = config.channels();
|
||||
let config = dev.default_input_config().or_else(|_| dev.default_output_config())?;
|
||||
let in_sample_rate = config.sample_rate();
|
||||
let in_channels = config.channels();
|
||||
let format = config.sample_format();
|
||||
let err_fn = |err| eprintln!("Audio stream error: {}", err);
|
||||
let stream_config: cpal::StreamConfig = config.into();
|
||||
let lvl_f32 = Arc::clone(&levels_for_cpal);
|
||||
let lvl_i16 = Arc::clone(&levels_for_cpal);
|
||||
let lvl_i32 = Arc::clone(&levels_for_cpal);
|
||||
let lvl_u16 = Arc::clone(&levels_for_cpal);
|
||||
|
||||
let mut resample_state_f32 = ResampleState::default();
|
||||
let mut resample_state_i16 = ResampleState::default();
|
||||
let mut resample_state_i32 = ResampleState::default();
|
||||
let mut resample_state_u16 = ResampleState::default();
|
||||
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::F32 => {
|
||||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[f32], _: &_| {
|
||||
let f32_data: Vec<f32> = data.iter().map(|&s| soft_limit(s * gain)).collect();
|
||||
let f32_data = convert_to_stereo_48k(data, in_channels, in_sample_rate, gain, &mut resample_state_f32);
|
||||
if is_mic {
|
||||
lvl_f32.update_mic(&f32_data);
|
||||
} else {
|
||||
|
|
@ -447,7 +557,8 @@ impl AudioCapture {
|
|||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[i16], _: &_| {
|
||||
let f32_data: Vec<f32> = data.iter().map(|&s| soft_limit((s as f32 / i16::MAX as f32) * gain)).collect();
|
||||
let converted_f32: Vec<f32> = data.iter().map(|&s| s as f32 / 32768.0).collect();
|
||||
let f32_data = convert_to_stereo_48k(&converted_f32, in_channels, in_sample_rate, gain, &mut resample_state_i16);
|
||||
if is_mic {
|
||||
lvl_i16.update_mic(&f32_data);
|
||||
} else {
|
||||
|
|
@ -459,10 +570,44 @@ impl AudioCapture {
|
|||
None,
|
||||
)?
|
||||
}
|
||||
cpal::SampleFormat::I32 => {
|
||||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[i32], _: &_| {
|
||||
let converted_f32: Vec<f32> = data.iter().map(|&s| s as f32 / 2147483648.0).collect();
|
||||
let f32_data = convert_to_stereo_48k(&converted_f32, in_channels, in_sample_rate, gain, &mut resample_state_i32);
|
||||
if is_mic {
|
||||
lvl_i32.update_mic(&f32_data);
|
||||
} else {
|
||||
lvl_i32.update_system(&f32_data);
|
||||
}
|
||||
let _ = tx.try_send(f32_data);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
dev.build_input_stream(
|
||||
stream_config,
|
||||
move |data: &[u16], _: &_| {
|
||||
let converted_f32: Vec<f32> = data.iter().map(|&s| (s as f32 - 32768.0) / 32768.0).collect();
|
||||
let f32_data = convert_to_stereo_48k(&converted_f32, in_channels, in_sample_rate, gain, &mut resample_state_u16);
|
||||
if is_mic {
|
||||
lvl_u16.update_mic(&f32_data);
|
||||
} else {
|
||||
lvl_u16.update_system(&f32_data);
|
||||
}
|
||||
let _ = tx.try_send(f32_data);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?
|
||||
}
|
||||
_ => return Err("Unsupported audio format".into()),
|
||||
};
|
||||
stream.play()?;
|
||||
Ok((stream, sample_rate, channels))
|
||||
Ok((stream, 48000, 2))
|
||||
};
|
||||
|
||||
match audio_mode {
|
||||
|
|
@ -582,6 +727,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_audio_probe() {
|
||||
// Requires a live PulseAudio/PipeWire or CoreAudio/WASAPI session. CI runners
|
||||
// have no audio server, so this is opt-in rather than a hard assertion.
|
||||
if std::env::var("SCYTHE_HW_TESTS").is_err() {
|
||||
eprintln!("skipping test_audio_probe: set SCYTHE_HW_TESTS=1 to run hardware audio tests");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Test System audio
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let sys_cap = AudioCapture::new_with_device_and_mode(tx, None, "system");
|
||||
|
|
@ -589,13 +741,12 @@ mod tests {
|
|||
let mut got_sys_samples = false;
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_millis(600) {
|
||||
if let Ok(samples) = rx.try_recv() {
|
||||
if !samples.is_empty() {
|
||||
if let Ok(samples) = rx.try_recv()
|
||||
&& !samples.is_empty() {
|
||||
got_sys_samples = true;
|
||||
println!("Received {} system audio samples", samples.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
assert!(got_sys_samples, "Did not receive any system audio samples");
|
||||
|
|
@ -608,13 +759,12 @@ mod tests {
|
|||
let mut got_mic_samples = false;
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_millis(600) {
|
||||
if let Ok(samples) = rx.try_recv() {
|
||||
if !samples.is_empty() {
|
||||
if let Ok(samples) = rx.try_recv()
|
||||
&& !samples.is_empty() {
|
||||
got_mic_samples = true;
|
||||
println!("Received {} mic audio samples", samples.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
if !got_mic_samples {
|
||||
|
|
@ -629,13 +779,12 @@ mod tests {
|
|||
let mut got_both_samples = false;
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_millis(600) {
|
||||
if let Ok(samples) = rx.try_recv() {
|
||||
if !samples.is_empty() {
|
||||
if let Ok(samples) = rx.try_recv()
|
||||
&& !samples.is_empty() {
|
||||
got_both_samples = true;
|
||||
println!("Received {} mixed audio samples", samples.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
if !got_both_samples {
|
||||
|
|
@ -643,5 +792,48 @@ mod tests {
|
|||
}
|
||||
drop(both_cap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_mono_44100_to_stereo_48000() {
|
||||
let mut state = ResampleState::default();
|
||||
// 4410 mono frames at 44.1kHz = 0.1 seconds
|
||||
let mono_input: Vec<f32> = (0..4410).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let output = convert_to_stereo_48k(&mono_input, 1, 44100, 1.0, &mut state);
|
||||
|
||||
// At 48000 Hz, 0.1s should yield ~4800 stereo frames = ~9600 floats
|
||||
let num_out_frames = output.len() / 2;
|
||||
assert!(
|
||||
(num_out_frames as i32 - 4800).abs() <= 5,
|
||||
"Expected ~4800 frames, got {}",
|
||||
num_out_frames
|
||||
);
|
||||
|
||||
// Ensure every pair has left == right (mono centered)
|
||||
for pair in output.chunks_exact(2) {
|
||||
assert!((pair[0] - pair[1]).abs() < 1e-5, "Mono upmix must be centered: {} != {}", pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_stereo_48000_passthrough() {
|
||||
let mut state = ResampleState::default();
|
||||
let stereo_input = vec![0.25f32, -0.25f32, 0.5f32, -0.5f32];
|
||||
let output = convert_to_stereo_48k(&stereo_input, 2, 48000, 1.0, &mut state);
|
||||
assert_eq!(output.len(), 4);
|
||||
assert!((output[0] - 0.25).abs() < 1e-5);
|
||||
assert!((output[1] - (-0.25)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_5_1_surround_downmix() {
|
||||
let mut state = ResampleState::default();
|
||||
// 6 channels: FL, FR, FC, LFE, SL, SR
|
||||
let surround_input = vec![0.2f32, 0.2f32, 0.4f32, 0.0f32, 0.1f32, 0.1f32];
|
||||
let output = convert_to_stereo_48k(&surround_input, 6, 48000, 1.0, &mut state);
|
||||
assert_eq!(output.len(), 2);
|
||||
// Both channels should be equal due to symmetrical inputs
|
||||
assert!((output[0] - output[1]).abs() < 1e-5);
|
||||
assert!(output[0] > 0.2, "Downmixing center/surround should contribute to stereo channel");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,32 @@
|
|||
use super::{Frame, FrameSource};
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct MockCapture {
|
||||
width: u32,
|
||||
height: u32,
|
||||
frame_count: u64,
|
||||
static_frame: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockCapture {
|
||||
pub fn new() -> Self {
|
||||
Self { width: 1920, height: 1080, frame_count: 0 }
|
||||
let width = 1920;
|
||||
let height = 1080;
|
||||
let mut data = vec![0u8; (width * height * 4) as usize];
|
||||
for chunk in data.chunks_exact_mut(4) {
|
||||
chunk[0] = 64; // B
|
||||
chunk[1] = 64; // G
|
||||
chunk[2] = 64; // R
|
||||
chunk[3] = 255; // A
|
||||
}
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
frame_count: 0,
|
||||
static_frame: Arc::new(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -22,24 +39,13 @@ impl Default for MockCapture {
|
|||
impl FrameSource for MockCapture {
|
||||
fn next_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>> {
|
||||
std::thread::sleep(Duration::from_millis(16)); // ~60fps
|
||||
let mut data = vec![0u8; (self.width * self.height * 4) as usize];
|
||||
|
||||
// Fill with a changing color
|
||||
let color = (self.frame_count % 255) as u8;
|
||||
for chunk in data.chunks_exact_mut(4) {
|
||||
chunk[0] = color; // B
|
||||
chunk[1] = color; // G
|
||||
chunk[2] = 255; // R
|
||||
chunk[3] = 255; // A
|
||||
}
|
||||
|
||||
self.frame_count += 1;
|
||||
|
||||
Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.width * 4,
|
||||
data,
|
||||
data: Arc::clone(&self.static_frame),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub mod x11;
|
|||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Frame {
|
||||
/// Zero-copy DMA-BUF file descriptor. Used by Wayland/PipeWire on Linux.
|
||||
DmaBuf {
|
||||
|
|
@ -29,7 +29,7 @@ pub enum Frame {
|
|||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
data: Vec<u8>,
|
||||
data: std::sync::Arc<Vec<u8>>,
|
||||
},
|
||||
/// Zero-copy DirectX 11 Texture handle. Used by Windows DXGI capture.
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ pub struct WaylandCapture {
|
|||
_proxy: Screencast,
|
||||
_session: ashpd::desktop::Session<Screencast>,
|
||||
stream: crate::capture::wayland_stream::PipeWireStream,
|
||||
show_cursor: bool,
|
||||
last_frame: Option<Frame>,
|
||||
last_reconnect_attempt: std::time::Instant,
|
||||
}
|
||||
|
||||
impl WaylandCapture {
|
||||
|
|
@ -59,16 +62,79 @@ impl WaylandCapture {
|
|||
|
||||
let stream = crate::capture::wayland_stream::PipeWireStream::new(node_id, fd)?;
|
||||
|
||||
Ok(Self { _proxy: proxy, _session: session, stream })
|
||||
Ok(Self {
|
||||
_proxy: proxy,
|
||||
_session: session,
|
||||
stream,
|
||||
show_cursor,
|
||||
last_frame: None,
|
||||
last_reconnect_attempt: std::time::Instant::now() - std::time::Duration::from_secs(5),
|
||||
})
|
||||
}
|
||||
|
||||
fn try_reconnect(&mut self) -> bool {
|
||||
if self.last_reconnect_attempt.elapsed() < std::time::Duration::from_millis(1500) {
|
||||
return false;
|
||||
}
|
||||
self.last_reconnect_attempt = std::time::Instant::now();
|
||||
eprintln!("Wayland PipeWire stream disconnected. Attempting auto-reconnect...");
|
||||
|
||||
let cursor = self.show_cursor;
|
||||
// Run on an independent OS thread so block_on never conflicts with Tokio runtime threads
|
||||
let res = std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?;
|
||||
rt.block_on(WaylandCapture::new_with_cursor(cursor)).ok()
|
||||
}).join();
|
||||
|
||||
if let Ok(Some(new_cap)) = res {
|
||||
eprintln!("Wayland ScreenCast session reconnected successfully.");
|
||||
self._proxy = new_cap._proxy;
|
||||
self._session = new_cap._session;
|
||||
self.stream = new_cap.stream;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameSource for WaylandCapture {
|
||||
fn next_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.stream.next_frame()
|
||||
match self.stream.next_frame() {
|
||||
Ok(f) => {
|
||||
self.last_frame = Some(f.clone());
|
||||
Ok(f)
|
||||
}
|
||||
Err(e) => {
|
||||
if self.try_reconnect() {
|
||||
return self.stream.next_frame();
|
||||
}
|
||||
if let Some(ref f) = self.last_frame {
|
||||
return Ok(f.clone());
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_frame_timeout(&mut self, timeout: std::time::Duration) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.stream.next_frame_timeout(timeout)
|
||||
match self.stream.next_frame_timeout(timeout) {
|
||||
Ok(f) => {
|
||||
self.last_frame = Some(f.clone());
|
||||
Ok(f)
|
||||
}
|
||||
Err(e) => {
|
||||
if self.try_reconnect() {
|
||||
return self.stream.next_frame_timeout(timeout);
|
||||
}
|
||||
if let Some(ref f) = self.last_frame {
|
||||
return Ok(f.clone());
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub struct PipeWireStream {
|
|||
|
||||
impl PipeWireStream {
|
||||
pub fn new(node_id: u32, fd: OwnedFd) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (tx, rx) = bounded(5);
|
||||
let (tx, rx) = bounded(2);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
pw::init();
|
||||
|
|
@ -77,10 +77,16 @@ impl PipeWireStream {
|
|||
let stride = data.chunk().stride() as u32;
|
||||
let offset = data.chunk().offset();
|
||||
let frame = if fd > 0 {
|
||||
let drm_fourcc = match user_data.format.format() {
|
||||
spa::param::video::VideoFormat::BGRx | spa::param::video::VideoFormat::BGRA => 0x34325258, // DRM_FORMAT_XRGB8888
|
||||
spa::param::video::VideoFormat::RGBx | spa::param::video::VideoFormat::RGBA => 0x34324258, // DRM_FORMAT_XBGR8888
|
||||
spa::param::video::VideoFormat::NV12 => 0x3231564e, // DRM_FORMAT_NV12
|
||||
_ => 0x34325241, // DRM_FORMAT_ARGB8888
|
||||
};
|
||||
Frame::DmaBuf {
|
||||
width: user_data.format.size().width,
|
||||
height: user_data.format.size().height,
|
||||
format: 0x34325241, // DRM_FORMAT_ARGB8888
|
||||
format: drm_fourcc,
|
||||
modifier: user_data.format.modifier(),
|
||||
fd,
|
||||
stride,
|
||||
|
|
@ -91,7 +97,7 @@ impl PipeWireStream {
|
|||
width: user_data.format.size().width,
|
||||
height: user_data.format.size().height,
|
||||
stride,
|
||||
data: slice.to_vec(),
|
||||
data: std::sync::Arc::new(slice.to_vec()),
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -9,17 +9,32 @@ use windows::{
|
|||
Win32::System::Com::*,
|
||||
};
|
||||
use crate::capture::{Frame, FrameSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_black_bgra(width: u32, height: u32) -> Arc<Vec<u8>> {
|
||||
let mut buf = vec![0u8; (width * 4 * height) as usize];
|
||||
for chunk in buf.chunks_exact_mut(4) {
|
||||
chunk[0] = 0; // B
|
||||
chunk[1] = 0; // G
|
||||
chunk[2] = 0; // R
|
||||
chunk[3] = 255; // A (opaque black)
|
||||
}
|
||||
Arc::new(buf)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub struct WindowsCapture {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
duplication: IDXGIOutputDuplication,
|
||||
duplication: Option<IDXGIOutputDuplication>,
|
||||
staging_texture: ID3D11Texture2D,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub last_stride: u32,
|
||||
last_frame: Vec<u8>,
|
||||
buffer_pool: Vec<Arc<Vec<u8>>>,
|
||||
last_frame: Option<Arc<Vec<u8>>>,
|
||||
last_reinit_attempt: std::time::Instant,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -34,65 +49,69 @@ impl WindowsCapture {
|
|||
|
||||
// Create DXGI Factory to find the display adapter with active outputs
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1()?;
|
||||
let mut chosen_adapter: Option<IDXGIAdapter1> = None;
|
||||
let mut chosen_output: Option<IDXGIOutput1> = None;
|
||||
let mut successful_session: Option<(ID3D11Device, ID3D11DeviceContext, IDXGIOutputDuplication, u32, u32)> = None;
|
||||
|
||||
let mut a_idx = 0;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(a_idx) {
|
||||
'adapter_loop: while let Ok(adapter) = factory.EnumAdapters1(a_idx) {
|
||||
if let Ok(desc1) = adapter.GetDesc1() {
|
||||
// Skip software rasterizers (e.g. Microsoft Basic Render Driver) when hardware GPU exists
|
||||
if (desc1.Flags & (DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32)) != 0 {
|
||||
a_idx += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut o_idx = 0;
|
||||
while let Ok(output) = adapter.EnumOutputs(o_idx) {
|
||||
if let Ok(output1) = output.cast::<IDXGIOutput1>() {
|
||||
chosen_adapter = Some(adapter);
|
||||
chosen_output = Some(output1);
|
||||
break;
|
||||
if let Ok(output1) = output.cast::<IDXGIOutput1>()
|
||||
&& let Ok(desc) = output1.GetDesc() {
|
||||
let is_attached = desc.AttachedToDesktop.as_bool();
|
||||
let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
|
||||
let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
|
||||
if is_attached && width > 0 && height > 0
|
||||
&& let Ok(adapter_base) = adapter.cast::<IDXGIAdapter>() {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let mut feature_level = D3D_FEATURE_LEVEL_11_0;
|
||||
|
||||
let create_res = D3D11CreateDevice(
|
||||
Some(&adapter_base),
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE(std::ptr::null_mut()),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_10_1,
|
||||
D3D_FEATURE_LEVEL_10_0,
|
||||
]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
Some(&mut feature_level),
|
||||
Some(&mut context),
|
||||
);
|
||||
|
||||
if create_res.is_ok()
|
||||
&& let (Some(dev), Some(ctx)) = (device, context) {
|
||||
match output1.DuplicateOutput(&dev) {
|
||||
Ok(dup) => {
|
||||
successful_session = Some((dev, ctx, dup, width, height));
|
||||
break 'adapter_loop;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Adapter {} Output {} DuplicateOutput failed: {:?}", a_idx, o_idx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
o_idx += 1;
|
||||
}
|
||||
if chosen_adapter.is_some() {
|
||||
break;
|
||||
}
|
||||
a_idx += 1;
|
||||
}
|
||||
|
||||
let (adapter, output1) = match (chosen_adapter, chosen_output) {
|
||||
(Some(a), Some(o)) => (a, o),
|
||||
_ => return Err("No active display output found for Windows desktop capture".into()),
|
||||
};
|
||||
let (device, context, duplication, width, height) = successful_session
|
||||
.ok_or("Failed to initialize Windows Desktop Duplication on any display output")?;
|
||||
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let mut feature_level = D3D_FEATURE_LEVEL_11_0;
|
||||
|
||||
let adapter_base = adapter.cast::<IDXGIAdapter>()?;
|
||||
|
||||
D3D11CreateDevice(
|
||||
Some(&adapter_base),
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE(std::ptr::null_mut()),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_10_1,
|
||||
D3D_FEATURE_LEVEL_10_0,
|
||||
]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
Some(&mut feature_level),
|
||||
Some(&mut context),
|
||||
)?;
|
||||
|
||||
let device = device.ok_or("Failed to create D3D11 device")?;
|
||||
let context = context.ok_or("Failed to create D3D11 context")?;
|
||||
|
||||
let desc = output1.GetDesc()?;
|
||||
let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
|
||||
let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
|
||||
|
||||
// Initialize Desktop Duplication
|
||||
let duplication = output1.DuplicateOutput(&device)?;
|
||||
|
||||
// Allocate a CPU-accessible staging texture for hardware frame reading
|
||||
let staging_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
|
|
@ -106,67 +125,45 @@ impl WindowsCapture {
|
|||
MiscFlags: 0,
|
||||
};
|
||||
|
||||
let mut staging_texture: Option<ID3D11Texture2D> = None;
|
||||
device.CreateTexture2D(&staging_desc, None, Some(&mut staging_texture))?;
|
||||
let staging_texture = staging_texture.ok_or("Failed to create D3D11 staging texture")?;
|
||||
let mut staging_texture_opt: Option<ID3D11Texture2D> = None;
|
||||
device.CreateTexture2D(&staging_desc, None, Some(&mut staging_texture_opt))?;
|
||||
let staging_texture = staging_texture_opt.ok_or("Failed to create D3D11 staging texture")?;
|
||||
|
||||
println!("Windows DXGI Hardware Desktop Duplication active: {}x{}", width, height);
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
duplication,
|
||||
duplication: Some(duplication),
|
||||
staging_texture,
|
||||
width,
|
||||
height,
|
||||
last_stride: width * 4,
|
||||
last_frame: Vec::new(),
|
||||
buffer_pool: Vec::with_capacity(8),
|
||||
last_frame: None,
|
||||
last_reinit_attempt: std::time::Instant::now() - std::time::Duration::from_secs(5),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn reinit_duplication(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
unsafe {
|
||||
let dxgi_device: IDXGIDevice = self.device.cast()?;
|
||||
let adapter = dxgi_device.GetAdapter()?;
|
||||
let mut o_idx = 0;
|
||||
while let Ok(output) = adapter.EnumOutputs(o_idx) {
|
||||
if let Ok(output1) = output.cast::<IDXGIOutput1>() {
|
||||
if let Ok(dup) = output1.DuplicateOutput(&self.device) {
|
||||
self.duplication = dup;
|
||||
if let Ok(desc) = output1.GetDesc() {
|
||||
let new_w = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
|
||||
let new_h = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
|
||||
if new_w != self.width || new_h != self.height {
|
||||
self.width = new_w;
|
||||
self.height = new_h;
|
||||
self.last_stride = new_w * 4;
|
||||
let staging_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: new_w,
|
||||
Height: new_h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut new_staging: Option<ID3D11Texture2D> = None;
|
||||
if self.device.CreateTexture2D(&staging_desc, None, Some(&mut new_staging)).is_ok()
|
||||
&& let Some(st) = new_staging {
|
||||
self.staging_texture = st;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
o_idx += 1;
|
||||
}
|
||||
Err("Failed to reinitialize desktop duplication".into())
|
||||
if self.last_reinit_attempt.elapsed() < std::time::Duration::from_millis(250) {
|
||||
return Err("Reinitialization throttled".into());
|
||||
}
|
||||
self.last_reinit_attempt = std::time::Instant::now();
|
||||
self.duplication = None;
|
||||
if let Ok(new_cap) = WindowsCapture::new() {
|
||||
self.device = new_cap.device;
|
||||
self.context = new_cap.context;
|
||||
self.duplication = new_cap.duplication;
|
||||
self.staging_texture = new_cap.staging_texture;
|
||||
self.width = new_cap.width;
|
||||
self.height = new_cap.height;
|
||||
self.last_stride = new_cap.last_stride;
|
||||
self.buffer_pool.clear();
|
||||
return Ok(());
|
||||
}
|
||||
Err("Failed to reinitialize desktop duplication".into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,105 +171,118 @@ impl WindowsCapture {
|
|||
impl FrameSource for WindowsCapture {
|
||||
fn next_frame(&mut self) -> Result<Frame, Box<dyn std::error::Error + Send + Sync>> {
|
||||
unsafe {
|
||||
if self.duplication.is_none() {
|
||||
let _ = self.reinit_duplication();
|
||||
}
|
||||
|
||||
let dup = match self.duplication.clone() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone().unwrap_or_else(|| create_black_bgra(self.width, self.height)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default();
|
||||
let mut desktop_resource: Option<IDXGIResource> = None;
|
||||
|
||||
for _ in 0..5 {
|
||||
match self.duplication.AcquireNextFrame(16, &mut frame_info, &mut desktop_resource) {
|
||||
for _ in 0..4 {
|
||||
match dup.AcquireNextFrame(8, &mut frame_info, &mut desktop_resource) {
|
||||
Ok(()) => {
|
||||
if let Some(resource) = desktop_resource {
|
||||
let texture: ID3D11Texture2D = resource.cast()?;
|
||||
|
||||
// Copy GPU desktop texture to staging texture accessible by CPU
|
||||
// Copy GPU desktop texture into CPU staging texture
|
||||
self.context.CopyResource(&self.staging_texture, &texture);
|
||||
|
||||
// Immediately release the desktop frame back to the DWM compositor
|
||||
let _ = self.duplication.ReleaseFrame();
|
||||
let _ = dup.ReleaseFrame();
|
||||
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context.Map(&self.staging_texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
|
||||
|
||||
let stride = mapped.RowPitch as usize;
|
||||
self.last_stride = mapped.RowPitch;
|
||||
let total_bytes = stride * self.height as usize;
|
||||
if self.last_frame.len() != total_bytes {
|
||||
self.last_frame.resize(total_bytes, 0);
|
||||
let target_stride = (self.width * 4) as usize;
|
||||
let total_bytes = target_stride * self.height as usize;
|
||||
|
||||
// Acquire buffer from pool or allocate if pool has room
|
||||
let mut arc_buf = None;
|
||||
for i in 0..self.buffer_pool.len() {
|
||||
if Arc::strong_count(&self.buffer_pool[i]) == 1 {
|
||||
arc_buf = Some(self.buffer_pool.swap_remove(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut arc_buf = arc_buf.unwrap_or_else(|| Arc::new(vec![0u8; total_bytes]));
|
||||
{
|
||||
let vec_ref = Arc::get_mut(&mut arc_buf).unwrap();
|
||||
if vec_ref.len() != total_bytes {
|
||||
vec_ref.resize(total_bytes, 0);
|
||||
}
|
||||
|
||||
let src_pitch = mapped.RowPitch as usize;
|
||||
if src_pitch == target_stride {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
mapped.pData as *const u8,
|
||||
vec_ref.as_mut_ptr(),
|
||||
total_bytes,
|
||||
);
|
||||
} else {
|
||||
for y in 0..self.height as usize {
|
||||
let src_row = (mapped.pData as *const u8).add(y * src_pitch);
|
||||
let dst_row = vec_ref.as_mut_ptr().add(y * target_stride);
|
||||
std::ptr::copy_nonoverlapping(src_row, dst_row, target_stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(
|
||||
mapped.pData as *const u8,
|
||||
self.last_frame.as_mut_ptr(),
|
||||
total_bytes,
|
||||
);
|
||||
self.context.Unmap(&self.staging_texture, 0);
|
||||
|
||||
let to_send = Arc::clone(&arc_buf);
|
||||
self.last_frame = Some(Arc::clone(&arc_buf));
|
||||
self.last_stride = target_stride as u32;
|
||||
|
||||
if self.buffer_pool.len() < 8 {
|
||||
self.buffer_pool.push(arc_buf);
|
||||
}
|
||||
|
||||
return Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: stride as u32,
|
||||
data: self.last_frame.clone(),
|
||||
stride: self.last_stride,
|
||||
data: to_send,
|
||||
});
|
||||
}
|
||||
let _ = self.duplication.ReleaseFrame();
|
||||
let _ = dup.ReleaseFrame();
|
||||
}
|
||||
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {
|
||||
if !self.last_frame.is_empty() {
|
||||
return Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone(),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(8));
|
||||
// Desktop static / no new frame presented
|
||||
break;
|
||||
}
|
||||
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST
|
||||
|| e.code() == DXGI_ERROR_ACCESS_DENIED
|
||||
|| e.code() == DXGI_ERROR_INVALID_CALL => {
|
||||
let _ = self.duplication.ReleaseFrame();
|
||||
drop(dup);
|
||||
self.duplication = None;
|
||||
let _ = self.reinit_duplication();
|
||||
if !self.last_frame.is_empty() {
|
||||
return Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone(),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.duplication.ReleaseFrame();
|
||||
drop(dup);
|
||||
self.duplication = None;
|
||||
let _ = self.reinit_duplication();
|
||||
if !self.last_frame.is_empty() {
|
||||
return Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone(),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.last_frame.is_empty() {
|
||||
Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone(),
|
||||
})
|
||||
} else {
|
||||
Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: vec![0u8; (self.last_stride * self.height) as usize],
|
||||
})
|
||||
}
|
||||
// Return last frame or black frame if idle
|
||||
Ok(Frame::Raw {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
stride: self.last_stride,
|
||||
data: self.last_frame.clone().unwrap_or_else(|| create_black_bgra(self.width, self.height)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ impl FrameSource for X11Capture {
|
|||
width: self.width as u32,
|
||||
height: self.height as u32,
|
||||
stride,
|
||||
data,
|
||||
data: std::sync::Arc::new(data),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ impl FrameSource for X11Capture {
|
|||
width: self.width as u32,
|
||||
height: self.height as u32,
|
||||
stride,
|
||||
data,
|
||||
data: std::sync::Arc::new(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
285
src/config.rs
|
|
@ -10,8 +10,6 @@ fn default_fps() -> u32 { 60 }
|
|||
fn default_video_codec() -> String { "h264".to_string() }
|
||||
fn default_audio_device() -> String { "default".to_string() }
|
||||
fn default_audio_mode() -> String { "system".to_string() }
|
||||
fn default_lang() -> String { "en".to_string() }
|
||||
fn default_theme() -> String { "dark".to_string() }
|
||||
fn default_save_hotkey() -> String { "Ctrl+Shift+R".to_string() }
|
||||
fn default_menu_hotkey() -> String { "Alt+Z".to_string() }
|
||||
fn default_record_hotkey() -> String { "Ctrl+Shift+F9".to_string() }
|
||||
|
|
@ -20,6 +18,20 @@ fn default_mic_volume() -> f32 { 0.60 }
|
|||
fn default_system_volume() -> f32 { 1.00 }
|
||||
fn default_accent_color() -> String { "blue".to_string() }
|
||||
|
||||
fn default_stream_service() -> String { "twitch".to_string() }
|
||||
fn default_stream_url() -> String { "rtmp://live.twitch.tv/app/".to_string() }
|
||||
fn default_screenshot_hotkey() -> String { "Ctrl+Shift+S".to_string() }
|
||||
fn default_screenshot_format() -> String { "png".to_string() }
|
||||
fn default_video_quality_mode() -> String { "cbr".to_string() }
|
||||
fn default_color_range() -> String { "limited".to_string() }
|
||||
fn default_audio_codec() -> String { "opus".to_string() }
|
||||
fn default_screenshot_quality() -> u32 { 90 }
|
||||
fn default_screenshot_preset() -> String { "balanced".to_string() }
|
||||
fn default_pause_hotkey() -> String { "Ctrl+Shift+F8".to_string() }
|
||||
fn default_mute_mic_hotkey() -> String { "Ctrl+Shift+M".to_string() }
|
||||
fn default_mute_sys_hotkey() -> String { "Ctrl+Shift+N".to_string() }
|
||||
fn default_screenshot_resolution() -> String { "native".to_string() }
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ScytheConfig {
|
||||
#[serde(default = "default_accent_color")]
|
||||
|
|
@ -31,8 +43,6 @@ pub struct ScytheConfig {
|
|||
#[serde(default = "default_replay_bitrate")]
|
||||
pub replay_bitrate_kbps: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub record_enabled: bool,
|
||||
#[serde(default = "default_record_bitrate")]
|
||||
pub record_bitrate_kbps: u32,
|
||||
#[serde(default = "default_fps")]
|
||||
|
|
@ -58,10 +68,6 @@ pub struct ScytheConfig {
|
|||
pub autostart_replay: bool,
|
||||
#[serde(default)]
|
||||
pub autostart_overlay: bool,
|
||||
#[serde(default = "default_lang")]
|
||||
pub language: String,
|
||||
#[serde(default = "default_theme")]
|
||||
pub ui_color_theme: String,
|
||||
#[serde(default = "default_save_hotkey")]
|
||||
pub save_hotkey: String,
|
||||
#[serde(default = "default_menu_hotkey")]
|
||||
|
|
@ -72,6 +78,54 @@ pub struct ScytheConfig {
|
|||
pub cursor_hotkey: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub do_not_ask_updates: bool,
|
||||
#[serde(default = "default_stream_service")]
|
||||
pub stream_service: String,
|
||||
#[serde(default = "default_stream_url")]
|
||||
pub stream_url: String,
|
||||
#[serde(default)]
|
||||
pub stream_key: String,
|
||||
|
||||
#[serde(default = "default_screenshot_hotkey")]
|
||||
pub screenshot_hotkey: String,
|
||||
#[serde(default = "ScytheConfig::default_screenshot_directory")]
|
||||
pub screenshot_directory: String,
|
||||
#[serde(default = "default_screenshot_format")]
|
||||
pub screenshot_format: String,
|
||||
#[serde(default)]
|
||||
pub screenshot_cursor: bool,
|
||||
|
||||
#[serde(default = "default_video_quality_mode")]
|
||||
pub video_quality_mode: String,
|
||||
#[serde(default = "default_color_range")]
|
||||
pub color_range: String,
|
||||
#[serde(default = "default_audio_codec")]
|
||||
pub audio_codec: String,
|
||||
#[serde(default)]
|
||||
pub record_low_power: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_replay_notifications: bool,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub restart_replay_on_save: bool,
|
||||
#[serde(default)]
|
||||
pub autostart_power_only: bool,
|
||||
|
||||
#[serde(default = "default_screenshot_quality")]
|
||||
pub screenshot_quality: u32,
|
||||
#[serde(default = "default_screenshot_preset")]
|
||||
pub screenshot_preset: String,
|
||||
#[serde(default = "default_pause_hotkey")]
|
||||
pub pause_hotkey: String,
|
||||
#[serde(default = "default_mute_mic_hotkey")]
|
||||
pub mute_mic_hotkey: String,
|
||||
#[serde(default = "default_mute_sys_hotkey")]
|
||||
pub mute_sys_hotkey: String,
|
||||
#[serde(default = "default_screenshot_resolution")]
|
||||
pub screenshot_resolution: String,
|
||||
#[serde(default)]
|
||||
pub use_monospace_font: bool,
|
||||
}
|
||||
|
||||
pub type VrecConfig = ScytheConfig;
|
||||
|
|
@ -84,7 +138,6 @@ impl Default for ScytheConfig {
|
|||
replay_duration_sec: 60,
|
||||
replay_bitrate_kbps: 18000,
|
||||
|
||||
record_enabled: false,
|
||||
record_bitrate_kbps: 20000,
|
||||
fps: 60,
|
||||
video_codec: "h264".to_string(),
|
||||
|
|
@ -98,13 +151,37 @@ impl Default for ScytheConfig {
|
|||
autostart: false,
|
||||
autostart_replay: false,
|
||||
autostart_overlay: false,
|
||||
language: "en".to_string(),
|
||||
ui_color_theme: "dark".to_string(),
|
||||
save_hotkey: "Ctrl+Shift+R".to_string(),
|
||||
menu_hotkey: "Alt+Z".to_string(),
|
||||
record_hotkey: "Ctrl+Shift+F9".to_string(),
|
||||
cursor_hotkey: "Ctrl+Shift+F10".to_string(),
|
||||
auto_check_updates: true,
|
||||
do_not_ask_updates: false,
|
||||
stream_service: "twitch".to_string(),
|
||||
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
|
||||
stream_key: String::new(),
|
||||
|
||||
screenshot_hotkey: default_screenshot_hotkey(),
|
||||
screenshot_directory: Self::default_screenshot_directory(),
|
||||
screenshot_format: default_screenshot_format(),
|
||||
screenshot_cursor: false,
|
||||
|
||||
video_quality_mode: default_video_quality_mode(),
|
||||
color_range: default_color_range(),
|
||||
audio_codec: default_audio_codec(),
|
||||
record_low_power: false,
|
||||
show_replay_notifications: true,
|
||||
|
||||
restart_replay_on_save: true,
|
||||
autostart_power_only: false,
|
||||
|
||||
screenshot_quality: default_screenshot_quality(),
|
||||
screenshot_preset: default_screenshot_preset(),
|
||||
pause_hotkey: default_pause_hotkey(),
|
||||
mute_mic_hotkey: default_mute_mic_hotkey(),
|
||||
mute_sys_hotkey: default_mute_sys_hotkey(),
|
||||
screenshot_resolution: default_screenshot_resolution(),
|
||||
use_monospace_font: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +197,16 @@ impl ScytheConfig {
|
|||
"~/Videos".to_string()
|
||||
}
|
||||
|
||||
pub fn default_screenshot_directory() -> String {
|
||||
if let Some(pic) = dirs::picture_dir() {
|
||||
return pic.join("Screenshots").to_string_lossy().to_string();
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join("Pictures").join("Screenshots").to_string_lossy().to_string();
|
||||
}
|
||||
Self::default_output_directory()
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("scythe").join("config.json")
|
||||
|
|
@ -147,6 +234,22 @@ impl ScytheConfig {
|
|||
let mut p = Self::expand_tilde(&self.output_directory);
|
||||
let _ = fs::create_dir_all(&p);
|
||||
p.push(filename);
|
||||
|
||||
if !p.exists() {
|
||||
return p.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("recording");
|
||||
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
|
||||
let parent = p.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
|
||||
for counter in 1..10000 {
|
||||
let candidate = parent.join(format!("{}_{}.{}", stem, counter, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +319,7 @@ Type=Application\n\
|
|||
Name=Scythe Replay Engine\n\
|
||||
Comment=Scythe Background Screen Recorder & Instant Replay Daemon\n\
|
||||
Icon=media-record\n\
|
||||
Exec=scythe-daemon\n\
|
||||
Exec=scythe daemon\n\
|
||||
Terminal=false\n\
|
||||
Hidden=false\n\
|
||||
X-GNOME-Autostart-enabled=true\n";
|
||||
|
|
@ -233,7 +336,7 @@ Type=Application\n\
|
|||
Name=Scythe Overlay\n\
|
||||
Comment=Scythe Screen Recorder HUD Overlay\n\
|
||||
Icon=media-record\n\
|
||||
Exec=scythe-ui --menu\n\
|
||||
Exec=scythe --menu\n\
|
||||
Terminal=false\n\
|
||||
Hidden=false\n\
|
||||
X-GNOME-Autostart-enabled=true\n";
|
||||
|
|
@ -244,19 +347,18 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
|
||||
// 3. Hyprland execs.lua integration if present
|
||||
let hypr_execs_lua = home.join(".config").join("hypr").join("hyprland").join("execs.lua");
|
||||
if hypr_execs_lua.exists() {
|
||||
if let Ok(mut text) = fs::read_to_string(&hypr_execs_lua) {
|
||||
if hypr_execs_lua.exists()
|
||||
&& let Ok(mut text) = fs::read_to_string(&hypr_execs_lua) {
|
||||
let mut modified = false;
|
||||
|
||||
let daemon_cmd_pattern = "scythe-daemon";
|
||||
let daemon_cmd_line = " hl.exec_cmd(\"scythe-daemon\")\n";
|
||||
let daemon_cmd_pattern = "scythe daemon";
|
||||
let daemon_cmd_line = " hl.exec_cmd(\"scythe daemon\")\n";
|
||||
if self.autostart_replay {
|
||||
if !text.contains(daemon_cmd_pattern) {
|
||||
if let Some(pos) = text.rfind("end)") {
|
||||
if !text.contains(daemon_cmd_pattern)
|
||||
&& let Some(pos) = text.rfind("end)") {
|
||||
text.insert_str(pos, daemon_cmd_line);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
} else if text.contains(daemon_cmd_pattern) {
|
||||
text = text.lines()
|
||||
.filter(|l| !l.contains(daemon_cmd_pattern))
|
||||
|
|
@ -265,15 +367,14 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
modified = true;
|
||||
}
|
||||
|
||||
let overlay_cmd_pattern = "scythe-ui --menu";
|
||||
let overlay_cmd_line = " hl.exec_cmd(\"scythe-ui --menu\")\n";
|
||||
let overlay_cmd_pattern = "scythe --menu";
|
||||
let overlay_cmd_line = " hl.exec_cmd(\"scythe --menu\")\n";
|
||||
if self.autostart_overlay {
|
||||
if !text.contains(overlay_cmd_pattern) {
|
||||
if let Some(pos) = text.rfind("end)") {
|
||||
if !text.contains(overlay_cmd_pattern)
|
||||
&& let Some(pos) = text.rfind("end)") {
|
||||
text.insert_str(pos, overlay_cmd_line);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
} else if text.contains(overlay_cmd_pattern) {
|
||||
text = text.lines()
|
||||
.filter(|l| !l.contains(overlay_cmd_pattern))
|
||||
|
|
@ -286,6 +387,48 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
let _ = fs::write(&hypr_execs_lua, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable session autostart for the daemon and the overlay.
|
||||
///
|
||||
/// Linux only: systemd user units and XDG autostart entries. Windows has no
|
||||
/// equivalent implementation yet, so the call is a no-op there.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
|
||||
pub fn apply_autostart_system(enable_replay: bool, enable_overlay: bool) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// 1. systemd user service enable / disable
|
||||
if enable_replay {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "enable", "scythe-daemon.service"])
|
||||
.status();
|
||||
} else {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "disable", "scythe-daemon.service"])
|
||||
.status();
|
||||
}
|
||||
|
||||
// 2. Standard XDG autostart desktop entry for session login
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
let autostart_dir = config_dir.join("autostart");
|
||||
let _ = std::fs::create_dir_all(&autostart_dir);
|
||||
let daemon_desktop = autostart_dir.join("scythe-daemon.desktop");
|
||||
let overlay_desktop = autostart_dir.join("scythe-overlay.desktop");
|
||||
|
||||
if enable_replay {
|
||||
let content = "[Desktop Entry]\nType=Application\nName=Scythe Replay Daemon\nExec=scythe daemon\nTerminal=false\nCategories=AudioVideo;Recorder;\nX-GNOME-Autostart-enabled=true\n";
|
||||
let _ = std::fs::write(&daemon_desktop, content);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&daemon_desktop);
|
||||
}
|
||||
|
||||
if enable_overlay {
|
||||
let content = "[Desktop Entry]\nType=Application\nName=Scythe Overlay\nExec=scythe --menu\nTerminal=false\nCategories=AudioVideo;Recorder;\nX-GNOME-Autostart-enabled=true\n";
|
||||
let _ = std::fs::write(&overlay_desktop, content);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&overlay_desktop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -297,6 +440,53 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
}
|
||||
|
||||
/// Formats video filenames with informative local timestamps, e.g. "Replay-18-30-00_05-09-2026.mp4"
|
||||
pub fn is_ac_power_connected() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") {
|
||||
let mut has_power_status = false;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Check AC online status
|
||||
let online_path = path.join("online");
|
||||
if let Ok(content) = std::fs::read_to_string(&online_path) {
|
||||
has_power_status = true;
|
||||
if content.trim() == "1" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check battery status
|
||||
let status_path = path.join("status");
|
||||
if let Ok(content) = std::fs::read_to_string(&status_path) {
|
||||
let s = content.trim().to_lowercase();
|
||||
if s == "charging" || s == "full" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_power_status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
unsafe {
|
||||
use windows::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS};
|
||||
let mut status = SYSTEM_POWER_STATUS::default();
|
||||
if GetSystemPowerStatus(&mut status).is_ok() {
|
||||
return status.ACLineStatus == 1;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
true
|
||||
}
|
||||
|
||||
pub fn format_video_filename(prefix: &str, ext: &str) -> String {
|
||||
unsafe {
|
||||
let t = libc::time(std::ptr::null_mut());
|
||||
|
|
@ -341,6 +531,29 @@ mod tests {
|
|||
assert!((parsed.mic_volume - 0.60).abs() < 1e-4);
|
||||
assert!((parsed.system_volume - 1.00).abs() < 1e-4);
|
||||
assert!(parsed.auto_check_updates);
|
||||
assert!(!parsed.do_not_ask_updates);
|
||||
assert_eq!(parsed.stream_service, "twitch");
|
||||
assert_eq!(parsed.stream_url, "rtmp://live.twitch.tv/app/");
|
||||
assert_eq!(parsed.stream_key, "");
|
||||
|
||||
assert!(parsed.restart_replay_on_save);
|
||||
assert!(!parsed.autostart_power_only);
|
||||
assert_eq!(parsed.video_quality_mode, "cbr");
|
||||
assert_eq!(parsed.color_range, "limited");
|
||||
assert_eq!(parsed.audio_codec, "opus");
|
||||
assert!(!parsed.record_low_power);
|
||||
assert!(parsed.show_replay_notifications);
|
||||
assert_eq!(parsed.screenshot_hotkey, "Ctrl+Shift+S");
|
||||
assert_eq!(parsed.screenshot_format, "png");
|
||||
assert!(!parsed.screenshot_cursor);
|
||||
|
||||
assert_eq!(parsed.screenshot_quality, 90);
|
||||
assert_eq!(parsed.screenshot_preset, "balanced");
|
||||
assert_eq!(parsed.pause_hotkey, "Ctrl+Shift+F8");
|
||||
assert_eq!(parsed.mute_mic_hotkey, "Ctrl+Shift+M");
|
||||
assert_eq!(parsed.mute_sys_hotkey, "Ctrl+Shift+N");
|
||||
assert_eq!(parsed.screenshot_resolution, "native");
|
||||
assert!(!parsed.use_monospace_font);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -364,4 +577,26 @@ mod tests {
|
|||
assert!(part.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_save_path_collision() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("scythe_test_collision_{}", std::process::id()));
|
||||
let cfg = ScytheConfig {
|
||||
output_directory: temp_dir.to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let path1 = cfg.resolve_save_path("test_clip.mp4");
|
||||
assert!(path1.ends_with("test_clip.mp4"));
|
||||
fs::write(&path1, b"dummy").unwrap();
|
||||
|
||||
let path2 = cfg.resolve_save_path("test_clip.mp4");
|
||||
assert!(path2.ends_with("test_clip_1.mp4"), "Expected collision to append _1: {}", path2);
|
||||
fs::write(&path2, b"dummy2").unwrap();
|
||||
|
||||
let path3 = cfg.resolve_save_path("test_clip.mp4");
|
||||
assert!(path3.ends_with("test_clip_2.mp4"), "Expected collision to append _2: {}", path3);
|
||||
|
||||
let _ = fs::remove_dir_all(temp_dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1284
src/daemon.rs
Normal file
434
src/encoder.rs
|
|
@ -1,5 +1,6 @@
|
|||
use ffmpeg_next::ffi::*;
|
||||
use std::ptr;
|
||||
#[cfg(unix)]
|
||||
use std::collections::HashMap;
|
||||
use crate::capture::Frame;
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ pub struct VaapiEncoder {
|
|||
staged_nv12: *mut AVFrame,
|
||||
hw_frame: *mut AVFrame,
|
||||
has_frame: bool,
|
||||
pub force_idr: bool,
|
||||
#[cfg(unix)]
|
||||
dma_mmap_cache: HashMap<i32, (*mut libc::c_void, usize)>,
|
||||
#[cfg(unix)]
|
||||
|
|
@ -18,6 +20,10 @@ pub struct VaapiEncoder {
|
|||
}
|
||||
|
||||
impl VaapiEncoder {
|
||||
pub fn force_keyframe(&mut self) {
|
||||
self.force_idr = true;
|
||||
}
|
||||
|
||||
pub fn new(width: u32, height: u32) -> Result<Self, String> {
|
||||
Self::new_with_params(width, height, 18_000, 60, "h264")
|
||||
}
|
||||
|
|
@ -27,7 +33,24 @@ impl VaapiEncoder {
|
|||
}
|
||||
|
||||
pub fn new_with_params(width: u32, height: u32, bitrate_kbps: u32, fps: u32, codec_pref: &str) -> Result<Self, String> {
|
||||
Self::new_with_options(width, height, bitrate_kbps, fps, codec_pref, "vbr", "limited", false)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_options(
|
||||
width: u32,
|
||||
height: u32,
|
||||
bitrate_kbps: u32,
|
||||
fps: u32,
|
||||
codec_pref: &str,
|
||||
quality_mode: &str,
|
||||
color_range: &str,
|
||||
low_power: bool,
|
||||
) -> Result<Self, String> {
|
||||
let fps = fps.clamp(20, 144) as i32;
|
||||
let is_full = color_range.to_lowercase() == "full";
|
||||
let is_cbr = quality_mode.to_lowercase() == "cbr";
|
||||
|
||||
unsafe {
|
||||
let codec_candidates: &[&std::ffi::CStr] = match codec_pref.to_lowercase().as_str() {
|
||||
"hevc" | "h265" => &[c"hevc_vaapi", c"h264_vaapi"],
|
||||
|
|
@ -61,19 +84,40 @@ impl VaapiEncoder {
|
|||
(*codec_ctx).gop_size = fps; // Emit IDR keyframe every second
|
||||
(*codec_ctx).max_b_frames = 0; // Zero latency, no frame reordering
|
||||
|
||||
// High Quality & Clean VBR Rate Control
|
||||
// High Quality & Clean VBR Rate Control matching GPU Screen Recorder standards
|
||||
let rate = (bitrate_kbps as i64) * 1000;
|
||||
(*codec_ctx).bit_rate = rate;
|
||||
(*codec_ctx).rc_max_rate = rate * 3 / 2; // Allow burst for fast motion scenes
|
||||
(*codec_ctx).rc_buffer_size = (rate / 2) as i32;
|
||||
(*codec_ctx).qmin = 16;
|
||||
(*codec_ctx).qmax = 28; // Prevent compression blockiness
|
||||
if is_cbr {
|
||||
(*codec_ctx).rc_max_rate = rate;
|
||||
(*codec_ctx).rc_buffer_size = rate as i32;
|
||||
let _ = av_opt_set((*codec_ctx).priv_data, c"rc_mode".as_ptr(), c"CBR".as_ptr(), 0);
|
||||
} else {
|
||||
(*codec_ctx).rc_max_rate = rate * 2; // Allow 2x burst for high-motion gaming scenes
|
||||
(*codec_ctx).rc_buffer_size = (rate * 2) as i32; // 2 seconds rate control window
|
||||
let _ = av_opt_set((*codec_ctx).priv_data, c"rc_mode".as_ptr(), c"VBR".as_ptr(), 0);
|
||||
}
|
||||
|
||||
(*codec_ctx).qmin = 1; // Allow high-fidelity crisp detail
|
||||
(*codec_ctx).qmax = 32;
|
||||
(*codec_ctx).profile = match codec_pref.to_lowercase().as_str() {
|
||||
"hevc" | "h265" => FF_PROFILE_HEVC_MAIN,
|
||||
"av1" => FF_PROFILE_UNKNOWN,
|
||||
_ => FF_PROFILE_H264_HIGH,
|
||||
};
|
||||
(*codec_ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
|
||||
let async_depth = if low_power { 2 } else { 4 };
|
||||
let _ = av_opt_set_int((*codec_ctx).priv_data, c"async_depth".as_ptr(), async_depth, 0);
|
||||
|
||||
if low_power {
|
||||
let _ = av_opt_set_int((*codec_ctx).priv_data, c"low_power".as_ptr(), 1, 0);
|
||||
let _ = av_opt_set_int((*codec_ctx).priv_data, c"compression_level".as_ptr(), 1, 0);
|
||||
}
|
||||
|
||||
// Rec.709 Color Space & Color Range
|
||||
(*codec_ctx).colorspace = AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*codec_ctx).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*codec_ctx).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*codec_ctx).color_range = if is_full { AVColorRange::AVCOL_RANGE_JPEG } else { AVColorRange::AVCOL_RANGE_MPEG };
|
||||
|
||||
let mut hw_device_ctx: *mut AVBufferRef = ptr::null_mut();
|
||||
let ret = av_hwdevice_ctx_create(
|
||||
|
|
@ -111,7 +155,15 @@ impl VaapiEncoder {
|
|||
|
||||
(*codec_ctx).hw_frames_ctx = av_buffer_ref(hw_frames_ref);
|
||||
|
||||
let ret = avcodec_open2(codec_ctx, codec, ptr::null_mut());
|
||||
let mut ret = avcodec_open2(codec_ctx, codec, ptr::null_mut());
|
||||
if ret < 0 && low_power {
|
||||
// If low_power mode is unsupported on this GPU driver, fallback to standard encoding entrypoint
|
||||
let _ = av_opt_set_int((*codec_ctx).priv_data, c"low_power".as_ptr(), 0, 0);
|
||||
if (*codec_ctx).hw_frames_ctx.is_null() {
|
||||
(*codec_ctx).hw_frames_ctx = av_buffer_ref(hw_frames_ref);
|
||||
}
|
||||
ret = avcodec_open2(codec_ctx, codec, ptr::null_mut());
|
||||
}
|
||||
if ret < 0 {
|
||||
av_buffer_unref(&mut (hw_frames_ref as *mut _));
|
||||
av_buffer_unref(&mut hw_device_ctx);
|
||||
|
|
@ -123,10 +175,18 @@ impl VaapiEncoder {
|
|||
(*staged_nv12).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32;
|
||||
(*staged_nv12).width = width as i32;
|
||||
(*staged_nv12).height = height as i32;
|
||||
(*staged_nv12).colorspace = AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*staged_nv12).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*staged_nv12).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*staged_nv12).color_range = AVColorRange::AVCOL_RANGE_MPEG;
|
||||
av_frame_get_buffer(staged_nv12, 32);
|
||||
|
||||
let hw_frame = av_frame_alloc();
|
||||
(*hw_frame).format = AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
|
||||
(*hw_frame).colorspace = AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*hw_frame).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*hw_frame).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*hw_frame).color_range = if is_full { AVColorRange::AVCOL_RANGE_JPEG } else { AVColorRange::AVCOL_RANGE_MPEG };
|
||||
|
||||
let sws_ctx = sws_getContext(
|
||||
width as i32,
|
||||
|
|
@ -135,12 +195,30 @@ impl VaapiEncoder {
|
|||
width as i32,
|
||||
height as i32,
|
||||
AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
2,
|
||||
// SWS_FAST_BILINEAR (1) enables AVX2/SSSE3 SIMD assembly for sub-3ms color conversion
|
||||
1,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
);
|
||||
|
||||
if !sws_ctx.is_null() {
|
||||
let coeffs = sws_getCoefficients(SWS_CS_ITU709);
|
||||
if !coeffs.is_null() {
|
||||
let dst_range = if is_full { 1 } else { 0 };
|
||||
sws_setColorspaceDetails(
|
||||
sws_ctx,
|
||||
coeffs,
|
||||
1, // srcRange: 1 (sRGB full range 0-255)
|
||||
coeffs,
|
||||
dst_range,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
codec_ctx,
|
||||
hw_device_ctx,
|
||||
|
|
@ -149,6 +227,7 @@ impl VaapiEncoder {
|
|||
staged_nv12,
|
||||
hw_frame,
|
||||
has_frame: false,
|
||||
force_idr: false,
|
||||
#[cfg(unix)]
|
||||
dma_mmap_cache: HashMap::new(),
|
||||
#[cfg(unix)]
|
||||
|
|
@ -172,6 +251,12 @@ impl VaapiEncoder {
|
|||
}
|
||||
|
||||
(*self.hw_frame).pts = pts;
|
||||
if self.force_idr {
|
||||
(*self.hw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I;
|
||||
self.force_idr = false;
|
||||
} else {
|
||||
(*self.hw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE;
|
||||
}
|
||||
|
||||
if avcodec_send_frame(self.codec_ctx, self.hw_frame) >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
|
|
@ -210,9 +295,11 @@ impl VaapiEncoder {
|
|||
self.has_frame = true;
|
||||
self.send_staged_frame(&mut packets, pts)?;
|
||||
}
|
||||
#[allow(unused_variables)]
|
||||
Frame::DmaBuf { width: dma_width, height: dma_height, format, modifier, fd, stride, offset } => {
|
||||
#[allow(unused_variables)]
|
||||
let mmap_size = (*offset as usize) + (*stride as usize) * (*dma_height as usize);
|
||||
#[allow(unused_mut)]
|
||||
let mut mapped_direct = false;
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
@ -267,6 +354,7 @@ impl VaapiEncoder {
|
|||
}
|
||||
av_packet_free(&mut pkt);
|
||||
mapped_direct = true;
|
||||
self.has_frame = true;
|
||||
}
|
||||
} else {
|
||||
self.direct_drm_unsupported = true;
|
||||
|
|
@ -291,10 +379,10 @@ impl VaapiEncoder {
|
|||
0,
|
||||
);
|
||||
if p != libc::MAP_FAILED {
|
||||
if self.dma_mmap_cache.len() >= 16 {
|
||||
for (_, (old_p, old_size)) in self.dma_mmap_cache.drain() {
|
||||
if self.dma_mmap_cache.len() >= 16
|
||||
&& let Some(old_fd) = self.dma_mmap_cache.keys().next().copied()
|
||||
&& let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) {
|
||||
libc::munmap(old_p, old_size);
|
||||
}
|
||||
}
|
||||
self.dma_mmap_cache.insert(*fd, (p, mmap_size));
|
||||
p
|
||||
|
|
@ -335,7 +423,7 @@ impl VaapiEncoder {
|
|||
pub fn encode_cached_frame(&mut self, pts: i64) -> Result<Vec<crate::ring::Packet>, String> {
|
||||
let mut packets = Vec::new();
|
||||
if !self.has_frame {
|
||||
return Ok(packets);
|
||||
return Err("No cached frame available in encoder".into());
|
||||
}
|
||||
unsafe {
|
||||
self.send_staged_frame(&mut packets, pts)?;
|
||||
|
|
@ -380,20 +468,43 @@ impl Drop for VaapiEncoder {
|
|||
#[cfg(target_os = "windows")]
|
||||
pub struct WindowsHwEncoder {
|
||||
codec_ctx: *mut AVCodecContext,
|
||||
#[allow(dead_code)]
|
||||
encoder_name: String,
|
||||
sws_ctx: *mut SwsContext,
|
||||
sw_frame: *mut AVFrame,
|
||||
pub has_frame: bool,
|
||||
pub force_idr: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl WindowsHwEncoder {
|
||||
pub fn force_keyframe(&mut self) {
|
||||
self.force_idr = true;
|
||||
}
|
||||
|
||||
pub fn new(width: u32, height: u32) -> Result<Self, String> {
|
||||
Self::new_with_params(width, height, 20_000, 60, "h264")
|
||||
}
|
||||
|
||||
pub fn new_with_params(width: u32, height: u32, bitrate_kbps: u32, fps: u32, codec_pref: &str) -> Result<Self, String> {
|
||||
Self::new_with_options(width, height, bitrate_kbps, fps, codec_pref, "vbr", "limited", false)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_options(
|
||||
width: u32,
|
||||
height: u32,
|
||||
bitrate_kbps: u32,
|
||||
fps: u32,
|
||||
codec_pref: &str,
|
||||
quality_mode: &str,
|
||||
color_range: &str,
|
||||
low_power: bool,
|
||||
) -> Result<Self, String> {
|
||||
let fps = fps.clamp(20, 144) as i32;
|
||||
let is_full = color_range.to_lowercase() == "full";
|
||||
let is_cbr = quality_mode.to_lowercase() == "cbr";
|
||||
|
||||
unsafe {
|
||||
let candidates: &[(&std::ffi::CStr, &str)] = match codec_pref.to_lowercase().as_str() {
|
||||
"hevc" | "h265" => &[
|
||||
|
|
@ -429,60 +540,89 @@ impl WindowsHwEncoder {
|
|||
continue;
|
||||
}
|
||||
|
||||
let ctx = avcodec_alloc_context3(c);
|
||||
if ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
|
||||
(*ctx).width = width as i32;
|
||||
(*ctx).height = height as i32;
|
||||
(*ctx).time_base = AVRational { num: 1, den: fps };
|
||||
(*ctx).framerate = AVRational { num: fps, den: 1 };
|
||||
(*ctx).gop_size = fps;
|
||||
(*ctx).max_b_frames = 0;
|
||||
|
||||
let rate = (bitrate_kbps as i64) * 1000;
|
||||
(*ctx).bit_rate = rate;
|
||||
(*ctx).rc_max_rate = rate * 3 / 2;
|
||||
(*ctx).rc_buffer_size = (rate / 2) as i32;
|
||||
(*ctx).qmin = 16;
|
||||
(*ctx).qmax = 28;
|
||||
let codec_id = (*c).id;
|
||||
(*ctx).profile = if codec_id == AVCodecID::AV_CODEC_ID_HEVC {
|
||||
FF_PROFILE_HEVC_MAIN
|
||||
} else if codec_id == AVCodecID::AV_CODEC_ID_AV1 {
|
||||
FF_PROFILE_UNKNOWN
|
||||
let formats_to_try: &[AVPixelFormat] = if desc.contains("NVENC") || desc.contains("AMF") || desc.contains("QuickSync") {
|
||||
&[AVPixelFormat::AV_PIX_FMT_NV12, AVPixelFormat::AV_PIX_FMT_YUV420P]
|
||||
} else {
|
||||
FF_PROFILE_H264_HIGH
|
||||
&[AVPixelFormat::AV_PIX_FMT_YUV420P, AVPixelFormat::AV_PIX_FMT_NV12]
|
||||
};
|
||||
(*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
|
||||
|
||||
if desc.contains("NVENC") {
|
||||
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"p1".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"forced-idr".as_ptr(), c"1".as_ptr(), 0);
|
||||
} else if desc.contains("AMF") {
|
||||
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
let _ = av_opt_set((*ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
|
||||
} else if desc.contains("QuickSync") {
|
||||
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
|
||||
} else {
|
||||
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P;
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
|
||||
for &pix_fmt in formats_to_try {
|
||||
let ctx = avcodec_alloc_context3(c);
|
||||
if ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
|
||||
(*ctx).width = width as i32;
|
||||
(*ctx).height = height as i32;
|
||||
(*ctx).time_base = AVRational { num: 1, den: fps };
|
||||
(*ctx).framerate = AVRational { num: fps, den: 1 };
|
||||
(*ctx).gop_size = fps;
|
||||
(*ctx).max_b_frames = 0;
|
||||
|
||||
let rate = (bitrate_kbps as i64) * 1000;
|
||||
(*ctx).bit_rate = rate;
|
||||
if is_cbr {
|
||||
(*ctx).rc_max_rate = rate;
|
||||
(*ctx).rc_buffer_size = rate as i32;
|
||||
} else {
|
||||
(*ctx).rc_max_rate = rate * 2;
|
||||
(*ctx).rc_buffer_size = (rate * 2) as i32;
|
||||
}
|
||||
(*ctx).qmin = 1;
|
||||
(*ctx).qmax = 32;
|
||||
let codec_id = (*c).id;
|
||||
(*ctx).profile = if codec_id == AVCodecID::AV_CODEC_ID_HEVC {
|
||||
FF_PROFILE_HEVC_MAIN
|
||||
} else if codec_id == AVCodecID::AV_CODEC_ID_AV1 {
|
||||
FF_PROFILE_UNKNOWN
|
||||
} else {
|
||||
FF_PROFILE_H264_HIGH
|
||||
};
|
||||
(*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
|
||||
(*ctx).pix_fmt = pix_fmt;
|
||||
|
||||
// ITU-R BT.709 Color Space & Color Range standard
|
||||
(*ctx).colorspace = AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*ctx).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*ctx).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*ctx).color_range = if is_full { AVColorRange::AVCOL_RANGE_JPEG } else { AVColorRange::AVCOL_RANGE_MPEG };
|
||||
|
||||
if desc.contains("NVENC") {
|
||||
let preset = if low_power { c"p1".as_ptr() } else { c"p3".as_ptr() };
|
||||
let rc = if is_cbr { c"cbr".as_ptr() } else { c"vbr".as_ptr() };
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), preset, 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"forced-idr".as_ptr(), c"1".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"rc".as_ptr(), rc, 0);
|
||||
} else if desc.contains("AMF") {
|
||||
let rc = if is_cbr { c"cbr".as_ptr() } else { c"vbr_latency".as_ptr() };
|
||||
let _ = av_opt_set((*ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"rc".as_ptr(), rc, 0);
|
||||
if low_power {
|
||||
let _ = av_opt_set((*ctx).priv_data, c"quality".as_ptr(), c"speed".as_ptr(), 0);
|
||||
}
|
||||
} else if desc.contains("QuickSync") {
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
|
||||
} else {
|
||||
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
|
||||
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
|
||||
(*ctx).thread_count = 0;
|
||||
}
|
||||
|
||||
let ret = avcodec_open2(ctx, c, ptr::null_mut());
|
||||
if ret >= 0 {
|
||||
println!("Successfully initialized Windows video encoder: {} ({:?})", desc, pix_fmt);
|
||||
opened_codec_ctx = ctx;
|
||||
selected_desc = desc.to_string();
|
||||
break;
|
||||
} else {
|
||||
let mut tmp = ctx;
|
||||
avcodec_free_context(&mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
let ret = avcodec_open2(ctx, c, ptr::null_mut());
|
||||
if ret >= 0 {
|
||||
println!("Successfully initialized Windows video encoder: {}", desc);
|
||||
opened_codec_ctx = ctx;
|
||||
selected_desc = desc.to_string();
|
||||
if !opened_codec_ctx.is_null() {
|
||||
break;
|
||||
} else {
|
||||
let mut tmp = ctx;
|
||||
avcodec_free_context(&mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -499,6 +639,10 @@ impl WindowsHwEncoder {
|
|||
(*sw_frame).format = (*codec_ctx).pix_fmt as i32;
|
||||
(*sw_frame).width = width as i32;
|
||||
(*sw_frame).height = height as i32;
|
||||
(*sw_frame).colorspace = AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*sw_frame).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*sw_frame).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
(*sw_frame).color_range = if is_full { AVColorRange::AVCOL_RANGE_JPEG } else { AVColorRange::AVCOL_RANGE_MPEG };
|
||||
if av_frame_get_buffer(sw_frame, 32) < 0 {
|
||||
av_frame_free(&mut sw_frame);
|
||||
avcodec_free_context(&mut opened_codec_ctx);
|
||||
|
|
@ -512,18 +656,40 @@ impl WindowsHwEncoder {
|
|||
width as i32,
|
||||
height as i32,
|
||||
(*codec_ctx).pix_fmt,
|
||||
2,
|
||||
1, // SWS_FAST_BILINEAR for AVX2/SSSE3 SIMD acceleration
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
);
|
||||
|
||||
if sws_ctx.is_null() {
|
||||
av_frame_free(&mut sw_frame);
|
||||
avcodec_free_context(&mut opened_codec_ctx);
|
||||
return Err("Failed to initialize color space conversion context".into());
|
||||
}
|
||||
|
||||
let coeffs = sws_getCoefficients(SWS_CS_ITU709);
|
||||
if !coeffs.is_null() {
|
||||
let dst_range = if is_full { 1 } else { 0 };
|
||||
sws_setColorspaceDetails(
|
||||
sws_ctx,
|
||||
coeffs,
|
||||
1, // srcRange: 1 (sRGB full range 0-255)
|
||||
coeffs,
|
||||
dst_range,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
codec_ctx,
|
||||
encoder_name: selected_desc,
|
||||
sws_ctx,
|
||||
sw_frame,
|
||||
has_frame: false,
|
||||
force_idr: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -535,15 +701,14 @@ impl WindowsHwEncoder {
|
|||
pub fn encode_frame(&mut self, frame: &Frame, pts: i64) -> Result<Vec<crate::ring::Packet>, String> {
|
||||
let mut packets = Vec::new();
|
||||
unsafe {
|
||||
match frame {
|
||||
Frame::Raw { width: _, height, stride, data } => {
|
||||
if let Frame::Raw { width: _, height, stride, data } = frame {
|
||||
if av_frame_make_writable(self.sw_frame) < 0 {
|
||||
let _ = av_frame_get_buffer(self.sw_frame, 32);
|
||||
}
|
||||
|
||||
if !self.sws_ctx.is_null() {
|
||||
let src_data = [data.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_linesize = [*stride as i32, 0, 0, 0];
|
||||
|
||||
if av_frame_make_writable(self.sw_frame) < 0 {
|
||||
let _ = av_frame_get_buffer(self.sw_frame, 32);
|
||||
}
|
||||
|
||||
sws_scale(
|
||||
self.sws_ctx,
|
||||
src_data.as_ptr(),
|
||||
|
|
@ -553,37 +718,26 @@ impl WindowsHwEncoder {
|
|||
(*self.sw_frame).data.as_mut_ptr(),
|
||||
(*self.sw_frame).linesize.as_mut_ptr(),
|
||||
);
|
||||
|
||||
(*self.sw_frame).pts = pts;
|
||||
|
||||
if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
while avcodec_receive_packet(self.codec_ctx, pkt) >= 0 {
|
||||
let new_pkt = av_packet_alloc();
|
||||
av_packet_move_ref(new_pkt, pkt);
|
||||
packets.push(crate::ring::Packet::new(new_pkt));
|
||||
}
|
||||
av_packet_free(&mut pkt);
|
||||
self.has_frame = true;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
Frame::D3D11Texture { handle: _, .. } => {
|
||||
// Zero-copy D3D11 frame submission
|
||||
(*self.sw_frame).pts = pts;
|
||||
|
||||
if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
while avcodec_receive_packet(self.codec_ctx, pkt) >= 0 {
|
||||
let new_pkt = av_packet_alloc();
|
||||
av_packet_move_ref(new_pkt, pkt);
|
||||
packets.push(crate::ring::Packet::new(new_pkt));
|
||||
}
|
||||
av_packet_free(&mut pkt);
|
||||
self.has_frame = true;
|
||||
}
|
||||
(*self.sw_frame).pts = pts;
|
||||
if self.force_idr {
|
||||
(*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I;
|
||||
self.force_idr = false;
|
||||
} else {
|
||||
(*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE;
|
||||
}
|
||||
|
||||
if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
while avcodec_receive_packet(self.codec_ctx, pkt) >= 0 {
|
||||
let new_pkt = av_packet_alloc();
|
||||
av_packet_move_ref(new_pkt, pkt);
|
||||
packets.push(crate::ring::Packet::new(new_pkt));
|
||||
}
|
||||
av_packet_free(&mut pkt);
|
||||
self.has_frame = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(packets)
|
||||
|
|
@ -593,13 +747,19 @@ impl WindowsHwEncoder {
|
|||
pub fn encode_cached_frame(&mut self, pts: i64) -> Result<Vec<crate::ring::Packet>, String> {
|
||||
let mut packets = Vec::new();
|
||||
if !self.has_frame {
|
||||
return Ok(packets);
|
||||
return Err("No cached frame available in encoder".into());
|
||||
}
|
||||
unsafe {
|
||||
if self.sw_frame.is_null() {
|
||||
return Ok(packets);
|
||||
}
|
||||
(*self.sw_frame).pts = pts;
|
||||
if self.force_idr {
|
||||
(*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I;
|
||||
self.force_idr = false;
|
||||
} else {
|
||||
(*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE;
|
||||
}
|
||||
|
||||
if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 {
|
||||
let mut pkt = av_packet_alloc();
|
||||
|
|
@ -651,57 +811,82 @@ pub struct AudioEncoder {
|
|||
|
||||
impl AudioEncoder {
|
||||
pub fn new(sample_rate: i32, channels: i32) -> Result<Self, String> {
|
||||
Self::new_with_codec(sample_rate, channels, "aac")
|
||||
}
|
||||
|
||||
pub fn new_with_codec(sample_rate: i32, channels: i32, codec_pref: &str) -> Result<Self, String> {
|
||||
unsafe {
|
||||
let codec = avcodec_find_encoder(AVCodecID::AV_CODEC_ID_AAC);
|
||||
let wants_opus = codec_pref.to_lowercase() == "opus";
|
||||
let mut codec = if wants_opus {
|
||||
avcodec_find_encoder(AVCodecID::AV_CODEC_ID_OPUS)
|
||||
} else {
|
||||
avcodec_find_encoder(AVCodecID::AV_CODEC_ID_AAC)
|
||||
};
|
||||
|
||||
if codec.is_null() && wants_opus {
|
||||
codec = avcodec_find_encoder_by_name(c"libopus".as_ptr());
|
||||
if codec.is_null() {
|
||||
codec = avcodec_find_encoder(AVCodecID::AV_CODEC_ID_AAC);
|
||||
}
|
||||
} else if codec.is_null() {
|
||||
codec = avcodec_find_encoder(AVCodecID::AV_CODEC_ID_AAC);
|
||||
}
|
||||
|
||||
if codec.is_null() {
|
||||
return Err("AAC encoder not found".into());
|
||||
return Err("No compatible audio encoder found (AAC/Opus)".into());
|
||||
}
|
||||
|
||||
let codec_ctx = avcodec_alloc_context3(codec);
|
||||
if codec_ctx.is_null() {
|
||||
return Err("Failed to allocate AAC codec context".into());
|
||||
return Err("Failed to allocate audio codec context".into());
|
||||
}
|
||||
|
||||
let sample_fmt = if (*codec).id == AVCodecID::AV_CODEC_ID_OPUS {
|
||||
AVSampleFormat::AV_SAMPLE_FMT_FLT
|
||||
} else {
|
||||
AVSampleFormat::AV_SAMPLE_FMT_FLTP
|
||||
};
|
||||
|
||||
(*codec_ctx).sample_rate = sample_rate;
|
||||
(*codec_ctx).time_base = AVRational { num: 1, den: sample_rate };
|
||||
(*codec_ctx).sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_FLTP;
|
||||
(*codec_ctx).bit_rate = 192_000;
|
||||
(*codec_ctx).sample_fmt = sample_fmt;
|
||||
(*codec_ctx).bit_rate = if (*codec).id == AVCodecID::AV_CODEC_ID_OPUS { 160_000 } else { 192_000 };
|
||||
(*codec_ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
|
||||
av_channel_layout_default(&mut (*codec_ctx).ch_layout, channels);
|
||||
|
||||
let ret = avcodec_open2(codec_ctx, codec, ptr::null_mut());
|
||||
if ret < 0 {
|
||||
avcodec_free_context(&mut (codec_ctx as *mut _));
|
||||
return Err("Failed to open AAC encoder".into());
|
||||
return Err("Failed to open audio encoder".into());
|
||||
}
|
||||
|
||||
let mut swr_ctx: *mut SwrContext = ptr::null_mut();
|
||||
swr_alloc_set_opts2(
|
||||
&mut swr_ctx,
|
||||
&(*codec_ctx).ch_layout,
|
||||
AVSampleFormat::AV_SAMPLE_FMT_FLTP,
|
||||
sample_fmt,
|
||||
sample_rate,
|
||||
&(*codec_ctx).ch_layout,
|
||||
AVSampleFormat::AV_SAMPLE_FMT_FLT,
|
||||
sample_rate,
|
||||
0,
|
||||
ptr::null_mut()
|
||||
ptr::null_mut(),
|
||||
);
|
||||
swr_init(swr_ctx);
|
||||
|
||||
let frame_size = (*codec_ctx).frame_size;
|
||||
let fifo = av_audio_fifo_alloc(AVSampleFormat::AV_SAMPLE_FMT_FLTP, channels, 1024 * 32);
|
||||
let frame_size = if (*codec_ctx).frame_size > 0 { (*codec_ctx).frame_size } else { 960 };
|
||||
let fifo = av_audio_fifo_alloc(sample_fmt, channels, 1024 * 32);
|
||||
|
||||
let enc_frame = av_frame_alloc();
|
||||
(*enc_frame).nb_samples = frame_size;
|
||||
(*enc_frame).format = AVSampleFormat::AV_SAMPLE_FMT_FLTP as i32;
|
||||
(*enc_frame).format = sample_fmt as i32;
|
||||
(*enc_frame).ch_layout = (*codec_ctx).ch_layout;
|
||||
(*enc_frame).sample_rate = sample_rate;
|
||||
av_frame_get_buffer(enc_frame, 0);
|
||||
|
||||
let staging_out_frame = av_frame_alloc();
|
||||
(*staging_out_frame).nb_samples = 8192;
|
||||
(*staging_out_frame).format = AVSampleFormat::AV_SAMPLE_FMT_FLTP as i32;
|
||||
(*staging_out_frame).format = sample_fmt as i32;
|
||||
(*staging_out_frame).ch_layout = (*codec_ctx).ch_layout;
|
||||
(*staging_out_frame).sample_rate = sample_rate;
|
||||
av_frame_get_buffer(staging_out_frame, 0);
|
||||
|
|
@ -819,6 +1004,12 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opus_audio_encoder_initialization() {
|
||||
let enc_res = AudioEncoder::new_with_codec(48000, 2, "opus");
|
||||
assert!(enc_res.is_ok(), "Opus encoder initialization should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_encode_sine_wave() {
|
||||
let mut enc = AudioEncoder::new(48000, 2).expect("AudioEncoder creation");
|
||||
|
|
@ -850,7 +1041,7 @@ mod tests {
|
|||
width: 1280,
|
||||
height: 720,
|
||||
stride: 1280 * 4,
|
||||
data: vec![0u8; 1280 * 720 * 4],
|
||||
data: std::sync::Arc::new(vec![0u8; 1280 * 720 * 4]),
|
||||
};
|
||||
let pkts = enc.encode_frame(&blank_frame, 0).expect("Encode frame");
|
||||
println!("Encoded {} packets from first frame", pkts.len());
|
||||
|
|
@ -859,4 +1050,29 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vaapi_encoder_60fps_throughput() {
|
||||
if let Ok(mut enc) = VaapiEncoder::new_with_params(1920, 1080, 20_000, 60, "h264") {
|
||||
let blank_frame = Frame::Raw {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
stride: 1920 * 4,
|
||||
data: std::sync::Arc::new(vec![128u8; 1920 * 1080 * 4]),
|
||||
};
|
||||
// Warm up
|
||||
let _ = enc.encode_frame(&blank_frame, 0);
|
||||
|
||||
let iterations = 30;
|
||||
let start = std::time::Instant::now();
|
||||
for i in 1..=iterations {
|
||||
let _ = enc.encode_frame(&blank_frame, i);
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_ms = elapsed.as_secs_f64() * 1000.0 / (iterations as f64);
|
||||
println!("1080p60 VAAPI frame encode avg time: {:.2} ms per frame", avg_ms);
|
||||
// Frame::Raw incurs software swscale + PCIe GPU upload; assert healthy hardware throughput under 35ms
|
||||
assert!(avg_ms < 35.0, "Encoding time ({:.2}ms) must be under 35ms for software upload fallback", avg_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,5 +95,22 @@ mod tests {
|
|||
assert!(parse_hotkey("").is_none());
|
||||
assert!(parse_hotkey("invalid_key_xyz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_eight_default_scythe_hotkeys() {
|
||||
let defaults = [
|
||||
("Alt+Z", "menu"),
|
||||
("Ctrl+Shift+R", "save_replay"),
|
||||
("Ctrl+Shift+F9", "record_toggle"),
|
||||
("Ctrl+Shift+F8", "pause_record"),
|
||||
("Ctrl+Shift+S", "screenshot"),
|
||||
("Ctrl+Shift+F10", "toggle_cursor"),
|
||||
("Ctrl+Shift+M", "mute_mic"),
|
||||
("Ctrl+Shift+N", "mute_sys"),
|
||||
];
|
||||
for (hk_str, label) in defaults {
|
||||
assert!(parse_hotkey(hk_str).is_some(), "Hotkey '{}' for '{}' must parse successfully", hk_str, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,10 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
|||
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-notification" }, blur = true, ignore_alpha = 0.1 })"#])
|
||||
.output();
|
||||
let _ = Command::new("hyprctl")
|
||||
.args(["eval", r#"hl.window_rule({ match = { class = "scythe-overlay" }, float = true, pin = true, move = "0 0", size = "100% 100%" })"#])
|
||||
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
||||
.output();
|
||||
let _ = Command::new("hyprctl")
|
||||
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
|
||||
.output();
|
||||
let _ = Command::new("hyprctl")
|
||||
.args(["eval", r#"hl.window_rule({ match = { title = "Select Recordings Directory" }, float = true, pin = true, stay_focused = true, center = true })"#])
|
||||
|
|
@ -139,6 +142,9 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
|||
"size 100% 100%, class:^(scythe-overlay)$",
|
||||
"pin, class:^(scythe-overlay)$",
|
||||
"noborder, class:^(scythe-overlay)$",
|
||||
"noshadow, class:^(scythe-overlay)$",
|
||||
"rounding 0, class:^(scythe-overlay)$",
|
||||
"stayfocused, class:^(scythe-overlay)$",
|
||||
"float, title:^(Select Recordings Directory)$",
|
||||
"pin, title:^(Select Recordings Directory)$",
|
||||
"stayfocused, title:^(Select Recordings Directory)$",
|
||||
|
|
@ -158,6 +164,7 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
|||
"float, class:^(scythe-toast)$",
|
||||
"pin, class:^(scythe-toast)$",
|
||||
"noborder, class:^(scythe-toast)$",
|
||||
"move 100%-w 16, class:^(scythe-toast)$",
|
||||
"float, class:^(vrec-overlay)$",
|
||||
"move 0 0, class:^(vrec-overlay)$",
|
||||
"size 100% 100%, class:^(vrec-overlay)$",
|
||||
|
|
@ -175,7 +182,11 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
|
|||
(&config.menu_hotkey, "scythe-ui --menu"),
|
||||
(&config.save_hotkey, "scythe-ui --save"),
|
||||
(&config.record_hotkey, "scythe-ui --record"),
|
||||
(&config.pause_hotkey, "scythe-ui --pause"),
|
||||
(&config.cursor_hotkey, "scythe-ui --cursor"),
|
||||
(&config.screenshot_hotkey, "scythe-ui --screenshot"),
|
||||
(&config.mute_mic_hotkey, "scythe-ui --mute-mic"),
|
||||
(&config.mute_sys_hotkey, "scythe-ui --mute-sys"),
|
||||
];
|
||||
|
||||
for (hotkey, cmd) in binds {
|
||||
|
|
|
|||
103
src/ipc.rs
|
|
@ -1,15 +1,44 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToastIcon {
|
||||
Info,
|
||||
Record,
|
||||
Save,
|
||||
Replay,
|
||||
Cursor,
|
||||
Screenshot,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToastIcon {
|
||||
pub fn from_name(name: &str) -> Self {
|
||||
match name.to_lowercase().as_str() {
|
||||
"replay" | "save_replay" => ToastIcon::Replay,
|
||||
"record" | "recording" | "start" => ToastIcon::Record,
|
||||
"save" | "saved" | "stop" => ToastIcon::Save,
|
||||
"cursor" => ToastIcon::Cursor,
|
||||
"screenshot" => ToastIcon::Screenshot,
|
||||
"error" => ToastIcon::Error,
|
||||
_ => ToastIcon::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
SaveReplay,
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
ToggleRecording,
|
||||
StartStreaming,
|
||||
StopStreaming,
|
||||
ToggleStreaming,
|
||||
ToggleAudio,
|
||||
CycleAudioMode,
|
||||
ToggleCursor,
|
||||
ReloadConfig,
|
||||
PowerStateChanged(bool),
|
||||
StopDaemon,
|
||||
ShowOverlay,
|
||||
GetStatus,
|
||||
|
|
@ -33,6 +62,10 @@ pub struct DaemonStatus {
|
|||
pub mic_level_peak: f32,
|
||||
#[serde(default)]
|
||||
pub system_level_peak: f32,
|
||||
#[serde(default)]
|
||||
pub is_streaming: bool,
|
||||
#[serde(default)]
|
||||
pub streaming_duration_sec: u64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
|
@ -72,20 +105,30 @@ pub fn get_ipc_port_path() -> std::path::PathBuf {
|
|||
|
||||
#[cfg(windows)]
|
||||
pub fn read_active_ipc_port() -> u16 {
|
||||
if let Ok(content) = std::fs::read_to_string(get_ipc_port_path()) {
|
||||
if let Ok(port) = content.trim().parse::<u16>() {
|
||||
if let Ok(content) = std::fs::read_to_string(get_ipc_port_path())
|
||||
&& let Ok(port) = content.trim().parse::<u16>() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
let legacy = std::env::temp_dir().join("vrec-ipc.port");
|
||||
if let Ok(content) = std::fs::read_to_string(legacy) {
|
||||
if let Ok(port) = content.trim().parse::<u16>() {
|
||||
if let Ok(content) = std::fs::read_to_string(legacy)
|
||||
&& let Ok(port) = content.trim().parse::<u16>() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
42069
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_candidate_ipc_ports() -> Vec<u16> {
|
||||
let active_port = read_active_ipc_port();
|
||||
let mut ports = vec![active_port];
|
||||
for p in [42069, 42070, 42071, 42072] {
|
||||
if !ports.contains(&p) {
|
||||
ports.push(p);
|
||||
}
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
pub fn is_daemon_running() -> bool {
|
||||
query_status().is_ok()
|
||||
}
|
||||
|
|
@ -126,17 +169,15 @@ pub fn send_command(cmd: Command) -> Result<(), Box<dyn std::error::Error + Send
|
|||
#[cfg(windows)]
|
||||
{
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
let active_port = read_active_ipc_port();
|
||||
let candidate_ports = [active_port, 42069, 42070, 42071, 42072];
|
||||
let candidate_ports = get_candidate_ipc_ports();
|
||||
for &port in &candidate_ports {
|
||||
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>() {
|
||||
if let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
|
||||
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>()
|
||||
&& let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
|
||||
if stream.write_all(&len_buf).is_ok() && stream.write_all(&payload).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
last_err = Some("Could not connect to daemon on any TCP IPC port".into());
|
||||
|
|
@ -196,15 +237,13 @@ pub fn query_status() -> Result<DaemonStatus, Box<dyn std::error::Error + Send +
|
|||
#[cfg(windows)]
|
||||
let stream_res = {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
let active_port = read_active_ipc_port();
|
||||
let candidate_ports = [active_port, 42069, 42070, 42071, 42072];
|
||||
let candidate_ports = get_candidate_ipc_ports();
|
||||
let mut conn = None;
|
||||
for &port in &candidate_ports {
|
||||
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>() {
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
|
||||
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>()
|
||||
&& let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
|
||||
conn = Some(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "No active daemon port reachable"))
|
||||
|
|
@ -274,3 +313,35 @@ pub fn get_toast_pid_path() -> std::path::PathBuf {
|
|||
pub fn clean_toast_pid() {
|
||||
let _ = std::fs::remove_file(get_toast_pid_path());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ipc_command_and_status_serde() {
|
||||
let cmd = Command::ToggleStreaming;
|
||||
let json = serde_json::to_string(&cmd).unwrap();
|
||||
let parsed: Command = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, Command::ToggleStreaming);
|
||||
|
||||
let status = DaemonStatus {
|
||||
is_recording: true,
|
||||
recording_duration_sec: 42,
|
||||
is_replay_active: true,
|
||||
audio_muted: false,
|
||||
audio_mode: "system".to_string(),
|
||||
show_cursor: true,
|
||||
mic_volume: 0.6,
|
||||
system_volume: 1.0,
|
||||
mic_level_peak: 0.2,
|
||||
system_level_peak: 0.8,
|
||||
is_streaming: true,
|
||||
streaming_duration_sec: 120,
|
||||
};
|
||||
let status_json = serde_json::to_string(&status).unwrap();
|
||||
let parsed_status: DaemonStatus = serde_json::from_str(&status_json).unwrap();
|
||||
assert!(parsed_status.is_streaming);
|
||||
assert_eq!(parsed_status.streaming_duration_sec, 120);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(clippy::chunks_exact_to_as_chunks)]
|
||||
|
||||
pub mod capture;
|
||||
pub mod encoder;
|
||||
pub mod ring;
|
||||
|
|
@ -6,7 +8,7 @@ pub mod hotkey;
|
|||
pub mod ipc;
|
||||
pub mod overlay;
|
||||
pub mod config;
|
||||
pub mod settings_ui;
|
||||
pub mod overlay_egui;
|
||||
pub mod hyprland_binds;
|
||||
pub mod updater;
|
||||
pub mod daemon;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
#![allow(clippy::chunks_exact_to_as_chunks)]
|
||||
|
||||
use scythe::ipc::{Command, send_command, query_status};
|
||||
use scythe::overlay::{show_shadowplay_toast, ToastIcon};
|
||||
use std::env;
|
||||
use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent};
|
||||
use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent, HotKeyState};
|
||||
|
||||
fn ensure_wayland_env() {
|
||||
scythe::overlay::ensure_wayland_env();
|
||||
|
|
@ -11,44 +12,14 @@ fn ensure_wayland_env() {
|
|||
|
||||
fn get_daemon_cmd() -> std::process::Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = if let Ok(mut path) = std::env::current_exe() {
|
||||
path.pop();
|
||||
#[cfg(target_os = "windows")]
|
||||
let primary = path.join("scythe-daemon.exe");
|
||||
#[cfg(target_os = "windows")]
|
||||
let fallback = path.join("vrec-daemon.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let primary = path.join("scythe-daemon");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let fallback = path.join("vrec-daemon");
|
||||
|
||||
if primary.exists() {
|
||||
let mut c = std::process::Command::new(primary);
|
||||
c.current_dir(&path);
|
||||
c
|
||||
} else if fallback.exists() {
|
||||
let mut c = std::process::Command::new(fallback);
|
||||
c.current_dir(&path);
|
||||
c
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon")
|
||||
}
|
||||
}
|
||||
let mut cmd = if let Ok(self_exe) = std::env::current_exe() {
|
||||
let mut c = std::process::Command::new(self_exe);
|
||||
c.arg("daemon");
|
||||
c
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon")
|
||||
}
|
||||
let mut c = std::process::Command::new("scythe");
|
||||
c.arg("daemon");
|
||||
c
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -91,17 +62,15 @@ fn get_ui_cmd() -> std::process::Command {
|
|||
fn check_and_toggle_overlay() -> bool {
|
||||
let pid_path = scythe::ipc::get_overlay_pid_path();
|
||||
if pid_path.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(&pid_path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(elapsed) = modified.elapsed() {
|
||||
if let Ok(metadata) = std::fs::metadata(&pid_path)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
&& let Ok(elapsed) = modified.elapsed() {
|
||||
// Debounce: if the overlay was spawned less than 400ms ago, this is a
|
||||
// duplicate event from concurrent triggers. Do not kill it; simply exit.
|
||||
if elapsed < std::time::Duration::from_millis(400) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&pid_path) {
|
||||
#[cfg(unix)]
|
||||
|
|
@ -121,19 +90,42 @@ fn check_and_toggle_overlay() -> bool {
|
|||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if pid != std::process::id() {
|
||||
if let Ok(pid) = content.trim().parse::<u32>()
|
||||
&& pid != std::process::id() {
|
||||
unsafe {
|
||||
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) {
|
||||
let _ = TerminateProcess(h, 0);
|
||||
let _ = CloseHandle(h);
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
return true;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetWindowThreadProcessId, PostMessageW, WM_CLOSE,
|
||||
};
|
||||
use windows::Win32::Foundation::{BOOL, HWND, LPARAM, CloseHandle};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, TerminateProcess, PROCESS_TERMINATE, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
unsafe extern "system" fn close_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
unsafe {
|
||||
let target = lparam.0 as u32;
|
||||
let mut pid = 0u32;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||
if pid == target {
|
||||
let _ = PostMessageW(hwnd, WM_CLOSE, None, None);
|
||||
}
|
||||
BOOL(1)
|
||||
}
|
||||
}
|
||||
|
||||
let _ = EnumWindows(Some(close_proc), LPARAM(pid as isize));
|
||||
|
||||
// Wait up to 150ms for graceful shutdown before fallback termination
|
||||
if let Ok(h) = OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) {
|
||||
let wait_res = WaitForSingleObject(h, 150);
|
||||
if wait_res.0 != 0 {
|
||||
let _ = TerminateProcess(h, 0);
|
||||
}
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
|
|
@ -253,10 +245,12 @@ fn handle_toggle_recording() -> Result<(), Box<dyn std::error::Error + Send + Sy
|
|||
|
||||
fn handle_toggle_cursor() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
ensure_daemon_running();
|
||||
let current_cursor = query_status().ok().map(|s| s.show_cursor).unwrap_or(true);
|
||||
let next_cursor = !current_cursor;
|
||||
match send_command(Command::ToggleCursor) {
|
||||
Ok(()) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let next_cursor = query_status().ok().map(|s| s.show_cursor).unwrap_or_else(|| {
|
||||
scythe::config::ScytheConfig::load().show_cursor
|
||||
});
|
||||
if next_cursor {
|
||||
show_shadowplay_toast("MOUSE CURSOR", "Visible in recording", ToastIcon::Cursor);
|
||||
} else {
|
||||
|
|
@ -273,18 +267,162 @@ fn handle_toggle_cursor() -> Result<(), Box<dyn std::error::Error + Send + Sync>
|
|||
|
||||
fn handle_save_replay() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
ensure_daemon_running();
|
||||
if let Ok(st) = query_status() {
|
||||
if !st.is_replay_active {
|
||||
if let Ok(st) = query_status()
|
||||
&& !st.is_replay_active {
|
||||
show_shadowplay_toast("INSTANT REPLAY", "Replay is turned off", ToastIcon::Error);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay)
|
||||
}
|
||||
|
||||
fn handle_screenshot() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
let dir = scythe::config::ScytheConfig::expand_tilde(&cfg.screenshot_directory);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let ext = if cfg.screenshot_format.to_lowercase() == "jpg" || cfg.screenshot_format.to_lowercase() == "jpeg" { "jpg" } else { "png" };
|
||||
let filename = format!("Screenshot_{}.{}", now, ext);
|
||||
let out_path = dir.join(&filename);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut grim = std::process::Command::new("grim");
|
||||
if cfg.screenshot_cursor {
|
||||
grim.arg("-c");
|
||||
}
|
||||
if ext == "jpg" {
|
||||
grim.args(["-t", "jpeg", "-q", "90"]);
|
||||
}
|
||||
grim.arg(&out_path);
|
||||
let _ = grim.status();
|
||||
if !out_path.exists() {
|
||||
let _ = std::process::Command::new("import").args(["-window", "root"]).arg(&out_path).status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ps_cmd = format!(
|
||||
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; \
|
||||
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; \
|
||||
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; \
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp); \
|
||||
$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); \
|
||||
$bmp.Save('{}'); $g.Dispose(); $bmp.Dispose()",
|
||||
out_path.to_string_lossy().replace('/', "\\")
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps_cmd])
|
||||
.status();
|
||||
}
|
||||
|
||||
let scale_dims = match cfg.screenshot_resolution.as_str() {
|
||||
"720p" => Some("1280:720"),
|
||||
"1080p" => Some("1920:1080"),
|
||||
"1440p" => Some("2560:1440"),
|
||||
"2160p" => Some("3840:2160"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(scale) = scale_dims
|
||||
&& out_path.exists() {
|
||||
let tmp_path = dir.join(format!("tmp_{}", filename));
|
||||
if std::fs::rename(&out_path, &tmp_path).is_ok() {
|
||||
let _ = std::process::Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-y", "-i", &tmp_path.to_string_lossy(), "-vf", &format!("scale={}:flags=lanczos", scale), &out_path.to_string_lossy()])
|
||||
.status();
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
}
|
||||
|
||||
show_shadowplay_toast("SCREENSHOT", &format!("Saved to {}", filename), ToastIcon::Screenshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_mute_mic() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
let is_muted = cfg.mic_volume < 0.05;
|
||||
cfg.mic_volume = if is_muted { 0.60 } else { 0.0 };
|
||||
let _ = cfg.save();
|
||||
scythe::config::ScytheConfig::notify_daemon_reload();
|
||||
show_shadowplay_toast("MICROPHONE", if is_muted { "Unmuted" } else { "Muted" }, ToastIcon::Info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_mute_sys() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
let is_muted = cfg.system_volume < 0.05;
|
||||
cfg.system_volume = if is_muted { 1.00 } else { 0.0 };
|
||||
let _ = cfg.save();
|
||||
scythe::config::ScytheConfig::notify_daemon_reload();
|
||||
show_shadowplay_toast("SYSTEM AUDIO", if is_muted { "Unmuted" } else { "Muted" }, ToastIcon::Info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
ensure_daemon_running();
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
if cfg.stream_key.trim().is_empty() {
|
||||
show_shadowplay_toast("LIVESTREAM", "Stream key is empty. Configure in Settings.", ToastIcon::Error);
|
||||
return Ok(());
|
||||
}
|
||||
let status = query_status().ok();
|
||||
let is_streaming = status.as_ref().map(|s| s.is_streaming).unwrap_or(false);
|
||||
match send_command(Command::ToggleStreaming) {
|
||||
Ok(()) => {
|
||||
if is_streaming {
|
||||
show_shadowplay_toast("LIVESTREAM", "Stream stopped", ToastIcon::Info);
|
||||
} else {
|
||||
show_shadowplay_toast("LIVESTREAM", "Livestream started", ToastIcon::Record);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the binary was invoked as.
|
||||
///
|
||||
/// `current_exe()` reads /proc/self/exe, which resolves symlinks to the real
|
||||
/// file, so the installed `scythe-daemon` / `scythe-ui` aliases would all
|
||||
/// report "scythe" and dispatch to the client. argv[0] keeps the invocation
|
||||
/// name, which is what multicall dispatch needs.
|
||||
fn invoked_stem() -> String {
|
||||
if let Some(arg0) = env::args_os().next() {
|
||||
let path = std::path::Path::new(&arg0);
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let stem = stem.to_string_lossy().to_string();
|
||||
if !stem.is_empty() {
|
||||
return stem;
|
||||
}
|
||||
}
|
||||
}
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let exe_name = invoked_stem();
|
||||
|
||||
// Multicall dispatch: scythe-daemon executable, symlink, or subcommand
|
||||
let is_daemon = exe_name == "scythe-daemon"
|
||||
|| exe_name == "vrec-daemon"
|
||||
|| (args.len() > 1 && (args[1] == "daemon" || args[1] == "--daemon" || args[1] == "--mock"));
|
||||
|
||||
if is_daemon {
|
||||
return scythe::daemon::run_daemon();
|
||||
}
|
||||
|
||||
run_client(args)
|
||||
}
|
||||
|
||||
fn run_client(args: Vec<String>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
let is_cli = args.iter().any(|a| a == "--status" || a == "--help" || a == "-h");
|
||||
|
|
@ -298,7 +436,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"--save" => {
|
||||
"--save" | "--save-replay" | "save" => {
|
||||
return handle_save_replay();
|
||||
}
|
||||
"--notify-save" => {
|
||||
|
|
@ -313,6 +451,29 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
show_shadowplay_toast("RECORDING", "Recording saved", ToastIcon::Save);
|
||||
return Ok(());
|
||||
}
|
||||
"--screenshot" | "screenshot" => {
|
||||
return handle_screenshot();
|
||||
}
|
||||
"--mute-mic" => {
|
||||
return handle_mute_mic();
|
||||
}
|
||||
"--mute-sys" => {
|
||||
return handle_mute_sys();
|
||||
}
|
||||
"--pause" => {
|
||||
return handle_toggle_recording();
|
||||
}
|
||||
"--show-overlay" | "--open" => {
|
||||
ensure_daemon_running();
|
||||
// Daemon owns overlay lifetime; if it is unreachable, open the HUD directly.
|
||||
if send_command(Command::ShowOverlay).is_err() {
|
||||
ensure_hotkeys_running();
|
||||
if !check_and_toggle_overlay() {
|
||||
scythe::overlay_egui::run_egui_overlay();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"--toast" => {
|
||||
let title = args.get(2).map(|s| s.as_str()).unwrap_or("SCYTHE");
|
||||
let subtitle = args.get(3).map(|s| s.as_str()).unwrap_or("");
|
||||
|
|
@ -320,7 +481,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
scythe::overlay_egui::run_egui_toast(title, subtitle, ToastIcon::from_name(icon_name));
|
||||
return Ok(());
|
||||
}
|
||||
"--menu" => {
|
||||
"--menu" | "menu" => {
|
||||
ensure_daemon_running_async();
|
||||
ensure_hotkeys_running();
|
||||
if check_and_toggle_overlay() {
|
||||
|
|
@ -339,20 +500,23 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
return Ok(());
|
||||
}
|
||||
"--gtk" => {
|
||||
ensure_daemon_running();
|
||||
ensure_daemon_running_async();
|
||||
ensure_hotkeys_running();
|
||||
#[cfg(target_os = "linux")]
|
||||
scythe::overlay::show_menu_overlay();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if check_and_toggle_overlay() {
|
||||
return Ok(());
|
||||
}
|
||||
scythe::overlay_egui::run_egui_overlay();
|
||||
return Ok(());
|
||||
}
|
||||
"--hotkeys" | "--background" => {
|
||||
// fall through to hotkey loop
|
||||
}
|
||||
"--record" | "--toggle" => {
|
||||
"--record" | "--toggle" | "record" => {
|
||||
return handle_toggle_recording();
|
||||
}
|
||||
"--stream" | "--toggle-stream" => {
|
||||
return handle_toggle_streaming();
|
||||
}
|
||||
"--cursor" | "--toggle-cursor" => {
|
||||
return handle_toggle_cursor();
|
||||
}
|
||||
|
|
@ -366,12 +530,13 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
ensure_daemon_running();
|
||||
return send_command(Command::ReloadConfig);
|
||||
}
|
||||
"--status" => {
|
||||
"--status" | "status" => {
|
||||
ensure_daemon_running();
|
||||
match query_status() {
|
||||
Ok(st) => {
|
||||
println!("Daemon Status:");
|
||||
println!(" State: {}", if st.is_recording { format!("RECORDING ({}s)", st.recording_duration_sec) } else { "IDLE".to_string() });
|
||||
println!(" Livestream: {}", if st.is_streaming { format!("STREAMING ({}s)", st.streaming_duration_sec) } else { "OFF".to_string() });
|
||||
println!(" Instant Replay: {}", if st.is_replay_active { "ACTIVE" } else { "OFF" });
|
||||
println!(" Audio Mode: {}", st.audio_mode);
|
||||
println!(" Mic Volume: {:.0}%", st.mic_volume * 100.0);
|
||||
|
|
@ -385,31 +550,38 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"--quit" => {
|
||||
"--quit" | "quit" => {
|
||||
return send_command(Command::StopDaemon);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("scythe-ui - UI Overlay and Hotkey listener for scythe");
|
||||
println!("scythe {} - GPU screen recorder, instant replay and HUD overlay", scythe::updater::CURRENT_VERSION);
|
||||
println!("Usage:");
|
||||
println!(" scythe-ui Open the interactive overlay UI menu (default)");
|
||||
println!(" scythe-ui --menu Open the interactive overlay UI menu");
|
||||
println!(" scythe-ui --gtk Open legacy GTK layer-shell overlay (Linux)");
|
||||
println!(" scythe-ui --hotkeys Run global hotkey manager in background");
|
||||
println!(" scythe-ui --status Query current daemon status");
|
||||
println!(" scythe-ui --save Save instant replay and show notification");
|
||||
println!(" scythe-ui --notify-save Show instant replay saved notification toast");
|
||||
println!(" scythe-ui --notify-start Show recording started notification toast");
|
||||
println!(" scythe-ui --notify-stop Show recording saved notification toast");
|
||||
println!(" scythe-ui --toast <t> <s> Show custom ShadowPlay notification toast");
|
||||
println!(" scythe-ui --record Toggle normal recording on/off (with notification)");
|
||||
println!(" scythe-ui --cursor Toggle mouse cursor recording on/off (with notification)");
|
||||
println!(" scythe-ui --start Start normal recording");
|
||||
println!(" scythe-ui --stop Stop normal recording");
|
||||
println!(" scythe-ui --reload Reload daemon configuration");
|
||||
println!(" scythe-ui --quit Stop background daemon");
|
||||
println!(" scythe Open the interactive overlay UI menu (default)");
|
||||
println!(" scythe --menu Open the interactive overlay UI menu");
|
||||
println!(" scythe --gtk Deprecated alias for --menu (GTK backend retired)");
|
||||
println!(" scythe --hotkeys Run global hotkey manager in background");
|
||||
println!(" scythe --status Query current daemon status");
|
||||
println!(" scythe --save Save instant replay and show notification");
|
||||
println!(" scythe --save-replay Alias for --save");
|
||||
println!(" scythe --notify-save Show instant replay saved notification toast");
|
||||
println!(" scythe --notify-start Show recording started notification toast");
|
||||
println!(" scythe --notify-stop Show recording saved notification toast");
|
||||
println!(" scythe --toast <t> <s> Show custom ShadowPlay notification toast");
|
||||
println!(" scythe --record Toggle normal recording on/off (with notification)");
|
||||
println!(" scythe --stream Toggle livestreaming on/off (with notification)");
|
||||
println!(" scythe --cursor Toggle mouse cursor recording on/off (with notification)");
|
||||
println!(" scythe --show-overlay Open the HUD overlay via the daemon (no toggle)");
|
||||
println!(" scythe --start Start normal recording");
|
||||
println!(" scythe --stop Stop normal recording");
|
||||
println!(" scythe --reload Reload daemon configuration");
|
||||
println!(" scythe --quit Stop background daemon");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
other => {
|
||||
eprintln!("scythe: unknown argument '{}'", other);
|
||||
eprintln!("Run 'scythe --help' for usage.");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ensure_daemon_running_async();
|
||||
|
|
@ -463,10 +635,22 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RegisteredIds {
|
||||
menu: Option<u32>,
|
||||
save: Option<u32>,
|
||||
rec: Option<u32>,
|
||||
pause: Option<u32>,
|
||||
cur: Option<u32>,
|
||||
shot: Option<u32>,
|
||||
mute_mic: Option<u32>,
|
||||
mute_sys: Option<u32>,
|
||||
}
|
||||
|
||||
let mut current_config = scythe::config::ScytheConfig::load();
|
||||
let mut registered_hotkeys: Vec<HotKey> = Vec::new();
|
||||
|
||||
let register_all = |mgr: &GlobalHotKeyManager, cfg: &scythe::config::ScytheConfig, reg: &mut Vec<HotKey>| -> (Option<u32>, Option<u32>, Option<u32>, Option<u32>) {
|
||||
let register_all = |mgr: &GlobalHotKeyManager, cfg: &scythe::config::ScytheConfig, reg: &mut Vec<HotKey>| -> RegisteredIds {
|
||||
for hk in reg.drain(..) {
|
||||
let _ = mgr.unregister(hk);
|
||||
}
|
||||
|
|
@ -474,42 +658,62 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
let m_menu = scythe::hotkey::parse_hotkey(&cfg.menu_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::ALT), Code::KeyZ)));
|
||||
let m_save = scythe::hotkey::parse_hotkey(&cfg.save_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyR)));
|
||||
let m_rec = scythe::hotkey::parse_hotkey(&cfg.record_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F9)));
|
||||
let m_pause = scythe::hotkey::parse_hotkey(&cfg.pause_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F8)));
|
||||
let m_cur = scythe::hotkey::parse_hotkey(&cfg.cursor_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F10)));
|
||||
let m_shot = scythe::hotkey::parse_hotkey(&cfg.screenshot_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)));
|
||||
let m_mute_mic = scythe::hotkey::parse_hotkey(&cfg.mute_mic_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyM)));
|
||||
let m_mute_sys = scythe::hotkey::parse_hotkey(&cfg.mute_sys_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyN)));
|
||||
|
||||
let mut id_menu = None;
|
||||
let mut id_save = None;
|
||||
let mut id_rec = None;
|
||||
let mut id_cur = None;
|
||||
let mut ids = RegisteredIds::default();
|
||||
|
||||
if !is_on_hyprland {
|
||||
if let Some(hk) = m_menu {
|
||||
id_menu = Some(hk.id());
|
||||
if !is_on_hyprland
|
||||
&& let Some(hk) = m_menu {
|
||||
ids.menu = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
}
|
||||
if let Some(hk) = m_save {
|
||||
id_save = Some(hk.id());
|
||||
ids.save = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
if let Some(hk) = m_rec {
|
||||
id_rec = Some(hk.id());
|
||||
ids.rec = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
if let Some(hk) = m_pause {
|
||||
ids.pause = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
if let Some(hk) = m_cur {
|
||||
id_cur = Some(hk.id());
|
||||
ids.cur = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
(id_menu, id_save, id_rec, id_cur)
|
||||
if let Some(hk) = m_shot {
|
||||
ids.shot = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
if let Some(hk) = m_mute_mic {
|
||||
ids.mute_mic = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
if let Some(hk) = m_mute_sys {
|
||||
ids.mute_sys = Some(hk.id());
|
||||
let _ = mgr.register(hk);
|
||||
reg.push(hk);
|
||||
}
|
||||
ids
|
||||
};
|
||||
|
||||
let (mut id_menu, mut id_save, mut id_rec, mut id_cur) = register_all(&manager, ¤t_config, &mut registered_hotkeys);
|
||||
let mut current_ids = register_all(&manager, ¤t_config, &mut registered_hotkeys);
|
||||
|
||||
println!("Listening for global hotkeys (Overlay: {}, Replay: {}, Record: {}, Cursor: {})...",
|
||||
current_config.menu_hotkey, current_config.save_hotkey, current_config.record_hotkey, current_config.cursor_hotkey);
|
||||
println!("Listening for global hotkeys (Overlay: {}, Replay: {}, Record: {}, Screenshot: {})...",
|
||||
current_config.menu_hotkey, current_config.save_hotkey, current_config.record_hotkey, current_config.screenshot_hotkey);
|
||||
|
||||
let receiver = GlobalHotKeyEvent::receiver();
|
||||
let mut last_config_check = std::time::Instant::now();
|
||||
|
|
@ -533,37 +737,64 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
if new_cfg.menu_hotkey != current_config.menu_hotkey
|
||||
|| new_cfg.save_hotkey != current_config.save_hotkey
|
||||
|| new_cfg.record_hotkey != current_config.record_hotkey
|
||||
|| new_cfg.pause_hotkey != current_config.pause_hotkey
|
||||
|| new_cfg.cursor_hotkey != current_config.cursor_hotkey
|
||||
|| new_cfg.screenshot_hotkey != current_config.screenshot_hotkey
|
||||
|| new_cfg.mute_mic_hotkey != current_config.mute_mic_hotkey
|
||||
|| new_cfg.mute_sys_hotkey != current_config.mute_sys_hotkey
|
||||
{
|
||||
current_config = new_cfg;
|
||||
let (im, is, ir, ic) = register_all(&manager, ¤t_config, &mut registered_hotkeys);
|
||||
id_menu = im;
|
||||
id_save = is;
|
||||
id_rec = ir;
|
||||
id_cur = ic;
|
||||
current_ids = register_all(&manager, ¤t_config, &mut registered_hotkeys);
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(event) = receiver.try_recv() {
|
||||
if Some(event.id) == id_menu {
|
||||
if event.state != HotKeyState::Pressed {
|
||||
continue;
|
||||
}
|
||||
if Some(event.id) == current_ids.menu {
|
||||
std::thread::spawn(|| {
|
||||
ensure_daemon_running();
|
||||
let _ = get_ui_cmd().arg("--menu").spawn();
|
||||
});
|
||||
} else if Some(event.id) == id_save {
|
||||
} else if Some(event.id) == current_ids.save {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_save_replay();
|
||||
});
|
||||
} else if Some(event.id) == id_rec {
|
||||
} else if Some(event.id) == current_ids.rec {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_toggle_recording();
|
||||
});
|
||||
} else if Some(event.id) == id_cur {
|
||||
} else if Some(event.id) == current_ids.pause {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_toggle_recording();
|
||||
});
|
||||
} else if Some(event.id) == current_ids.cur {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_toggle_cursor();
|
||||
});
|
||||
} else if Some(event.id) == current_ids.shot {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_screenshot();
|
||||
});
|
||||
} else if Some(event.id) == current_ids.mute_mic {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_mute_mic();
|
||||
});
|
||||
} else if Some(event.id) == current_ids.mute_sys {
|
||||
std::thread::spawn(|| {
|
||||
let _ = handle_mute_sys();
|
||||
});
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
MsgWaitForMultipleObjectsEx, QS_ALLINPUT, MWMO_INPUTAVAILABLE,
|
||||
};
|
||||
let _ = MsgWaitForMultipleObjectsEx(None, 200, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
49
src/muxer.rs
|
|
@ -21,14 +21,21 @@ impl Muxer {
|
|||
let path_cstr = CString::new(path).map_err(|e| e.to_string())?;
|
||||
let mut fmt_ctx: *mut AVFormatContext = std::ptr::null_mut();
|
||||
|
||||
let is_rtmp = path.starts_with("rtmp://") || path.starts_with("rtmps://");
|
||||
let format_name = if is_rtmp {
|
||||
c"flv".as_ptr()
|
||||
} else {
|
||||
std::ptr::null()
|
||||
};
|
||||
|
||||
let ret = avformat_alloc_output_context2(
|
||||
&mut fmt_ctx,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
format_name,
|
||||
path_cstr.as_ptr(),
|
||||
);
|
||||
if ret < 0 || fmt_ctx.is_null() {
|
||||
return Err("Failed to alloc output context".into());
|
||||
return Err(format!("Failed to alloc output context (code {})", ret));
|
||||
}
|
||||
|
||||
let stream = avformat_new_stream(fmt_ctx, std::ptr::null());
|
||||
|
|
@ -61,15 +68,31 @@ impl Muxer {
|
|||
}
|
||||
|
||||
if ((*(*fmt_ctx).oformat).flags & AVFMT_NOFILE) == 0 {
|
||||
let ret = avio_open(&mut (*fmt_ctx).pb, path_cstr.as_ptr(), AVIO_FLAG_WRITE);
|
||||
let mut io_opts: *mut AVDictionary = std::ptr::null_mut();
|
||||
if is_rtmp {
|
||||
av_dict_set(&mut io_opts, c"rw_timeout".as_ptr(), c"5000000".as_ptr(), 0);
|
||||
av_dict_set(&mut io_opts, c"tcp_nodelay".as_ptr(), c"1".as_ptr(), 0);
|
||||
}
|
||||
let ret = avio_open2(
|
||||
&mut (*fmt_ctx).pb,
|
||||
path_cstr.as_ptr(),
|
||||
AVIO_FLAG_WRITE,
|
||||
std::ptr::null(),
|
||||
&mut io_opts,
|
||||
);
|
||||
av_dict_free(&mut io_opts);
|
||||
if ret < 0 {
|
||||
avformat_free_context(fmt_ctx);
|
||||
return Err("Failed to open output file".into());
|
||||
return Err(format!("Failed to open output URL/file (code {})", ret));
|
||||
}
|
||||
}
|
||||
|
||||
let mut opts: *mut AVDictionary = std::ptr::null_mut();
|
||||
av_dict_set(&mut opts, c"movflags".as_ptr(), c"+faststart".as_ptr(), 0);
|
||||
if is_rtmp {
|
||||
av_dict_set(&mut opts, c"flvflags".as_ptr(), c"no_duration_filesize".as_ptr(), 0);
|
||||
} else {
|
||||
av_dict_set(&mut opts, c"movflags".as_ptr(), c"+faststart".as_ptr(), 0);
|
||||
}
|
||||
(*fmt_ctx).avoid_negative_ts = 2; // AVFMT_AVOID_NEG_TS_MAKE_ZERO: Shift timestamps so that they start at 0
|
||||
(*fmt_ctx).max_interleave_delta = 1_000_000;
|
||||
let ret = avformat_write_header(fmt_ctx, &mut opts);
|
||||
|
|
@ -79,7 +102,7 @@ impl Muxer {
|
|||
avio_closep(&mut (*fmt_ctx).pb);
|
||||
}
|
||||
avformat_free_context(fmt_ctx);
|
||||
return Err("Failed to write header".into());
|
||||
return Err(format!("Failed to write header (code {})", ret));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
|
|
@ -155,3 +178,17 @@ impl Drop for Muxer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_rtmp_url_classification() {
|
||||
let rtmp_twitch = "rtmp://live.twitch.tv/app/live_test_key";
|
||||
let rtmps_youtube = "rtmps://a.rtmp.youtube.com/live2/xxxx-yyyy-zzzz";
|
||||
let local_mp4 = "/home/user/Videos/recording.mp4";
|
||||
|
||||
assert!(rtmp_twitch.starts_with("rtmp://") || rtmp_twitch.starts_with("rtmps://"));
|
||||
assert!(rtmps_youtube.starts_with("rtmp://") || rtmps_youtube.starts_with("rtmps://"));
|
||||
assert!(!local_mp4.starts_with("rtmp://") && !local_mp4.starts_with("rtmps://"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1753
src/overlay.rs
4170
src/overlay_egui.rs
|
|
@ -1,187 +0,0 @@
|
|||
use gtk::prelude::*;
|
||||
use gtk::{Window, WindowType, Box, Orientation, Label, Entry, Button, Switch, ComboBoxText};
|
||||
use crate::config::ScytheConfig;
|
||||
|
||||
pub fn open_replay_settings(config_in: &ScytheConfig) {
|
||||
let window = Window::new(WindowType::Toplevel);
|
||||
window.set_title("Replay Settings");
|
||||
window.set_default_size(320, 200);
|
||||
|
||||
let vbox = Box::new(Orientation::Vertical, 10);
|
||||
vbox.set_margin(15);
|
||||
|
||||
let duration_label = Label::new(Some("Replay Duration (seconds):"));
|
||||
duration_label.set_halign(gtk::Align::Start);
|
||||
let duration_entry = Entry::new();
|
||||
duration_entry.set_text(&config_in.replay_duration_sec.to_string());
|
||||
|
||||
let bitrate_label = Label::new(Some("Bitrate (kbps):"));
|
||||
bitrate_label.set_halign(gtk::Align::Start);
|
||||
let bitrate_entry = Entry::new();
|
||||
bitrate_entry.set_text(&config_in.replay_bitrate_kbps.to_string());
|
||||
|
||||
let save_btn = Button::with_label("Save");
|
||||
|
||||
vbox.pack_start(&duration_label, false, false, 0);
|
||||
vbox.pack_start(&duration_entry, false, false, 0);
|
||||
vbox.pack_start(&bitrate_label, false, false, 0);
|
||||
vbox.pack_start(&bitrate_entry, false, false, 0);
|
||||
vbox.pack_start(&save_btn, false, false, 0);
|
||||
|
||||
window.add(&vbox);
|
||||
|
||||
let window_clone = window.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let mut cfg = ScytheConfig::load();
|
||||
if let Ok(val) = duration_entry.text().parse() {
|
||||
cfg.replay_duration_sec = val;
|
||||
}
|
||||
if let Ok(val) = bitrate_entry.text().parse() {
|
||||
cfg.replay_bitrate_kbps = val;
|
||||
}
|
||||
let _ = cfg.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
window_clone.close();
|
||||
});
|
||||
|
||||
let window_esc = window.clone();
|
||||
window.connect_key_press_event(move |_, key| {
|
||||
if key.keyval() == gdk::keys::constants::Escape {
|
||||
window_esc.close();
|
||||
gtk::glib::Propagation::Stop
|
||||
} else {
|
||||
gtk::glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
window.show_all();
|
||||
}
|
||||
|
||||
pub fn open_record_settings(config_in: &ScytheConfig) {
|
||||
let window = Window::new(WindowType::Toplevel);
|
||||
window.set_title("Record Settings");
|
||||
window.set_default_size(320, 150);
|
||||
|
||||
let vbox = Box::new(Orientation::Vertical, 10);
|
||||
vbox.set_margin(15);
|
||||
|
||||
let bitrate_label = Label::new(Some("Recording Bitrate (kbps):"));
|
||||
bitrate_label.set_halign(gtk::Align::Start);
|
||||
let bitrate_entry = Entry::new();
|
||||
bitrate_entry.set_text(&config_in.record_bitrate_kbps.to_string());
|
||||
|
||||
let save_btn = Button::with_label("Save");
|
||||
|
||||
vbox.pack_start(&bitrate_label, false, false, 0);
|
||||
vbox.pack_start(&bitrate_entry, false, false, 0);
|
||||
vbox.pack_start(&save_btn, false, false, 0);
|
||||
|
||||
window.add(&vbox);
|
||||
|
||||
let window_clone = window.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let mut cfg = ScytheConfig::load();
|
||||
if let Ok(val) = bitrate_entry.text().parse() {
|
||||
cfg.record_bitrate_kbps = val;
|
||||
}
|
||||
let _ = cfg.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
window_clone.close();
|
||||
});
|
||||
|
||||
let window_esc = window.clone();
|
||||
window.connect_key_press_event(move |_, key| {
|
||||
if key.keyval() == gdk::keys::constants::Escape {
|
||||
window_esc.close();
|
||||
gtk::glib::Propagation::Stop
|
||||
} else {
|
||||
gtk::glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
window.show_all();
|
||||
}
|
||||
|
||||
pub fn open_general_settings(config_in: &ScytheConfig) {
|
||||
let window = Window::new(WindowType::Toplevel);
|
||||
window.set_title("General Settings");
|
||||
window.set_default_size(380, 320);
|
||||
|
||||
let vbox = Box::new(Orientation::Vertical, 10);
|
||||
vbox.set_margin(15);
|
||||
|
||||
// Autostart
|
||||
let hbox_auto = Box::new(Orientation::Horizontal, 10);
|
||||
let auto_label = Label::new(Some("Autostart with system:"));
|
||||
let auto_switch = Switch::new();
|
||||
auto_switch.set_active(config_in.autostart);
|
||||
hbox_auto.pack_start(&auto_label, false, false, 0);
|
||||
hbox_auto.pack_start(&auto_switch, false, false, 0);
|
||||
|
||||
// Theme
|
||||
let theme_label = Label::new(Some("UI Theme:"));
|
||||
theme_label.set_halign(gtk::Align::Start);
|
||||
let theme_combo = ComboBoxText::new();
|
||||
theme_combo.append_text("dark");
|
||||
theme_combo.append_text("light");
|
||||
theme_combo.set_active(Some(if config_in.ui_color_theme == "dark" { 0 } else { 1 }));
|
||||
|
||||
// Language
|
||||
let lang_label = Label::new(Some("Language Code (e.g. en, fr):"));
|
||||
lang_label.set_halign(gtk::Align::Start);
|
||||
let lang_entry = Entry::new();
|
||||
lang_entry.set_text(&config_in.language);
|
||||
|
||||
// Hotkeys
|
||||
let menu_hk_label = Label::new(Some("Menu Hotkey:"));
|
||||
menu_hk_label.set_halign(gtk::Align::Start);
|
||||
let menu_hk_entry = Entry::new();
|
||||
menu_hk_entry.set_text(&config_in.menu_hotkey);
|
||||
|
||||
let save_hk_label = Label::new(Some("Save Replay Hotkey:"));
|
||||
save_hk_label.set_halign(gtk::Align::Start);
|
||||
let save_hk_entry = Entry::new();
|
||||
save_hk_entry.set_text(&config_in.save_hotkey);
|
||||
|
||||
let save_btn = Button::with_label("Save General Settings");
|
||||
|
||||
vbox.pack_start(&hbox_auto, false, false, 0);
|
||||
vbox.pack_start(&theme_label, false, false, 0);
|
||||
vbox.pack_start(&theme_combo, false, false, 0);
|
||||
vbox.pack_start(&lang_label, false, false, 0);
|
||||
vbox.pack_start(&lang_entry, false, false, 0);
|
||||
vbox.pack_start(&menu_hk_label, false, false, 0);
|
||||
vbox.pack_start(&menu_hk_entry, false, false, 0);
|
||||
vbox.pack_start(&save_hk_label, false, false, 0);
|
||||
vbox.pack_start(&save_hk_entry, false, false, 0);
|
||||
vbox.pack_start(&save_btn, false, false, 0);
|
||||
|
||||
window.add(&vbox);
|
||||
|
||||
let window_clone = window.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let mut cfg = ScytheConfig::load();
|
||||
cfg.autostart = auto_switch.is_active();
|
||||
if let Some(txt) = theme_combo.active_text() {
|
||||
cfg.ui_color_theme = txt.to_string();
|
||||
}
|
||||
cfg.language = lang_entry.text().to_string();
|
||||
cfg.menu_hotkey = menu_hk_entry.text().to_string();
|
||||
cfg.save_hotkey = save_hk_entry.text().to_string();
|
||||
let _ = cfg.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
window_clone.close();
|
||||
});
|
||||
|
||||
let window_esc = window.clone();
|
||||
window.connect_key_press_event(move |_, key| {
|
||||
if key.keyval() == gdk::keys::constants::Escape {
|
||||
window_esc.close();
|
||||
gtk::glib::Propagation::Stop
|
||||
} else {
|
||||
gtk::glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
window.show_all();
|
||||
}
|
||||
373
src/updater.rs
|
|
@ -2,8 +2,18 @@ use std::sync::{Arc, Mutex};
|
|||
use serde::Deserialize;
|
||||
|
||||
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const FORGEJO_HOST: &str = "https://git.oiupoyt.space";
|
||||
pub const REPO_OWNER_NAME: &str = "oiupoyt/scythe";
|
||||
pub const GITHUB_REPO: &str = "oiupoyt/scythe";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
|
|
@ -12,14 +22,17 @@ pub struct ReleaseInfo {
|
|||
pub html_url: String,
|
||||
pub release_notes: String,
|
||||
pub has_update: bool,
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GitHubRelease {
|
||||
struct ForgejoRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
html_url: String,
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
|
|
@ -43,57 +56,57 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
|
|||
}
|
||||
|
||||
pub fn check_for_updates() -> Option<ReleaseInfo> {
|
||||
let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
|
||||
let urls = [
|
||||
format!("{}/api/v1/repos/{}/releases/latest", FORGEJO_HOST, REPO_OWNER_NAME),
|
||||
format!("https://api.github.com/repos/{}/releases/latest", REPO_OWNER_NAME),
|
||||
];
|
||||
|
||||
// Run curl with a 4-second timeout to avoid blocking
|
||||
let mut cmd = std::process::Command::new("curl");
|
||||
cmd.args([
|
||||
"-s",
|
||||
"-H", "User-Agent: scythe-updater",
|
||||
"-H", "Accept: application/vnd.github.v3+json",
|
||||
"--max-time", "4",
|
||||
&url,
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
for url in urls {
|
||||
let mut cmd = std::process::Command::new("curl");
|
||||
cmd.args([
|
||||
"-s",
|
||||
"-H", "User-Agent: scythe-updater",
|
||||
"-H", "Accept: application/json",
|
||||
"--max-time", "6",
|
||||
&url,
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
}
|
||||
if let Ok(output) = cmd.output()
|
||||
&& output.status.success()
|
||||
&& let Ok(release) = serde_json::from_slice::<ForgejoRelease>(&output.stdout) {
|
||||
let clean_ver = release.tag_name.trim().trim_start_matches('v').to_string();
|
||||
let has_update = is_newer(&clean_ver, CURRENT_VERSION);
|
||||
|
||||
return Some(ReleaseInfo {
|
||||
tag_name: release.tag_name,
|
||||
version: clean_ver,
|
||||
name: release.name.unwrap_or_else(|| format!("v{}", CURRENT_VERSION)),
|
||||
html_url: release.html_url,
|
||||
release_notes: release.body.unwrap_or_default(),
|
||||
has_update,
|
||||
assets: release.assets,
|
||||
});
|
||||
}
|
||||
}
|
||||
let output = cmd.output().ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let release: GitHubRelease = serde_json::from_slice(&output.stdout).ok()?;
|
||||
let clean_ver = release.tag_name.trim().trim_start_matches('v').to_string();
|
||||
let has_update = is_newer(&clean_ver, CURRENT_VERSION);
|
||||
|
||||
Some(ReleaseInfo {
|
||||
tag_name: release.tag_name,
|
||||
version: clean_ver,
|
||||
name: release.name.unwrap_or_else(|| format!("Scythe v{}", CURRENT_VERSION)),
|
||||
html_url: release.html_url,
|
||||
release_notes: release.body.unwrap_or_default(),
|
||||
has_update,
|
||||
})
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum UpdateStatus {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
UpToDate { version: String },
|
||||
Available(ReleaseInfo),
|
||||
Updating { version: String, progress: String },
|
||||
Updated { version: String, message: String },
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Default for UpdateStatus {
|
||||
fn default() -> Self {
|
||||
UpdateStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_update_check(status: Arc<Mutex<UpdateStatus>>) {
|
||||
if let Ok(mut lock) = status.lock() {
|
||||
*lock = UpdateStatus::Checking;
|
||||
|
|
@ -118,6 +131,286 @@ pub fn spawn_update_check(status: Arc<Mutex<UpdateStatus>>) {
|
|||
});
|
||||
}
|
||||
|
||||
fn download_file(url: &str, dest: &std::path::Path) -> Result<(), String> {
|
||||
let dest_str = dest.to_str().ok_or_else(|| "Invalid destination path".to_string())?;
|
||||
let mut cmd = std::process::Command::new("curl");
|
||||
cmd.args([
|
||||
"-L",
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"-H", "User-Agent: scythe-updater",
|
||||
"-o", dest_str,
|
||||
url,
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| format!("Failed to run curl: {}", e))?;
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Download failed: {}", err.trim()));
|
||||
}
|
||||
if !dest.exists() || std::fs::metadata(dest).map(|m| m.len() == 0).unwrap_or(true) {
|
||||
return Err("Downloaded file is empty or missing".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_auto_update(info: ReleaseInfo, status: Arc<Mutex<UpdateStatus>>) {
|
||||
if let Ok(mut lock) = status.lock() {
|
||||
*lock = UpdateStatus::Updating {
|
||||
version: info.version.clone(),
|
||||
progress: "Preparing update...".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let res = run_auto_update(&info, &status);
|
||||
if let Ok(mut lock) = status.lock() {
|
||||
match res {
|
||||
Ok(msg) => {
|
||||
*lock = UpdateStatus::Updated {
|
||||
version: info.version.clone(),
|
||||
message: msg,
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
*lock = UpdateStatus::Failed(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn run_auto_update(info: &ReleaseInfo, status: &Arc<Mutex<UpdateStatus>>) -> Result<String, String> {
|
||||
let set_progress = |msg: &str| {
|
||||
if let Ok(mut lock) = status.lock() {
|
||||
*lock = UpdateStatus::Updating {
|
||||
version: info.version.clone(),
|
||||
progress: msg.to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// 1. Check if running from AppImage
|
||||
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
|
||||
let appimage_file = std::path::PathBuf::from(&appimage_path);
|
||||
if appimage_file.exists() {
|
||||
set_progress("Locating AppImage package...");
|
||||
let asset = info
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(".AppImage"))
|
||||
.ok_or_else(|| "Could not find AppImage in release assets".to_string())?;
|
||||
|
||||
set_progress(&format!("Downloading AppImage ({})...", asset.name));
|
||||
let temp_download = appimage_file.with_extension("download");
|
||||
download_file(&asset.browser_download_url, &temp_download)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&temp_download, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
|
||||
set_progress("Applying update...");
|
||||
std::fs::rename(&temp_download, &appimage_file)
|
||||
.map_err(|e| format!("Failed to replace AppImage: {}", e))?;
|
||||
|
||||
return Ok(format!("Updated AppImage to v{}", info.version));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Standard binary update (.tar.gz containing scythe-ui, scythe-daemon)
|
||||
set_progress("Locating Linux package...");
|
||||
let asset = info
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "scythe-linux-x86_64.tar.gz")
|
||||
.or_else(|| info.assets.iter().find(|a| a.name.contains("linux") && a.name.ends_with(".tar.gz")))
|
||||
.ok_or_else(|| "Could not find scythe-linux-x86_64.tar.gz in release assets".to_string())?;
|
||||
|
||||
let temp_dir = std::env::temp_dir().join(format!("scythe_update_{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
let archive_path = temp_dir.join("scythe-linux-x86_64.tar.gz");
|
||||
|
||||
set_progress(&format!("Downloading {}...", asset.name));
|
||||
download_file(&asset.browser_download_url, &archive_path)?;
|
||||
|
||||
set_progress("Extracting binaries...");
|
||||
let extract_status = std::process::Command::new("tar")
|
||||
.args(["-xzf", archive_path.to_str().unwrap(), "-C", temp_dir.to_str().unwrap()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run tar: {}", e))?;
|
||||
|
||||
if !extract_status.status.success() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("tar extraction failed: {}", String::from_utf8_lossy(&extract_status.stderr)));
|
||||
}
|
||||
|
||||
let extracted_dir = temp_dir.join("scythe-linux-x86_64");
|
||||
if !extracted_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err("Extracted directory not found".to_string());
|
||||
}
|
||||
|
||||
// Resolve current executable's directory
|
||||
let current_exe = std::env::current_exe().map_err(|e| format!("Cannot locate current executable: {}", e))?;
|
||||
let canonical_exe = std::fs::canonicalize(¤t_exe).unwrap_or_else(|_| current_exe.clone());
|
||||
let exe_dir = canonical_exe.parent().ok_or_else(|| "Invalid executable path".to_string())?;
|
||||
|
||||
set_progress(&format!("Installing binaries into {}...", exe_dir.display()));
|
||||
|
||||
for bin_name in ["scythe-ui", "scythe-daemon"] {
|
||||
let src = extracted_dir.join(bin_name);
|
||||
if src.exists() {
|
||||
let dest = exe_dir.join(bin_name);
|
||||
let dest_new = exe_dir.join(format!("{}.new", bin_name));
|
||||
|
||||
std::fs::copy(&src, &dest_new)
|
||||
.map_err(|e| format!("Failed to write {}: {}. Check write permissions in {}", bin_name, e, exe_dir.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dest_new, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
|
||||
std::fs::rename(&dest_new, &dest)
|
||||
.map_err(|e| format!("Failed to atomically replace {}: {}", bin_name, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain aliases in ~/.local/bin if installed there
|
||||
if exe_dir.ends_with(".local/bin") {
|
||||
for link in ["scythe", "vrec", "vrec-ui"] {
|
||||
let link_path = exe_dir.join(link);
|
||||
let target_path = exe_dir.join("scythe-ui");
|
||||
if !link_path.exists() {
|
||||
#[cfg(unix)]
|
||||
let _ = std::os::unix::fs::symlink(&target_path, &link_path);
|
||||
}
|
||||
}
|
||||
let daemon_link = exe_dir.join("vrec-daemon");
|
||||
if !daemon_link.exists() {
|
||||
#[cfg(unix)]
|
||||
let _ = std::os::unix::fs::symlink(exe_dir.join("scythe-daemon"), &daemon_link);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
Ok(format!("Installed v{} to {}", info.version, exe_dir.display()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 1. Check for official NSIS installer (scythe-setup.exe)
|
||||
if let Some(asset) = info.assets.iter().find(|a| a.name == "scythe-setup.exe") {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let installer_path = temp_dir.join(format!("scythe-setup-v{}.exe", info.version));
|
||||
|
||||
set_progress(&format!("Downloading Windows installer ({})...", asset.name));
|
||||
download_file(&asset.browser_download_url, &installer_path)?;
|
||||
|
||||
set_progress("Launching installer...");
|
||||
let mut cmd = std::process::Command::new(&installer_path);
|
||||
cmd.arg("/S");
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
let _ = cmd.spawn().map_err(|e| format!("Failed to launch installer: {}", e))?;
|
||||
|
||||
return Ok(format!("Update installer launched for v{}.", info.version));
|
||||
}
|
||||
|
||||
// 2. Portable zip update
|
||||
if let Some(asset) = info.assets.iter().find(|a| a.name == "scythe-windows-x86_64.zip") {
|
||||
let temp_dir = std::env::temp_dir().join(format!("scythe_update_{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
let zip_path = temp_dir.join("scythe-windows-x86_64.zip");
|
||||
|
||||
set_progress(&format!("Downloading {}...", asset.name));
|
||||
download_file(&asset.browser_download_url, &zip_path)?;
|
||||
|
||||
set_progress("Extracting archive...");
|
||||
let tar_out = std::process::Command::new("tar.exe")
|
||||
.args(["-xf", zip_path.to_str().unwrap(), "-C", temp_dir.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
let extracted_ok = tar_out.map(|o| o.status.success()).unwrap_or(false);
|
||||
if !extracted_ok {
|
||||
let ps_script = format!(
|
||||
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
|
||||
zip_path.display(),
|
||||
temp_dir.display()
|
||||
);
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps_script])
|
||||
.output()
|
||||
.map_err(|e| format!("Extraction failed: {}", e))?;
|
||||
}
|
||||
|
||||
let current_exe = std::env::current_exe().map_err(|e| format!("Cannot locate current executable: {}", e))?;
|
||||
let exe_dir = current_exe.parent().ok_or_else(|| "Invalid executable path".to_string())?;
|
||||
|
||||
set_progress("Replacing binaries and libraries...");
|
||||
let mut files_to_update = Vec::new();
|
||||
let mut search_dirs = vec![temp_dir.clone()];
|
||||
while let Some(dir) = search_dirs.pop() {
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
search_dirs.push(path);
|
||||
} else if path.is_file() {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let lower = name.to_lowercase();
|
||||
if lower.ends_with(".exe") || lower.ends_with(".dll") {
|
||||
files_to_update.push((path, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (src, name) in files_to_update {
|
||||
let dest = exe_dir.join(&name);
|
||||
let dest_old = exe_dir.join(format!("{}.old", name));
|
||||
let _ = std::fs::remove_file(&dest_old);
|
||||
let _ = std::fs::rename(&dest, &dest_old);
|
||||
let _ = std::fs::copy(&src, &dest);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Ok(format!("Updated binaries to v{} in {}", info.version, exe_dir.display()));
|
||||
}
|
||||
|
||||
Err("No compatible Windows package found in release assets".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
Err("Automatic update is not supported on this operating system".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restart_application() {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = cmd.spawn();
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
pub fn open_browser_url(url: &str) {
|
||||
let u = url.to_string();
|
||||
std::thread::spawn(move || {
|
||||
|
|
|
|||
|
|
@ -10,21 +10,75 @@ echo "[1/6] Building release binaries..."
|
|||
cargo build --release --quiet
|
||||
|
||||
SOCKET_PATH="${XDG_RUNTIME_DIR:-/tmp}/scythe.sock"
|
||||
|
||||
# Liveness probe with no side effects. `scythe --status` calls
|
||||
# ensure_daemon_running(), so probing with it spawns a daemon and the check
|
||||
# starts chasing its own tail.
|
||||
daemon_alive() {
|
||||
[ -S "$SOCKET_PATH" ] || return 1
|
||||
python3 -c '
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(0.5)
|
||||
try:
|
||||
s.connect(sys.argv[1])
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
s.close()
|
||||
' "$SOCKET_PATH" 2>/dev/null
|
||||
}
|
||||
|
||||
# A live daemon owns this socket. Removing the path entry while it runs leaves the
|
||||
# kernel socket bound but unreachable, breaking the user's session until restart.
|
||||
# Stop it gracefully, remember how it ran, and put it back when the test finishes.
|
||||
PREVIOUS_DAEMON_CMD=""
|
||||
PREVIOUS_DAEMON_CWD=""
|
||||
if daemon_alive; then
|
||||
PREVIOUS_DAEMON_CMD=$(pgrep -af 'scythe.*daemon' 2>/dev/null | head -1 | cut -d' ' -f2- || true)
|
||||
PREVIOUS_DAEMON_CWD=$(readlink -f /proc/"$(pgrep -f 'scythe.*daemon' 2>/dev/null | head -1)"/cwd 2>/dev/null || true)
|
||||
echo "[0/6] Existing daemon detected; stopping it for the test run..."
|
||||
target/release/scythe --quit >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
daemon_alive || break
|
||||
sleep 0.1
|
||||
done
|
||||
if daemon_alive; then
|
||||
echo "ERROR: A scythe daemon is running and did not stop cleanly."
|
||||
echo " Refusing to unlink its socket. Stop it first: scythe --quit"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$SOCKET_PATH"
|
||||
rm -f record_*.mp4 replay_*.mp4
|
||||
|
||||
SAVE_DIR=$(python3 -c 'import json, os; p=os.path.expanduser("~/.config/scythe/config.json"); print(json.load(open(p))["output_directory"]) if os.path.exists(p) else print(os.path.expanduser("~/Videos/Scythe"))' 2>/dev/null || echo "$HOME/Videos/Scythe")
|
||||
SAVE_DIR=$(python3 -c 'import json, os; p=os.path.expanduser("~/.config/scythe/config.json"); print(os.path.expanduser(json.load(open(p))["output_directory"])) if os.path.exists(p) else print(os.path.expanduser("~/Videos/Scythe"))' 2>/dev/null || echo "$HOME/Videos/Scythe")
|
||||
mkdir -p "$SAVE_DIR"
|
||||
|
||||
echo "[2/6] Starting scythe-daemon with mock capture..."
|
||||
target/release/scythe-daemon --mock > daemon_test.log 2>&1 &
|
||||
WAS_SERVICE_ACTIVE=0
|
||||
if systemctl --user is-active --quiet scythe-daemon.service 2>/dev/null; then
|
||||
WAS_SERVICE_ACTIVE=1
|
||||
systemctl --user stop scythe-daemon.service 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
|
||||
echo "[2/6] Starting scythe daemon with mock capture..."
|
||||
target/release/scythe daemon --mock > daemon_test.log 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if kill -0 $DAEMON_PID 2>/dev/null; then
|
||||
target/release/scythe-ui --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true
|
||||
target/release/scythe --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true
|
||||
fi
|
||||
rm -f daemon_test.log
|
||||
if [ "$WAS_SERVICE_ACTIVE" -eq 1 ]; then
|
||||
systemctl --user start scythe-daemon.service 2>/dev/null || true
|
||||
elif [ -n "$PREVIOUS_DAEMON_CMD" ]; then
|
||||
echo "Restoring the daemon that was running before the test..."
|
||||
( cd "${PREVIOUS_DAEMON_CWD:-$PWD}" && setsid $PREVIOUS_DAEMON_CMD >/dev/null 2>&1 & ) || true
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
|
@ -46,23 +100,23 @@ echo "Daemon is live (PID: $DAEMON_PID, Socket: $SOCKET_PATH)"
|
|||
|
||||
# Test 1: Start Normal Recording
|
||||
echo "[3/6] Testing: Start Normal Recording..."
|
||||
target/release/scythe-ui --start
|
||||
target/release/scythe --start
|
||||
echo "Recording in progress for 3 seconds..."
|
||||
sleep 3
|
||||
|
||||
# Test 2: Stop Normal Recording
|
||||
echo "[4/6] Testing: Stop Normal Recording..."
|
||||
target/release/scythe-ui --stop
|
||||
target/release/scythe --stop
|
||||
sleep 1
|
||||
|
||||
# Test 3: Save Instant Replay
|
||||
echo "[5/6] Testing: Save Instant Replay buffer..."
|
||||
target/release/scythe-ui --save
|
||||
target/release/scythe --save
|
||||
sleep 2
|
||||
|
||||
# Test 4: Quit Daemon
|
||||
echo "[6/6] Testing: Clean Daemon Shutdown..."
|
||||
target/release/scythe-ui --quit
|
||||
target/release/scythe --quit
|
||||
wait $DAEMON_PID 2>/dev/null || true
|
||||
echo "Daemon shutdown cleanly."
|
||||
|
||||
|
|
@ -72,7 +126,7 @@ echo " Verifying Generated Recordings with ffprobe"
|
|||
echo "============================================="
|
||||
|
||||
FOUND_ANY=0
|
||||
for f in $(find "$SAVE_DIR" "$HOME/Videos/Scythe" "$HOME/Videos/vrec" . -maxdepth 2 \( -name "record_*.mp4" -o -name "replay_*.mp4" \) 2>/dev/null); do
|
||||
while IFS= read -r -d '' f; do
|
||||
if [ -f "$f" ]; then
|
||||
FOUND_ANY=1
|
||||
echo "--> Testing file: $f ($(ls -lh "$f" | awk '{print $5}'))"
|
||||
|
|
@ -87,7 +141,7 @@ for f in $(find "$SAVE_DIR" "$HOME/Videos/Scythe" "$HOME/Videos/vrec" . -maxdept
|
|||
echo "---------------------------------------------"
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
done < <(find "$SAVE_DIR" "$HOME/Videos/Scythe" "$HOME/Videos/vrec" . -maxdepth 2 -mmin -2 \( -iname "record*.mp4" -o -iname "replay*.mp4" \) -print0 2>/dev/null)
|
||||
|
||||
if [ $FOUND_ANY -eq 0 ]; then
|
||||
echo "FAILED: No recording or replay files were produced!"
|
||||
|
|
|
|||
16
tests/manual_ipc/send_ipc.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import socket
|
||||
import json
|
||||
import struct
|
||||
import os
|
||||
|
||||
def send(cmd):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
sock_path = f"{rt}/scythe.sock" if os.path.exists(f"{rt}/scythe.sock") else f"{rt}/vrec.sock"
|
||||
sock.connect(sock_path)
|
||||
payload = json.dumps(cmd).encode('utf-8')
|
||||
sock.sendall(struct.pack('<I', len(payload)))
|
||||
sock.sendall(payload)
|
||||
|
||||
import sys
|
||||
send(sys.argv[1])
|
||||
13
tests/manual_ipc/test_ipc.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import socket
|
||||
import json
|
||||
import struct
|
||||
import os
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
sock_path = f"{rt}/scythe.sock" if os.path.exists(f"{rt}/scythe.sock") else f"{rt}/vrec.sock"
|
||||
sock.connect(sock_path)
|
||||
payload = b'"SaveReplay"'
|
||||
sock.sendall(struct.pack('<I', len(payload)))
|
||||
sock.sendall(payload)
|
||||
print("Sent SaveReplay")
|
||||
9
tests/manual_ipc/test_record.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import socket, struct, os
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
sock_path = f"{rt}/scythe.sock" if os.path.exists(f"{rt}/scythe.sock") else f"{rt}/vrec.sock"
|
||||
sock.connect(sock_path)
|
||||
payload = b'"StartRecording"'
|
||||
sock.sendall(struct.pack('<I', len(payload)))
|
||||
sock.sendall(payload)
|
||||
print("Sent StartRecording")
|
||||
9
tests/manual_ipc/test_reload.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import socket, struct, os
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
sock_path = f"{rt}/scythe.sock" if os.path.exists(f"{rt}/scythe.sock") else f"{rt}/vrec.sock"
|
||||
sock.connect(sock_path)
|
||||
payload = b'"ReloadConfig"'
|
||||
sock.sendall(struct.pack('<I', len(payload)))
|
||||
sock.sendall(payload)
|
||||
print("Sent ReloadConfig")
|
||||
9
tests/manual_ipc/test_stop.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import socket, struct, os
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
sock_path = f"{rt}/scythe.sock" if os.path.exists(f"{rt}/scythe.sock") else f"{rt}/vrec.sock"
|
||||
sock.connect(sock_path)
|
||||
payload = b'"StopRecording"'
|
||||
sock.sendall(struct.pack('<I', len(payload)))
|
||||
sock.sendall(payload)
|
||||
print("Sent StopRecording")
|
||||