Compare commits

...

5 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 273862c882 release scripts: push the release branch to both forges
Push to the 'all' remote (GitHub + Gitea pushurls) instead of origin so
releases are mirrored to Gitea too. CI still triggers via the GitHub pushurl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:38:33 -04:00
Skyler Lehmkuhl a3f18bdab6 Remove the legacy Tauri backend (src-tauri)
The shipping product is the pure-Rust app in lightningbeam-ui/; the old
JavaScript UI in src/ is browser-only (window.__TAURI__ is shimmed by
src/tauri_polyfill.js), so the src-tauri Tauri backend is dead. It also no
longer compiled (mid-refactor: missing video_server module, handler list
referencing deleted commands), and CI only ever built lightningbeam-ui.

- Delete src-tauri/ entirely (commands, native renderer/render-window,
  websocket frame streamers, Tauri config, generated schemas, icons).
- Relocate icon.icns into lightningbeam-ui/lightningbeam-editor/assets/icons/
  (the only src-tauri asset still needed) and repoint the editor's window-icon
  include_bytes! at assets/icons/256x256.png (was src-tauri/icons/icon.png).
- CI/packaging: drop the redundant icon-copy steps (PNGs are committed in the
  editor assets) and point macOS bundling at the relocated icns.
- package.json: drop @tauri-apps/* deps and the tauri script.
- Docs/.gitignore: drop src-tauri references.

daw-backend/ (shared audio engine) and the top-level WASM lightningbeam-core/
are untouched. Editor builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:25:39 -04:00
Skyler Lehmkuhl 2c40858663 docs: point branch references at master
rust-ui is being merged into master and retired as the primary branch.
Update README, ARCHITECTURE, and CONTRIBUTING to reference master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:13:02 -04:00
Skyler Lehmkuhl 149760e9c0 CI: build NeuralAudio with Ninja on Windows
cmake-rs 0.1.54 panics with "unsupported or unknown VisualStudio version: 18.0"
because the windows-latest runner now ships Visual Studio 18 (2026), which the
crate's VS-generator detection doesn't recognize.

Install Ninja and set CMAKE_GENERATOR=Ninja for the Windows build so the cmake
crate skips VS-version detection and drives cl.exe (from the active MSVC env)
directly. build.rs already searches the single-config (non-Release) output dirs,
so no linking changes are needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:18:41 -04:00
Skyler Lehmkuhl 1cb821f05b CI: build Windows under pwsh so MSVC's linker is used
Git for Windows puts its usr\bin on PATH ahead of the MSVC tools, so under
Git Bash rustc picked up coreutils link.exe instead of MSVC link.exe and
every link (even host build scripts like proc-macro2/serde) failed with
"/usr/bin/link: extra operand".

Split the build step: non-Windows keeps the bash script (needs --target
handling); Windows builds under pwsh, where the MSVC environment from
msvc-dev-cmd stays at the front of PATH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:03:43 -04:00
49 changed files with 42 additions and 11137 deletions

View File

@ -88,7 +88,11 @@ jobs:
- name: Install dependencies (Windows) - name: Install dependencies (Windows)
if: matrix.platform == 'windows-latest' if: matrix.platform == 'windows-latest'
shell: pwsh shell: pwsh
run: choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y run: |
choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y
# Ninja: build NeuralAudio with it instead of a Visual Studio generator,
# so the cmake crate doesn't need to recognize the runner's VS version.
choco install ninja -y
# Activate the MSVC developer environment (runs vcvars) so the cmake crate # Activate the MSVC developer environment (runs vcvars) so the cmake crate
# building nam-ffi/NeuralAudio can determine the Visual Studio generator. # building nam-ffi/NeuralAudio can determine the Visual Studio generator.
@ -126,14 +130,6 @@ jobs:
# LLVM/libclang for bindgen # LLVM/libclang for bindgen
echo "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append echo "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Setup icons
shell: bash
run: |
mkdir -p lightningbeam-ui/lightningbeam-editor/assets/icons
cp -f src-tauri/icons/32x32.png lightningbeam-ui/lightningbeam-editor/assets/icons/
cp -f src-tauri/icons/128x128.png lightningbeam-ui/lightningbeam-editor/assets/icons/
cp -f src-tauri/icons/icon.png lightningbeam-ui/lightningbeam-editor/assets/icons/256x256.png
- name: Stage factory presets - name: Stage factory presets
shell: bash shell: bash
run: | run: |
@ -170,6 +166,7 @@ jobs:
echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> "$GITHUB_ENV" echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> "$GITHUB_ENV"
- name: Build release binary - name: Build release binary
if: matrix.platform != 'windows-latest'
shell: bash shell: bash
env: env:
FFMPEG_STATIC: "1" FFMPEG_STATIC: "1"
@ -181,6 +178,23 @@ jobs:
fi fi
cargo build --release --bin lightningbeam-editor $TARGET_FLAG cargo build --release --bin lightningbeam-editor $TARGET_FLAG
# Windows must build under pwsh, not Git Bash: Git for Windows puts its
# usr\bin on PATH ahead of the MSVC tools, so its coreutils `link.exe`
# shadows MSVC's linker and rustc fails with "extra operand". pwsh keeps
# the MSVC environment (set by msvc-dev-cmd) at the front of PATH.
- name: Build release binary (Windows)
if: matrix.platform == 'windows-latest'
shell: pwsh
env:
FFMPEG_STATIC: "1"
# Force Ninja for the cmake crate (nam-ffi/NeuralAudio): cmake-rs 0.1.54
# panics on "unknown VisualStudio version: 18.0" when it auto-detects the
# runner's VS generator. Ninja sidesteps that lookup entirely.
CMAKE_GENERATOR: Ninja
run: |
cd lightningbeam-ui
cargo build --release --bin lightningbeam-editor
- name: Copy cross-compiled binary to release dir - name: Copy cross-compiled binary to release dir
if: matrix.target != '' if: matrix.target != ''
shell: bash shell: bash
@ -305,7 +319,7 @@ jobs:
mkdir -p "$APP/Contents/Resources/presets" mkdir -p "$APP/Contents/Resources/presets"
cp lightningbeam-ui/target/release/lightningbeam-editor "$APP/Contents/MacOS/" cp lightningbeam-ui/target/release/lightningbeam-editor "$APP/Contents/MacOS/"
cp src-tauri/icons/icon.icns "$APP/Contents/Resources/lightningbeam-editor.icns" cp lightningbeam-ui/lightningbeam-editor/assets/icons/icon.icns "$APP/Contents/Resources/lightningbeam-editor.icns"
cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* "$APP/Contents/Resources/presets/" cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* "$APP/Contents/Resources/presets/"
cat > "$APP/Contents/Info.plist" << EOF cat > "$APP/Contents/Info.plist" << EOF

2
.gitignore vendored
View File

@ -28,8 +28,6 @@ dist-ssr
.gitignore .gitignore
# Build # Build
src-tauri/gen
src-tauri/target
lightningbeam-core/target lightningbeam-core/target
daw-backend/target daw-backend/target
target/ target/

View File

@ -52,7 +52,7 @@ Lightningbeam is a 2D multimedia editor combining vector animation, audio produc
Lightningbeam is undergoing a rewrite from a Tauri/JavaScript prototype to pure Rust. The original architecture hit IPC bandwidth limitations when streaming decoded video frames. The new Rust UI eliminates this bottleneck by handling all rendering natively. Lightningbeam is undergoing a rewrite from a Tauri/JavaScript prototype to pure Rust. The original architecture hit IPC bandwidth limitations when streaming decoded video frames. The new Rust UI eliminates this bottleneck by handling all rendering natively.
**Current Status**: Active development on the `rust-ui` branch. Core UI, tools, and undo system are implemented. Audio integration in progress. **Current Status**: Active development on the `master` branch. Core UI, tools, undo system, and audio integration are implemented.
## Technology Stack ## Technology Stack
@ -486,8 +486,7 @@ lightningbeam-2/
│ ├── synth/ # Synthesizers │ ├── synth/ # Synthesizers
│ └── midi/ # MIDI handling │ └── midi/ # MIDI handling
├── src-tauri/ # Legacy Tauri backend ├── src/ # Legacy JavaScript frontend (browser-only)
├── src/ # Legacy JavaScript frontend
├── CONTRIBUTING.md # Contributor guide ├── CONTRIBUTING.md # Contributor guide
├── ARCHITECTURE.md # This file ├── ARCHITECTURE.md # This file
├── README.md # Project overview ├── README.md # Project overview

View File

@ -122,25 +122,23 @@ lightningbeam-2/
│ │ ├── track.rs # Track management │ │ ├── track.rs # Track management
│ │ └── project.rs # Project state │ │ └── project.rs # Project state
│ └── effects/ # Audio effects │ └── effects/ # Audio effects
├── src-tauri/ # Legacy Tauri backend └── src/ # Legacy JavaScript frontend (browser-only)
└── src/ # Legacy JavaScript frontend
``` ```
## Making Changes ## Making Changes
### Branching Strategy ### Branching Strategy
- `main` - Stable branch - `master` - Main development branch
- `rust-ui` - Active development branch for Rust UI rewrite - Feature branches - Create from `master` for new features
- Feature branches - Create from `rust-ui` for new features
### Before You Start ### Before You Start
1. Check existing issues or create a new one to discuss your change 1. Check existing issues or create a new one to discuss your change
2. Make sure you're on the latest `rust-ui` branch: 2. Make sure you're on the latest `master` branch:
```bash ```bash
git checkout rust-ui git checkout master
git pull origin rust-ui git pull origin master
``` ```
3. Create a feature branch: 3. Create a feature branch:
```bash ```bash
@ -241,7 +239,7 @@ this solution.
### Pull Request Process ### Pull Request Process
1. Push your branch to GitHub or Gitea 1. Push your branch to GitHub or Gitea
2. Open a pull request against `rust-ui` branch 2. Open a pull request against `master` branch
- GitHub: https://github.com/skykooler/lightningbeam - GitHub: https://github.com/skykooler/lightningbeam
- Gitea: https://git.skyler.io/skyler/lightningbeam - Gitea: https://git.skyler.io/skyler/lightningbeam
3. Provide a clear description of: 3. Provide a clear description of:

View File

@ -48,7 +48,7 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr
## Project Status ## Project Status
Lightningbeam is under active development on the `rust-ui` branch. The project has been rewritten from a Tauri/JavaScript prototype to a pure Rust application to eliminate IPC bottlenecks and achieve better performance for real-time video and audio processing. Lightningbeam is developed on the `master` branch. The project has been rewritten from a Tauri/JavaScript prototype to a pure Rust application to eliminate IPC bottlenecks and achieve better performance for real-time video and audio processing.
**Current Status:** **Current Status:**
- ✅ Core UI panes (Stage, Timeline, Asset Library, Info Panel, Toolbar) - ✅ Core UI panes (Stage, Timeline, Asset Library, Info Panel, Toolbar)

View File

@ -26,7 +26,9 @@ echo "Merging $MAIN_BRANCH into $RELEASE_BRANCH..."
git merge $MAIN_BRANCH --no-ff -m "Release $VERSION" git merge $MAIN_BRANCH --no-ff -m "Release $VERSION"
echo "Pushing $RELEASE_BRANCH..." echo "Pushing $RELEASE_BRANCH..."
git push origin $RELEASE_BRANCH # Push to the 'all' remote so the release branch lands on both GitHub and Gitea.
# CI (GitHub Actions) still triggers via the GitHub pushurl.
git push all $RELEASE_BRANCH
git checkout $MAIN_BRANCH git checkout $MAIN_BRANCH

View File

@ -140,7 +140,7 @@ fn main() -> eframe::Result {
} }
// Load window icon // Load window icon
let icon_data = include_bytes!("../../../src-tauri/icons/icon.png"); let icon_data = include_bytes!("../assets/icons/256x256.png");
let icon_image = match image::load_from_memory(icon_data) { let icon_image = match image::load_from_memory(icon_data) {
Ok(img) => { Ok(img) => {
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();

View File

@ -4,12 +4,10 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tauri": "tauri",
"test": "wdio run wdio.conf.js", "test": "wdio run wdio.conf.js",
"test:watch": "wdio run wdio.conf.js --watch" "test:watch": "wdio run wdio.conf.js --watch"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2",
"@wdio/cli": "^9.20.0", "@wdio/cli": "^9.20.0",
"@wdio/globals": "^9.17.0", "@wdio/globals": "^9.17.0",
"@wdio/local-runner": "8", "@wdio/local-runner": "8",
@ -19,9 +17,6 @@
}, },
"dependencies": { "dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.10",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-log": "^2.2.0",
"ffmpeg": "^0.0.4", "ffmpeg": "^0.0.4",
"ffmpeg.js": "^4.2.9003" "ffmpeg.js": "^4.2.9003"
} }

View File

@ -20,9 +20,7 @@ pkgbuild: setup-icons
setup-icons: setup-icons:
@echo "==> Setting up icons..." @echo "==> Setting up icons..."
@mkdir -p $(EDITOR)/assets/icons @mkdir -p $(EDITOR)/assets/icons
@cp -u $(REPO_ROOT)/src-tauri/icons/32x32.png $(EDITOR)/assets/icons/ 2>/dev/null || true @# Icons are committed under $(EDITOR)/assets/icons (32x32, 128x128, 256x256, icon.icns).
@cp -u $(REPO_ROOT)/src-tauri/icons/128x128.png $(EDITOR)/assets/icons/ 2>/dev/null || true
@cp -u $(REPO_ROOT)/src-tauri/icons/icon.png $(EDITOR)/assets/icons/256x256.png 2>/dev/null || true
@echo " Icons ready" @echo " Icons ready"
clean: clean:

View File

@ -34,4 +34,6 @@ vim "$CHANGELOG"
# Commit and push # Commit and push
git add "$CARGO_TOML" "$CHANGELOG" git add "$CARGO_TOML" "$CHANGELOG"
git commit -m "Release v${new_version}" git commit -m "Release v${new_version}"
git push --force origin "$(git branch --show-current):release" # Push to the 'all' remote so the release branch lands on both GitHub and Gitea.
# CI (GitHub Actions) still triggers via the GitHub pushurl.
git push --force all "$(git branch --show-current):release"

6708
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +0,0 @@
[package]
name = "lightningbeam"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "lightningbeam_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
log = "0.4"
env_logger = "0.11"
# DAW backend integration
daw-backend = { path = "../daw-backend" }
cpal = "0.15"
rtrb = "0.3"
tokio = { version = "1", features = ["sync", "time"] }
# Video decoding
lru = "0.12"
# WebSocket for frame streaming (disable default features to remove tracing, but keep handshake)
tungstenite = { version = "0.20", default-features = false, features = ["handshake"] }
# Native rendering with wgpu
wgpu = "0.19"
winit = "0.29"
pollster = "0.3"
bytemuck = { version = "1.14", features = ["derive"] }
raw-window-handle = "0.6"
image = "0.24"
[target.'cfg(target_os = "macos")'.dependencies]
ffmpeg-next = { version = "8.0", features = ["build"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
ffmpeg-next = "8.0"
[profile.dev]
opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance
[profile.release]
opt-level = 3
lto = true

View File

@ -1,98 +0,0 @@
{
"metadata": {
"name": "Basic Sine",
"description": "Simple sine wave synthesizer with ADSR envelope. Great for learning the basics of subtractive synthesis.",
"author": "Lightningbeam",
"version": 1,
"tags": ["basic", "lead", "mono"]
},
"nodes": [
{
"id": 0,
"node_type": "MidiInput",
"parameters": {},
"position": [100.0, 200.0]
},
{
"id": 1,
"node_type": "MidiToCV",
"parameters": {},
"position": [300.0, 200.0]
},
{
"id": 2,
"node_type": "Oscillator",
"parameters": {
"0": 440.0,
"1": 0.7,
"2": 0.0
},
"position": [500.0, 150.0]
},
{
"id": 3,
"node_type": "ADSR",
"parameters": {
"0": 0.01,
"1": 0.1,
"2": 0.7,
"3": 0.3
},
"position": [500.0, 300.0]
},
{
"id": 4,
"node_type": "Gain",
"parameters": {
"0": 1.0
},
"position": [700.0, 200.0]
},
{
"id": 5,
"node_type": "AudioOutput",
"parameters": {},
"position": [900.0, 200.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 3,
"to_port": 0
},
{
"from_node": 2,
"from_port": 0,
"to_node": 4,
"to_port": 0
},
{
"from_node": 3,
"from_port": 0,
"to_node": 4,
"to_port": 1
},
{
"from_node": 4,
"from_port": 0,
"to_node": 5,
"to_port": 0
}
],
"midi_targets": [0],
"output_node": 5
}

View File

@ -1,137 +0,0 @@
{
"metadata": {
"name": "Pluck",
"description": "Percussive pluck sound with fast attack and decay. Great for arpeggios, melodies, and rhythmic patterns.",
"author": "Lightningbeam",
"version": 1,
"tags": ["pluck", "lead", "percussive", "arpeggio"]
},
"nodes": [
{
"id": 0,
"node_type": "MidiInput",
"parameters": {},
"position": [100.0, 250.0]
},
{
"id": 1,
"node_type": "MidiToCV",
"parameters": {},
"position": [300.0, 250.0]
},
{
"id": 2,
"node_type": "Oscillator",
"parameters": {
"0": 440.0,
"1": 0.6,
"2": 2.0
},
"position": [500.0, 150.0]
},
{
"id": 3,
"node_type": "Filter",
"parameters": {
"0": 2000.0,
"1": 0.8,
"2": 0.0
},
"position": [700.0, 150.0]
},
{
"id": 4,
"node_type": "ADSR",
"parameters": {
"0": 0.001,
"1": 0.3,
"2": 0.0,
"3": 0.05
},
"position": [500.0, 350.0]
},
{
"id": 5,
"node_type": "ADSR",
"parameters": {
"0": 0.001,
"1": 0.4,
"2": 0.0,
"3": 0.1
},
"position": [700.0, 350.0]
},
{
"id": 6,
"node_type": "Gain",
"parameters": {
"0": 1.0
},
"position": [900.0, 200.0]
},
{
"id": 7,
"node_type": "AudioOutput",
"parameters": {},
"position": [1100.0, 200.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 4,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 5,
"to_port": 0
},
{
"from_node": 2,
"from_port": 0,
"to_node": 3,
"to_port": 0
},
{
"from_node": 4,
"from_port": 0,
"to_node": 3,
"to_port": 1
},
{
"from_node": 3,
"from_port": 0,
"to_node": 6,
"to_port": 0
},
{
"from_node": 5,
"from_port": 0,
"to_node": 6,
"to_port": 1
},
{
"from_node": 6,
"from_port": 0,
"to_node": 7,
"to_port": 0
}
],
"midi_targets": [0],
"output_node": 7
}

View File

@ -1,145 +0,0 @@
{
"metadata": {
"name": "Poly Synth",
"description": "8-voice polyphonic synthesizer with sawtooth oscillator and ADSR envelope. Perfect for chords and complex harmonies.",
"author": "Lightningbeam",
"version": 1,
"tags": ["poly", "polyphonic", "synth", "chords"]
},
"nodes": [
{
"id": 0,
"node_type": "MidiInput",
"parameters": {},
"position": [100.0, 200.0]
},
{
"id": 1,
"node_type": "VoiceAllocator",
"parameters": {
"0": 8.0
},
"position": [400.0, 200.0],
"template_graph": {
"metadata": {
"name": "template",
"description": "",
"author": "",
"version": 1,
"tags": []
},
"nodes": [
{
"id": 0,
"node_type": "TemplateInput",
"parameters": {},
"position": [100.0, 200.0]
},
{
"id": 1,
"node_type": "MidiToCV",
"parameters": {},
"position": [300.0, 200.0]
},
{
"id": 2,
"node_type": "Oscillator",
"parameters": {
"0": 440.0,
"1": 0.7,
"2": 1.0
},
"position": [500.0, 150.0]
},
{
"id": 3,
"node_type": "ADSR",
"parameters": {
"0": 0.01,
"1": 0.2,
"2": 0.6,
"3": 0.3
},
"position": [500.0, 300.0]
},
{
"id": 4,
"node_type": "Gain",
"parameters": {
"0": 1.0
},
"position": [700.0, 200.0]
},
{
"id": 5,
"node_type": "TemplateOutput",
"parameters": {},
"position": [900.0, 200.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 3,
"to_port": 0
},
{
"from_node": 2,
"from_port": 0,
"to_node": 4,
"to_port": 0
},
{
"from_node": 3,
"from_port": 0,
"to_node": 4,
"to_port": 1
},
{
"from_node": 4,
"from_port": 0,
"to_node": 5,
"to_port": 0
}
],
"midi_targets": [],
"output_node": 5
}
},
{
"id": 2,
"node_type": "AudioOutput",
"parameters": {},
"position": [700.0, 200.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
}
],
"midi_targets": [0],
"output_node": 2
}

View File

@ -1,137 +0,0 @@
{
"metadata": {
"name": "Sawtooth Bass",
"description": "Classic analog-style bass synth with sawtooth oscillator and resonant lowpass filter. Perfect for electronic music basslines.",
"author": "Lightningbeam",
"version": 1,
"tags": ["bass", "analog", "electronic", "mono"]
},
"nodes": [
{
"id": 0,
"node_type": "MidiInput",
"parameters": {},
"position": [100.0, 250.0]
},
{
"id": 1,
"node_type": "MidiToCV",
"parameters": {},
"position": [300.0, 250.0]
},
{
"id": 2,
"node_type": "Oscillator",
"parameters": {
"0": 110.0,
"1": 0.8,
"2": 1.0
},
"position": [500.0, 150.0]
},
{
"id": 3,
"node_type": "Filter",
"parameters": {
"0": 800.0,
"1": 2.5,
"2": 0.0
},
"position": [700.0, 150.0]
},
{
"id": 4,
"node_type": "ADSR",
"parameters": {
"0": 0.005,
"1": 0.2,
"2": 0.3,
"3": 0.1
},
"position": [500.0, 300.0]
},
{
"id": 5,
"node_type": "ADSR",
"parameters": {
"0": 0.005,
"1": 0.15,
"2": 0.6,
"3": 0.2
},
"position": [700.0, 350.0]
},
{
"id": 6,
"node_type": "Gain",
"parameters": {
"0": 1.2
},
"position": [900.0, 200.0]
},
{
"id": 7,
"node_type": "AudioOutput",
"parameters": {},
"position": [1100.0, 200.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 4,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 5,
"to_port": 0
},
{
"from_node": 2,
"from_port": 0,
"to_node": 3,
"to_port": 0
},
{
"from_node": 4,
"from_port": 0,
"to_node": 3,
"to_port": 1
},
{
"from_node": 3,
"from_port": 0,
"to_node": 6,
"to_port": 0
},
{
"from_node": 5,
"from_port": 0,
"to_node": 6,
"to_port": 1
},
{
"from_node": 6,
"from_port": 0,
"to_node": 7,
"to_port": 0
}
],
"midi_targets": [0],
"output_node": 7
}

View File

@ -1,176 +0,0 @@
{
"metadata": {
"name": "Warm Pad",
"description": "Lush pad sound combining sawtooth and triangle waves with slow filter sweep and gentle attack. Ideal for ambient and cinematic music.",
"author": "Lightningbeam",
"version": 1,
"tags": ["pad", "ambient", "warm", "cinematic"]
},
"nodes": [
{
"id": 0,
"node_type": "MidiInput",
"parameters": {},
"position": [100.0, 300.0]
},
{
"id": 1,
"node_type": "MidiToCV",
"parameters": {},
"position": [300.0, 300.0]
},
{
"id": 2,
"node_type": "Oscillator",
"parameters": {
"0": 440.0,
"1": 0.5,
"2": 1.0
},
"position": [500.0, 150.0]
},
{
"id": 3,
"node_type": "Oscillator",
"parameters": {
"0": 440.0,
"1": 0.4,
"2": 3.0
},
"position": [500.0, 250.0]
},
{
"id": 4,
"node_type": "Mixer",
"parameters": {
"0": 0.5,
"1": 0.5,
"2": 0.0,
"3": 0.0
},
"position": [700.0, 200.0]
},
{
"id": 5,
"node_type": "Filter",
"parameters": {
"0": 1200.0,
"1": 1.0,
"2": 0.0
},
"position": [900.0, 200.0]
},
{
"id": 6,
"node_type": "ADSR",
"parameters": {
"0": 0.8,
"1": 1.0,
"2": 0.6,
"3": 1.5
},
"position": [700.0, 400.0]
},
{
"id": 7,
"node_type": "ADSR",
"parameters": {
"0": 0.5,
"1": 0.5,
"2": 0.8,
"3": 1.0
},
"position": [900.0, 400.0]
},
{
"id": 8,
"node_type": "Gain",
"parameters": {
"0": 0.8
},
"position": [1100.0, 250.0]
},
{
"id": 9,
"node_type": "AudioOutput",
"parameters": {},
"position": [1300.0, 250.0]
}
],
"connections": [
{
"from_node": 0,
"from_port": 0,
"to_node": 1,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 2,
"to_port": 0
},
{
"from_node": 1,
"from_port": 0,
"to_node": 3,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 6,
"to_port": 0
},
{
"from_node": 1,
"from_port": 1,
"to_node": 7,
"to_port": 0
},
{
"from_node": 2,
"from_port": 0,
"to_node": 4,
"to_port": 0
},
{
"from_node": 3,
"from_port": 0,
"to_node": 4,
"to_port": 1
},
{
"from_node": 4,
"from_port": 0,
"to_node": 5,
"to_port": 0
},
{
"from_node": 6,
"from_port": 0,
"to_node": 5,
"to_port": 1
},
{
"from_node": 5,
"from_port": 0,
"to_node": 8,
"to_port": 0
},
{
"from_node": 7,
"from_port": 0,
"to_node": 8,
"to_port": 1
},
{
"from_node": 8,
"from_port": 0,
"to_node": 9,
"to_port": 0
}
],
"midi_targets": [0],
"output_node": 9
}

View File

@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@ -1,77 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main",
"window*"
],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-set-title",
"shell:allow-open",
"fs:default",
{
"identifier": "fs:allow-exists",
"allow": [
{
"path": "$HOME/*"
},
{
"path": "$DOCUMENT/*"
},
{
"path": "$DOWNLOAD/*"
},
{
"path": "$DESKTOP/*"
},
{
"path": "**/*"
}
]
},
{
"identifier": "fs:allow-app-write-recursive",
"allow": [
{
"path": "$HOME/*"
},
{
"path": "$DOCUMENT/*"
},
{
"path": "$DOWNLOAD/*"
},
{
"path": "$DESKTOP/*"
},
{
"path": "**/*"
}
]
},
{
"identifier": "fs:allow-app-read-recursive",
"allow": [
{
"path": "$HOME/*"
},
{
"path": "$DOCUMENT/*"
},
{
"path": "$DOWNLOAD/*"
},
{
"path": "$DESKTOP/*"
},
{
"path": "**/*"
}
]
},
"dialog:default"
]
}

View File

@ -1,104 +0,0 @@
extern crate ffmpeg_next as ffmpeg;
use std::env;
fn main() {
ffmpeg::init().unwrap();
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <video_file>", args[0]);
std::process::exit(1);
}
let path = &args[1];
let input = ffmpeg::format::input(path).expect("Failed to open video");
println!("=== VIDEO FILE INFORMATION ===");
println!("File: {}", path);
println!("Format: {}", input.format().name());
println!("Duration: {:.2}s", input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE));
println!();
let video_stream = input.streams()
.best(ffmpeg::media::Type::Video)
.expect("No video stream found");
let stream_index = video_stream.index();
let time_base = f64::from(video_stream.time_base());
let duration = video_stream.duration() as f64 * time_base;
let fps = f64::from(video_stream.avg_frame_rate());
println!("=== VIDEO STREAM ===");
println!("Stream index: {}", stream_index);
println!("Time base: {} ({:.10})", video_stream.time_base(), time_base);
println!("Duration: {:.2}s", duration);
println!("FPS: {:.2}", fps);
println!("Frames: {}", video_stream.frames());
let context = ffmpeg::codec::context::Context::from_parameters(video_stream.parameters())
.expect("Failed to create context");
let decoder = context.decoder().video().expect("Failed to create decoder");
println!("Codec: {:?}", decoder.id());
println!("Resolution: {}x{}", decoder.width(), decoder.height());
println!("Pixel format: {:?}", decoder.format());
println!();
println!("=== SCANNING FRAMES ===");
println!("Timestamp (ts) | Time (s) | Key | Type");
println!("---------------|----------|-----|-----");
let mut input = ffmpeg::format::input(path).expect("Failed to reopen video");
let context = ffmpeg::codec::context::Context::from_parameters(
input.streams().best(ffmpeg::media::Type::Video).unwrap().parameters()
).expect("Failed to create context");
let mut decoder = context.decoder().video().expect("Failed to create decoder");
let mut frame_count = 0;
let mut keyframe_count = 0;
for (stream, packet) in input.packets() {
if stream.index() == stream_index {
let packet_pts = packet.pts().unwrap_or(0);
let packet_time = packet_pts as f64 * time_base;
let is_key = packet.is_key();
if is_key {
keyframe_count += 1;
}
// Print first 50 packets and all keyframes
if frame_count < 50 || is_key {
println!("{:14} | {:8.2} | {:3} | {:?}",
packet_pts,
packet_time,
if is_key { "KEY" } else { " " },
if is_key { "I-frame" } else { "P/B-frame" }
);
}
decoder.send_packet(&packet).ok();
let mut frame = ffmpeg::util::frame::Video::empty();
while decoder.receive_frame(&mut frame).is_ok() {
frame_count += 1;
}
}
}
// Flush decoder
decoder.send_eof().ok();
let mut frame = ffmpeg::util::frame::Video::empty();
while decoder.receive_frame(&mut frame).is_ok() {
frame_count += 1;
}
println!();
println!("=== SUMMARY ===");
println!("Total frames decoded: {}", frame_count);
println!("Total keyframes: {}", keyframe_count);
if keyframe_count > 0 {
println!("Average keyframe interval: {:.2} frames", frame_count as f64 / keyframe_count as f64);
println!("Average keyframe interval: {:.2}s", duration / keyframe_count as f64);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::thread;
use tungstenite::{accept, Message};
pub struct FrameStreamer {
port: u16,
clients: Arc<Mutex<Vec<tungstenite::WebSocket<std::net::TcpStream>>>>,
}
impl FrameStreamer {
pub fn new() -> Result<Self, String> {
// Bind to localhost on a random available port
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("Failed to create WebSocket listener: {}", e))?;
let port = listener.local_addr()
.map_err(|e| format!("Failed to get listener address: {}", e))?
.port();
// eprintln!("[Frame Streamer] WebSocket server started on port {}", port);
let clients = Arc::new(Mutex::new(Vec::new()));
let clients_clone = clients.clone();
// Spawn acceptor thread
thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
// eprintln!("[Frame Streamer] New WebSocket connection from {:?}", stream.peer_addr());
match accept(stream) {
Ok(websocket) => {
let mut clients = clients_clone.lock().unwrap();
clients.push(websocket);
// eprintln!("[Frame Streamer] Client connected, total clients: {}", clients.len());
}
Err(_e) => {
// eprintln!("[Frame Streamer] Failed to accept WebSocket: {}", e);
}
}
}
Err(_e) => {
// eprintln!("[Frame Streamer] Failed to accept connection: {}", e);
}
}
}
});
Ok(Self {
port,
clients,
})
}
pub fn port(&self) -> u16 {
self.port
}
/// Send a decoded frame to all connected clients
/// Frame format: [pool_index: u32][timestamp_ms: u32][width: u32][height: u32][rgba_data...]
pub fn send_frame(&self, pool_index: usize, timestamp: f64, width: u32, height: u32, rgba_data: &[u8]) {
let mut clients = self.clients.lock().unwrap();
// Build frame message (rgba_data is already in RGBA format from decoder)
let mut frame_msg = Vec::with_capacity(16 + rgba_data.len());
frame_msg.extend_from_slice(&(pool_index as u32).to_le_bytes());
frame_msg.extend_from_slice(&((timestamp * 1000.0) as u32).to_le_bytes());
frame_msg.extend_from_slice(&width.to_le_bytes());
frame_msg.extend_from_slice(&height.to_le_bytes());
frame_msg.extend_from_slice(rgba_data);
// Send to all clients, remove disconnected ones
clients.retain_mut(|client| {
match client.write_message(Message::Binary(frame_msg.clone())) {
Ok(_) => true,
Err(_e) => {
// eprintln!("[Frame Streamer] Client disconnected: {}", e);
false
}
}
});
}
}

View File

@ -1,230 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::get,
Router,
};
use flume::{Sender, unbounded};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::Duration;
/// Playback state for a video pool
#[derive(Clone, Debug)]
pub struct PlaybackState {
pub is_playing: bool,
pub target_fps: f64,
pub current_time: f64,
}
/// Shared server state
pub struct ServerState {
/// Connected clients
clients: RwLock<Vec<Sender<Vec<u8>>>>,
/// Playback state per pool index
playback_state: RwLock<HashMap<usize, PlaybackState>>,
}
impl ServerState {
pub fn new() -> Self {
Self {
clients: RwLock::new(Vec::new()),
playback_state: RwLock::new(HashMap::new()),
}
}
/// Register a new client
pub async fn add_client(&self, sender: Sender<Vec<u8>>) {
let mut clients = self.clients.write().await;
clients.push(sender);
eprintln!("[Async Frame Streamer] Client registered, total: {}", clients.len());
}
/// Broadcast a frame to all connected clients
pub async fn broadcast_frame(&self, frame_data: Vec<u8>) {
let clients = self.clients.read().await;
// Send to all clients
for client in clients.iter() {
// Non-blocking send, drop frame if client is slow
let _ = client.try_send(frame_data.clone());
}
}
/// Remove disconnected clients
pub async fn cleanup_clients(&self) {
let mut clients = self.clients.write().await;
clients.retain(|client| !client.is_disconnected());
eprintln!("[Async Frame Streamer] Cleaned up clients, remaining: {}", clients.len());
}
/// Update playback state for a pool
pub async fn set_playback_state(&self, pool_index: usize, state: PlaybackState) {
let mut states = self.playback_state.write().await;
states.insert(pool_index, state);
}
/// Get playback state for a pool
pub async fn get_playback_state(&self, pool_index: usize) -> Option<PlaybackState> {
let states = self.playback_state.read().await;
states.get(&pool_index).cloned()
}
}
pub struct AsyncFrameStreamer {
port: u16,
state: Arc<ServerState>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl AsyncFrameStreamer {
pub async fn new() -> Result<Self, String> {
let state = Arc::new(ServerState::new());
// Create router with WebSocket upgrade handler
let app_state = state.clone();
let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(app_state);
// Bind to localhost on a random port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind: {}", e))?;
let port = listener
.local_addr()
.map_err(|e| format!("Failed to get address: {}", e))?
.port();
eprintln!("[Async Frame Streamer] WebSocket server starting on port {}", port);
// Spawn server task
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await
.expect("Server error");
});
eprintln!("[Async Frame Streamer] Server started");
Ok(Self {
port,
state,
shutdown_tx: Some(shutdown_tx),
})
}
pub fn port(&self) -> u16 {
self.port
}
/// Send a frame to all connected clients for a specific pool
/// Frame format: [pool_index: u32][timestamp_ms: u32][width: u32][height: u32][rgba_data...]
pub async fn send_frame(&self, pool_index: usize, timestamp: f64, width: u32, height: u32, rgba_data: &[u8]) {
// Build frame message
let mut frame_msg = Vec::with_capacity(16 + rgba_data.len());
frame_msg.extend_from_slice(&(pool_index as u32).to_le_bytes());
frame_msg.extend_from_slice(&((timestamp * 1000.0) as u32).to_le_bytes());
frame_msg.extend_from_slice(&width.to_le_bytes());
frame_msg.extend_from_slice(&height.to_le_bytes());
frame_msg.extend_from_slice(rgba_data);
// Broadcast to all connected clients
self.state.broadcast_frame(frame_msg).await;
}
/// Start streaming frames for a pool at a target FPS
pub async fn start_stream(&self, pool_index: usize, fps: f64) {
let state = PlaybackState {
is_playing: true,
target_fps: fps,
current_time: 0.0,
};
self.state.set_playback_state(pool_index, state).await;
eprintln!("[Async Frame Streamer] Started streaming pool {} at {} FPS", pool_index, fps);
}
/// Stop streaming frames for a pool
pub async fn stop_stream(&self, pool_index: usize) {
if let Some(mut state) = self.state.get_playback_state(pool_index).await {
state.is_playing = false;
self.state.set_playback_state(pool_index, state).await;
eprintln!("[Async Frame Streamer] Stopped streaming pool {}", pool_index);
}
}
/// Seek to a specific time in a pool
pub async fn seek(&self, pool_index: usize, timestamp: f64) {
if let Some(mut state) = self.state.get_playback_state(pool_index).await {
state.current_time = timestamp;
self.state.set_playback_state(pool_index, state).await;
}
}
}
impl Drop for AsyncFrameStreamer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
/// WebSocket handler
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<ServerState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
/// Handle individual WebSocket connection
async fn handle_socket(mut socket: WebSocket, state: Arc<ServerState>) {
eprintln!("[Async Frame Streamer] New WebSocket connection");
// Create a channel for this client
let (tx, rx) = unbounded::<Vec<u8>>();
// Register this client
state.add_client(tx).await;
// Spawn task to send frames to this client
let mut rx = rx;
let mut send_task = tokio::spawn(async move {
while let Ok(frame) = rx.recv_async().await {
if socket.send(Message::Binary(frame)).await.is_err() {
eprintln!("[Async Frame Streamer] Failed to send frame to client");
break;
}
}
eprintln!("[Async Frame Streamer] Send task ended");
});
// Keep connection alive with ping/pong
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
// Connection alive, no need to ping in this simple implementation
}
_ = &mut send_task => {
eprintln!("[Async Frame Streamer] Send task completed, closing connection");
break;
}
}
}
// Cleanup
state.cleanup_clients().await;
}

View File

@ -1,346 +0,0 @@
use std::{path::PathBuf, sync::{Arc, Mutex}};
use tauri_plugin_log::{Target, TargetKind};
use log::{trace, info, debug, warn, error};
use tracing_subscriber::EnvFilter;
use chrono::Local;
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder};
mod audio;
mod video;
mod video_server;
#[derive(Default)]
struct AppState {
counter: u32,
}
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
fn trace(msg: String) {
trace!("{}",msg);
}
#[tauri::command]
fn info(msg: String) {
info!("{}",msg);
}
#[tauri::command]
fn debug(msg: String) {
debug!("{}",msg);
}
#[tauri::command]
fn warn(msg: String) {
warn!("{}",msg);
}
#[tauri::command]
fn error(msg: String) {
error!("{}",msg);
}
#[tauri::command]
async fn open_folder_dialog(app: AppHandle, title: String) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let folder = app.dialog()
.file()
.set_title(&title)
.blocking_pick_folder();
Ok(folder.map(|path| path.to_string()))
}
#[tauri::command]
async fn read_folder_files(path: String) -> Result<Vec<String>, String> {
use std::fs;
let entries = fs::read_dir(&path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let audio_extensions = vec!["wav", "aif", "aiff", "flac", "mp3", "ogg"];
let mut files = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if audio_extensions.contains(&ext_str.as_str()) {
if let Some(filename) = path.file_name() {
files.push(filename.to_string_lossy().to_string());
}
}
}
}
}
Ok(files)
}
use tauri::PhysicalSize;
#[tauri::command]
async fn create_window(app: tauri::AppHandle, path: Option<String>) {
let state = app.state::<Mutex<AppState>>();
// Lock the mutex to get mutable access:
let mut state = state.lock().unwrap();
// Increment the counter and generate a unique window label
let window_label = format!("window{}", state.counter);
state.counter += 1;
// Build the new window with the unique label
let webview_window = WebviewWindowBuilder::new(&app, &window_label, WebviewUrl::App("index.html".into()))
.title("Lightningbeam")
.build()
.unwrap();
// Get the current monitor's screen size from the new window
if let Ok(Some(monitor)) = webview_window.current_monitor() {
let screen_size = monitor.size(); // Get the size of the monitor
let width = 4096;
let height = 4096;
// Set the window size to be the smaller of the specified size or the screen size
let new_width = width.min(screen_size.width as u32);
let new_height = height.min(screen_size.height as u32 - 100);
// Set the size using PhysicalSize
webview_window.set_size(tauri::Size::Physical(PhysicalSize::new(new_width, new_height)))
.expect("Failed to set window size");
} else {
eprintln!("Could not detect the current monitor.");
}
// Set the opened file if provided
if let Some(val) = path {
// Pass path data to the window via JavaScript
webview_window.eval(&format!("window.openedFiles = [\"{val}\"]")).unwrap();
// Set the window title if provided
webview_window.set_title(&val).expect("Failed to set window title");
}
}
fn handle_file_associations(app: AppHandle, files: Vec<PathBuf>) {
// -- Scope handling start --
// You can remove this block if you only want to know about the paths, but not actually "use" them in the frontend.
// This requires the `fs` tauri plugin and is required to make the plugin's frontend work:
use tauri_plugin_fs::FsExt;
let fs_scope = app.fs_scope();
// This is for the `asset:` protocol to work:
let asset_protocol_scope = app.asset_protocol_scope();
for file in &files {
// This requires the `fs` plugin:
let _ = fs_scope.allow_file(file);
// This is for the `asset:` protocol:
let _ = asset_protocol_scope.allow_file(file);
}
// -- Scope handling end --
let files = files
.into_iter()
.map(|f| {
let file = f.to_string_lossy().replace('\\', "\\\\"); // escape backslash
format!("\"{file}\"",) // wrap in quotes for JS array
})
.collect::<Vec<_>>()
.join(",");
warn!("{}",files);
let window = app.get_webview_window("main").unwrap();
window.eval(&format!("window.openedFiles = [{files}]")).unwrap();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let pkg_name = env!("CARGO_PKG_NAME").to_string();
// Initialize video HTTP server
let video_server = video_server::VideoServer::new()
.expect("Failed to start video server");
eprintln!("[App] Video server started on port {}", video_server.port());
tauri::Builder::default()
.manage(Mutex::new(AppState::default()))
.manage(Arc::new(Mutex::new(audio::AudioState::default())))
.manage(Arc::new(Mutex::new(video::VideoState::default())))
.manage(Arc::new(Mutex::new(video_server)))
.setup(|app| {
#[cfg(any(windows, target_os = "linux"))] // Windows/Linux needs different handling from macOS
{
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may also be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with('-') {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(&maybe_file) {
// if let Ok(url) = url::Url::parse(&maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
handle_file_associations(app.handle().clone(), files);
}
#[cfg(debug_assertions)] // only include this code on debug builds
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
window.close_devtools();
}
Ok(())
})
.plugin(
tauri_plugin_log::Builder::new()
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
.format(|out, message, record| {
let date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
out.finish(format_args!(
"{}[{}] {}",
date,
record.level(),
message
))
})
.targets([
Target::new(TargetKind::Stdout),
// LogDir locations:
// Linux: /home/user/.local/share/org.lightningbeam.core/logs
// macOS: /Users/user/Library/Logs/org.lightningbeam.core/logs
// Windows: C:\Users\user\AppData\Local\org.lightningbeam.core\logs
Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()) }),
Target::new(TargetKind::Webview),
])
.build()
)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
greet, trace, debug, info, warn, error, create_window,
audio::audio_init,
audio::audio_reset,
audio::audio_play,
audio::audio_stop,
audio::set_metronome_enabled,
audio::audio_seek,
audio::audio_test_beep,
audio::audio_set_track_parameter,
audio::audio_create_track,
audio::audio_load_file,
audio::audio_add_clip,
audio::audio_move_clip,
audio::audio_trim_clip,
audio::audio_extend_clip,
audio::audio_start_recording,
audio::audio_stop_recording,
audio::audio_pause_recording,
audio::audio_resume_recording,
audio::audio_start_midi_recording,
audio::audio_stop_midi_recording,
audio::audio_create_midi_clip,
audio::audio_add_midi_note,
audio::audio_load_midi_file,
audio::audio_get_midi_clip_data,
audio::audio_update_midi_clip_notes,
audio::audio_send_midi_note_on,
audio::audio_send_midi_note_off,
audio::audio_set_active_midi_track,
audio::audio_get_pool_file_info,
audio::audio_get_pool_waveform,
audio::graph_add_node,
audio::graph_add_node_to_template,
audio::graph_remove_node,
audio::graph_connect,
audio::graph_connect_in_template,
audio::graph_disconnect,
audio::graph_set_parameter,
audio::graph_set_output_node,
audio::graph_save_preset,
audio::graph_load_preset,
audio::graph_load_preset_from_json,
audio::graph_list_presets,
audio::graph_delete_preset,
audio::graph_get_state,
audio::graph_get_template_state,
audio::sampler_load_sample,
audio::multi_sampler_add_layer,
audio::multi_sampler_get_layers,
audio::multi_sampler_update_layer,
audio::multi_sampler_remove_layer,
audio::get_oscilloscope_data,
audio::automation_add_keyframe,
audio::automation_remove_keyframe,
audio::automation_get_keyframes,
audio::automation_set_name,
audio::automation_get_name,
audio::audio_serialize_pool,
audio::audio_load_pool,
audio::audio_resolve_missing_file,
audio::audio_serialize_track_graph,
audio::audio_load_track_graph,
audio::audio_export,
video::video_load_file,
video::video_get_frame,
video::video_get_frames_batch,
video::video_set_cache_size,
open_folder_dialog,
read_folder_files,
video::video_get_pool_info,
video::video_ipc_benchmark,
video::video_get_transcode_status,
video::video_allow_asset,
])
// .manage(window_counter)
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(
#[allow(unused_variables)]
|app, event| {
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = event {
let app = app.clone();
let files = urls
.into_iter()
.filter_map(|url| url.to_file_path().ok())
.map(|f| {
let file = f.to_string_lossy().replace('\\', "\\\\"); // escape backslash
format!("\"{file}\"",) // wrap in quotes for JS array
})
.collect::<Vec<_>>();
tauri::async_runtime::spawn(async move {
for path in files {
create_window(app.clone(), Some(path)).await;
}
});
}
},
);
tracing_subscriber::fmt().with_env_filter(EnvFilter::new(format!("{}=trace", pkg_name))).init();
}

View File

@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
lightningbeam_lib::run();
}

View File

@ -1,161 +0,0 @@
use std::sync::Arc;
use std::thread;
use winit::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy},
window::WindowBuilder,
};
#[cfg(target_os = "linux")]
use winit::platform::x11::EventLoopBuilderExtX11;
use crate::renderer::Renderer;
/// Events that can be sent to the render window thread
#[derive(Debug, Clone)]
pub enum RenderEvent {
UpdateGradient { top: [f32; 4], bottom: [f32; 4] },
SetPosition { x: i32, y: i32 },
SetSize { width: u32, height: u32 },
RequestRedraw,
Close,
}
/// Handle to control the render window from other threads
pub struct RenderWindowHandle {
proxy: EventLoopProxy<RenderEvent>,
}
impl RenderWindowHandle {
/// Update the gradient colors
pub fn update_gradient(&self, top: [f32; 4], bottom: [f32; 4]) {
let _ = self.proxy.send_event(RenderEvent::UpdateGradient { top, bottom });
}
/// Set window position
pub fn set_position(&self, x: i32, y: i32) {
let _ = self.proxy.send_event(RenderEvent::SetPosition { x, y });
}
/// Set window size
pub fn set_size(&self, width: u32, height: u32) {
let _ = self.proxy.send_event(RenderEvent::SetSize { width, height });
}
/// Request a redraw
pub fn request_redraw(&self) {
let _ = self.proxy.send_event(RenderEvent::RequestRedraw);
}
/// Close the render window
pub fn close(&self) {
let _ = self.proxy.send_event(RenderEvent::Close);
}
}
/// Spawn the render window in a separate thread
pub fn spawn_render_window(
x: i32,
y: i32,
width: u32,
height: u32,
) -> Result<RenderWindowHandle, String> {
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let mut event_loop_builder = EventLoopBuilder::with_user_event();
// On Linux, allow event loop on any thread (not just main thread)
#[cfg(target_os = "linux")]
{
event_loop_builder.with_any_thread(true);
}
let event_loop: EventLoop<RenderEvent> = event_loop_builder.build().unwrap();
let proxy = event_loop.create_proxy();
// Send the proxy back to the main thread
tx.send(proxy.clone()).unwrap();
let window = WindowBuilder::new()
.with_title("Lightningbeam Renderer")
.with_inner_size(winit::dpi::PhysicalSize::new(width, height))
.with_position(winit::dpi::PhysicalPosition::new(x, y))
.with_decorations(false) // No title bar
.with_transparent(false) // Opaque background
.with_resizable(false)
.build(&event_loop)
.unwrap();
let window = Arc::new(window);
// Initialize renderer (async operation)
let mut renderer = pollster::block_on(Renderer::new(window.clone()));
event_loop.run(move |event, elwt| {
elwt.set_control_flow(ControlFlow::Wait);
match event {
Event::UserEvent(render_event) => match render_event {
RenderEvent::UpdateGradient { top, bottom } => {
eprintln!("[RenderWindow] Updating gradient: {:?} -> {:?}", top, bottom);
renderer.update_gradient(top, bottom);
window.request_redraw();
}
RenderEvent::SetPosition { x, y } => {
eprintln!("[RenderWindow] Setting position: ({}, {})", x, y);
let _ = window.set_outer_position(winit::dpi::PhysicalPosition::new(x, y));
}
RenderEvent::SetSize { width, height } => {
eprintln!("[RenderWindow] Setting size: {}x{}", width, height);
let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(width, height));
}
RenderEvent::RequestRedraw => {
window.request_redraw();
}
RenderEvent::Close => {
eprintln!("[RenderWindow] Closing render window");
elwt.exit();
}
},
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
elwt.exit();
}
Event::WindowEvent {
event: WindowEvent::Resized(physical_size),
..
} => {
renderer.resize(physical_size);
window.request_redraw();
}
Event::WindowEvent {
event: WindowEvent::RedrawRequested,
..
} => {
match renderer.render() {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost) => renderer.resize(window.inner_size()),
Err(wgpu::SurfaceError::OutOfMemory) => {
eprintln!("Out of memory!");
elwt.exit();
}
Err(e) => eprintln!("Render error: {:?}", e),
}
}
_ => {}
}
}).expect("Event loop error");
});
// Wait for the proxy to be sent back
let proxy = rx.recv().map_err(|e| format!("Failed to receive proxy: {}", e))?;
Ok(RenderWindowHandle { proxy })
}

View File

@ -1,293 +0,0 @@
use std::sync::Arc;
use wgpu::util::DeviceExt;
/// Vertex data for rendering
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 2],
color: [f32; 4],
}
impl Vertex {
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
// Position
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
},
// Color
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
],
}
}
}
/// Main renderer state that manages the wgpu rendering pipeline
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
size: winit::dpi::PhysicalSize<u32>,
render_pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
num_vertices: u32,
}
impl Renderer {
/// Create a new renderer for the given window
pub async fn new(window: Arc<winit::window::Window>) -> Self {
let size = window.inner_size();
// Create wgpu instance
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
// Create surface from window
let surface = instance.create_surface(window.clone()).unwrap();
// Request adapter
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.unwrap();
// Request device and queue
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("Lightningbeam Render Device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
},
None,
)
.await
.unwrap();
// Configure surface
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width,
height: size.height,
present_mode: wgpu::PresentMode::Fifo, // VSync
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
// Create shader module
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Gradient Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
});
// Create render pipeline
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Render Pipeline Layout"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Render Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
buffers: &[Vertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview: None,
});
// Create initial gradient vertices (two triangles forming a quad)
let vertices = Self::create_gradient_vertices();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Vertex Buffer"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
Self {
surface,
device,
queue,
config,
size,
render_pipeline,
vertex_buffer,
num_vertices: vertices.len() as u32,
}
}
/// Create vertices for a gradient quad covering the entire viewport
fn create_gradient_vertices() -> Vec<Vertex> {
vec![
// First triangle
Vertex {
position: [-1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
Vertex {
position: [-1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
Vertex {
position: [1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
// Second triangle
Vertex {
position: [-1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
Vertex {
position: [1.0, -1.0],
color: [0.6, 0.2, 0.8, 1.0], // Purple at bottom
},
Vertex {
position: [1.0, 1.0],
color: [0.2, 0.3, 0.8, 1.0], // Blue at top
},
]
}
/// Resize the renderer (call when window is resized)
pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
}
}
/// Render a frame
pub fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
let output = self.surface.get_current_texture()?;
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Render Encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.1,
g: 0.1,
b: 0.1,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.draw(0..self.num_vertices, 0..1);
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
Ok(())
}
/// Update gradient colors (for future customization)
pub fn update_gradient(&mut self, color_top: [f32; 4], color_bottom: [f32; 4]) {
let vertices = vec![
Vertex {
position: [-1.0, 1.0],
color: color_top,
},
Vertex {
position: [-1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [-1.0, 1.0],
color: color_top,
},
Vertex {
position: [1.0, -1.0],
color: color_bottom,
},
Vertex {
position: [1.0, 1.0],
color: color_top,
},
];
self.queue
.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices));
}
}

View File

@ -1,26 +0,0 @@
// Vertex shader
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.clip_position = vec4<f32>(input.position, 0.0, 1.0);
output.color = input.color;
return output;
}
// Fragment shader
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return input.color;
}

View File

@ -1,528 +0,0 @@
use std::sync::{Arc, Mutex};
use std::num::NonZeroUsize;
use ffmpeg_next as ffmpeg;
use lru::LruCache;
use daw_backend::WaveformPeak;
use image::RgbaImage;
use tauri::Manager;
#[derive(serde::Serialize, Clone)]
pub struct VideoFileMetadata {
pub pool_index: usize,
pub width: u32,
pub height: u32,
pub fps: f64,
pub duration: f64,
pub has_audio: bool,
pub audio_pool_index: Option<usize>,
pub audio_duration: Option<f64>,
pub audio_sample_rate: Option<u32>,
pub audio_channels: Option<u32>,
pub audio_waveform: Option<Vec<WaveformPeak>>,
}
struct VideoDecoder {
path: String,
width: u32, // Original video width
height: u32, // Original video height
output_width: u32, // Scaled output width
output_height: u32, // Scaled output height
fps: f64,
duration: f64,
time_base: f64,
stream_index: usize,
frame_cache: LruCache<i64, Vec<u8>>, // timestamp -> RGBA data
input: Option<ffmpeg::format::context::Input>,
decoder: Option<ffmpeg::decoder::Video>,
last_decoded_ts: i64, // Track the last decoded frame timestamp
}
impl VideoDecoder {
fn new(path: String, cache_size: usize, max_width: Option<u32>, max_height: Option<u32>) -> Result<Self, String> {
ffmpeg::init().map_err(|e| e.to_string())?;
let input = ffmpeg::format::input(&path)
.map_err(|e| format!("Failed to open video: {}", e))?;
let video_stream = input.streams()
.best(ffmpeg::media::Type::Video)
.ok_or("No video stream found")?;
let stream_index = video_stream.index();
let context_decoder = ffmpeg::codec::context::Context::from_parameters(
video_stream.parameters()
).map_err(|e| e.to_string())?;
let decoder = context_decoder.decoder().video()
.map_err(|e| e.to_string())?;
let width = decoder.width();
let height = decoder.height();
let time_base = f64::from(video_stream.time_base());
// Calculate output dimensions (scale down if larger than max)
let (output_width, output_height) = if let (Some(max_w), Some(max_h)) = (max_width, max_height) {
// Calculate scale to fit within max dimensions while preserving aspect ratio
let scale = (max_w as f32 / width as f32).min(max_h as f32 / height as f32).min(1.0);
((width as f32 * scale) as u32, (height as f32 * scale) as u32)
} else {
(width, height)
};
// Try to get duration from stream, fallback to container
let duration = if video_stream.duration() > 0 {
video_stream.duration() as f64 * time_base
} else if input.duration() > 0 {
input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE)
} else {
// If no duration available, estimate from frame count and fps
let fps = f64::from(video_stream.avg_frame_rate());
if video_stream.frames() > 0 && fps > 0.0 {
video_stream.frames() as f64 / fps
} else {
0.0 // Unknown duration
}
};
let fps = f64::from(video_stream.avg_frame_rate());
Ok(Self {
path,
width,
height,
output_width,
output_height,
fps,
duration,
time_base,
stream_index,
frame_cache: LruCache::new(
NonZeroUsize::new(cache_size).unwrap()
),
input: None,
decoder: None,
last_decoded_ts: -1,
})
}
fn get_frame(&mut self, timestamp: f64) -> Result<Vec<u8>, String> {
use std::time::Instant;
let t_start = Instant::now();
// Convert timestamp to frame timestamp
let frame_ts = (timestamp / self.time_base) as i64;
// Check cache
if let Some(cached_frame) = self.frame_cache.get(&frame_ts) {
eprintln!("[Video Timing] Cache hit for ts={:.3}s ({}ms)", timestamp, t_start.elapsed().as_millis());
return Ok(cached_frame.clone());
}
let _t_after_cache = Instant::now();
// Determine if we need to seek
// Seek if: no decoder open, going backwards, or jumping forward more than 2 seconds
let need_seek = self.decoder.is_none()
|| frame_ts < self.last_decoded_ts
|| frame_ts > self.last_decoded_ts + (2.0 / self.time_base) as i64;
if need_seek {
let t_seek_start = Instant::now();
// Reopen input
let mut input = ffmpeg::format::input(&self.path)
.map_err(|e| format!("Failed to reopen video: {}", e))?;
// Seek to timestamp
input.seek(frame_ts, ..frame_ts)
.map_err(|e| format!("Seek failed: {}", e))?;
let context_decoder = ffmpeg::codec::context::Context::from_parameters(
input.streams().best(ffmpeg::media::Type::Video).unwrap().parameters()
).map_err(|e| e.to_string())?;
let decoder = context_decoder.decoder().video()
.map_err(|e| e.to_string())?;
self.input = Some(input);
self.decoder = Some(decoder);
self.last_decoded_ts = -1; // Reset since we seeked
eprintln!("[Video Timing] Seek took {}ms", t_seek_start.elapsed().as_millis());
}
let input = self.input.as_mut().unwrap();
let decoder = self.decoder.as_mut().unwrap();
// Decode frames until we find the one closest to our target timestamp
let mut best_frame_data: Option<Vec<u8>> = None;
let mut best_frame_ts: Option<i64> = None;
let t_decode_start = Instant::now();
let mut decode_count = 0;
let mut scale_time_ms = 0u128;
for (stream, packet) in input.packets() {
if stream.index() == self.stream_index {
decoder.send_packet(&packet)
.map_err(|e| e.to_string())?;
let mut frame = ffmpeg::util::frame::Video::empty();
while decoder.receive_frame(&mut frame).is_ok() {
decode_count += 1;
let current_frame_ts = frame.timestamp().unwrap_or(0);
self.last_decoded_ts = current_frame_ts; // Update last decoded position
// Check if this frame is closer to our target than the previous best
let is_better = match best_frame_ts {
None => true,
Some(best_ts) => {
(current_frame_ts - frame_ts).abs() < (best_ts - frame_ts).abs()
}
};
if is_better {
let t_scale_start = Instant::now();
// Convert to RGBA and scale to output size
let mut scaler = ffmpeg::software::scaling::context::Context::get(
frame.format(),
frame.width(),
frame.height(),
ffmpeg::format::Pixel::RGBA,
self.output_width,
self.output_height,
ffmpeg::software::scaling::flag::Flags::BILINEAR,
).map_err(|e| e.to_string())?;
let mut rgb_frame = ffmpeg::util::frame::Video::empty();
scaler.run(&frame, &mut rgb_frame)
.map_err(|e| e.to_string())?;
// Remove stride padding to create tightly packed RGBA data
let width = self.output_width as usize;
let height = self.output_height as usize;
let stride = rgb_frame.stride(0);
let row_size = width * 4; // RGBA = 4 bytes per pixel
let source_data = rgb_frame.data(0);
let mut packed_data = Vec::with_capacity(row_size * height);
for y in 0..height {
let row_start = y * stride;
let row_end = row_start + row_size;
packed_data.extend_from_slice(&source_data[row_start..row_end]);
}
scale_time_ms += t_scale_start.elapsed().as_millis();
best_frame_data = Some(packed_data);
best_frame_ts = Some(current_frame_ts);
}
// If we've reached or passed the target timestamp, we can stop
if current_frame_ts >= frame_ts {
// Found our frame, cache and return it
if let Some(data) = best_frame_data {
let total_time = t_start.elapsed().as_millis();
let decode_time = t_decode_start.elapsed().as_millis();
eprintln!("[Video Timing] ts={:.3}s | Decoded {} frames in {}ms | Scale: {}ms | Total: {}ms",
timestamp, decode_count, decode_time, scale_time_ms, total_time);
self.frame_cache.put(frame_ts, data.clone());
return Ok(data);
}
break;
}
}
}
}
eprintln!("[Video Decoder] ERROR: Failed to decode frame for timestamp {}", timestamp);
Err("Failed to decode frame".to_string())
}
}
pub struct VideoState {
pool: Vec<Arc<Mutex<VideoDecoder>>>,
next_pool_index: usize,
cache_size: usize,
}
impl Default for VideoState {
fn default() -> Self {
Self {
pool: Vec::new(),
next_pool_index: 0,
cache_size: 20, // Default cache size
}
}
}
#[tauri::command]
pub async fn video_load_file(
video_state: tauri::State<'_, Arc<Mutex<VideoState>>>,
audio_state: tauri::State<'_, Arc<Mutex<crate::audio::AudioState>>>,
path: String,
) -> Result<VideoFileMetadata, String> {
eprintln!("[Video] Loading file: {}", path);
ffmpeg::init().map_err(|e| e.to_string())?;
// Open input to check for audio stream
let mut input = ffmpeg::format::input(&path)
.map_err(|e| format!("Failed to open video: {}", e))?;
let audio_stream_opt = input.streams()
.best(ffmpeg::media::Type::Audio);
let has_audio = audio_stream_opt.is_some();
// Extract audio if present
let (audio_pool_index, audio_duration, audio_sample_rate, audio_channels, audio_waveform) = if has_audio {
let audio_stream = audio_stream_opt.unwrap();
let audio_index = audio_stream.index();
// Get audio properties
let context_decoder = ffmpeg::codec::context::Context::from_parameters(
audio_stream.parameters()
).map_err(|e| e.to_string())?;
let mut audio_decoder = context_decoder.decoder().audio()
.map_err(|e| e.to_string())?;
let sample_rate = audio_decoder.rate();
let channels = audio_decoder.channels() as u32;
// Decode all audio frames
let mut audio_samples: Vec<f32> = Vec::new();
for (stream, packet) in input.packets() {
if stream.index() == audio_index {
audio_decoder.send_packet(&packet)
.map_err(|e| e.to_string())?;
let mut audio_frame = ffmpeg::util::frame::Audio::empty();
while audio_decoder.receive_frame(&mut audio_frame).is_ok() {
// Convert audio to f32 planar format
let format = audio_frame.format();
let frame_channels = audio_frame.channels() as usize;
// Create resampler to convert to f32 planar
let mut resampler = ffmpeg::software::resampling::context::Context::get(
format,
audio_frame.channel_layout(),
sample_rate,
ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
audio_frame.channel_layout(),
sample_rate,
).map_err(|e| e.to_string())?;
let mut resampled_frame = ffmpeg::util::frame::Audio::empty();
resampler.run(&audio_frame, &mut resampled_frame)
.map_err(|e| e.to_string())?;
// Extract f32 samples (interleaved format)
let data_ptr = resampled_frame.data(0).as_ptr() as *const f32;
let total_samples = resampled_frame.samples() * frame_channels;
let samples_slice = unsafe {
std::slice::from_raw_parts(data_ptr, total_samples)
};
audio_samples.extend_from_slice(samples_slice);
}
}
}
// Flush audio decoder
audio_decoder.send_eof().map_err(|e| e.to_string())?;
let mut audio_frame = ffmpeg::util::frame::Audio::empty();
while audio_decoder.receive_frame(&mut audio_frame).is_ok() {
let format = audio_frame.format();
let frame_channels = audio_frame.channels() as usize;
let mut resampler = ffmpeg::software::resampling::context::Context::get(
format,
audio_frame.channel_layout(),
sample_rate,
ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
audio_frame.channel_layout(),
sample_rate,
).map_err(|e| e.to_string())?;
let mut resampled_frame = ffmpeg::util::frame::Audio::empty();
resampler.run(&audio_frame, &mut resampled_frame)
.map_err(|e| e.to_string())?;
let data_ptr = resampled_frame.data(0).as_ptr() as *const f32;
let total_samples = resampled_frame.samples() * frame_channels;
let samples_slice = unsafe {
std::slice::from_raw_parts(data_ptr, total_samples)
};
audio_samples.extend_from_slice(samples_slice);
}
// Calculate audio duration
let total_samples_per_channel = audio_samples.len() / channels as usize;
let audio_duration = total_samples_per_channel as f64 / sample_rate as f64;
// Generate waveform
let target_peaks = ((audio_duration * 300.0) as usize).clamp(1000, 20000);
let waveform = generate_waveform(&audio_samples, channels, target_peaks);
// Send audio to DAW backend
let mut audio_state_guard = audio_state.lock().unwrap();
let audio_pool_index = audio_state_guard.next_pool_index;
audio_state_guard.next_pool_index += 1;
if let Some(controller) = &mut audio_state_guard.controller {
controller.add_audio_file(
path.clone(),
audio_samples,
channels,
sample_rate,
);
}
drop(audio_state_guard);
(Some(audio_pool_index), Some(audio_duration), Some(sample_rate), Some(channels), Some(waveform))
} else {
(None, None, None, None, None)
};
// Create video decoder with max dimensions for playback (1920x1080)
// This scales videos to reduce data transfer over WebSocket
let mut video_state_guard = video_state.lock().unwrap();
let pool_index = video_state_guard.next_pool_index;
video_state_guard.next_pool_index += 1;
let decoder = VideoDecoder::new(path.clone(), video_state_guard.cache_size, Some(1920), Some(1080))?;
let metadata = VideoFileMetadata {
pool_index,
width: decoder.output_width, // Return scaled dimensions to JS
height: decoder.output_height,
fps: decoder.fps,
duration: decoder.duration,
has_audio,
audio_pool_index,
audio_duration,
audio_sample_rate,
audio_channels,
audio_waveform,
};
video_state_guard.pool.push(Arc::new(Mutex::new(decoder)));
Ok(metadata)
}
fn generate_waveform(audio_data: &[f32], channels: u32, target_peaks: usize) -> Vec<WaveformPeak> {
let total_samples = audio_data.len();
let samples_per_channel = total_samples / channels as usize;
let samples_per_peak = (samples_per_channel / target_peaks).max(1);
let mut waveform = Vec::new();
for peak_idx in 0..target_peaks {
let start_sample = peak_idx * samples_per_peak;
let end_sample = ((peak_idx + 1) * samples_per_peak).min(samples_per_channel);
if start_sample >= samples_per_channel {
break;
}
let mut min_val = 0.0f32;
let mut max_val = 0.0f32;
for sample_idx in start_sample..end_sample {
// Average across channels
let mut channel_sum = 0.0f32;
for ch in 0..channels as usize {
let idx = sample_idx * channels as usize + ch;
if idx < total_samples {
channel_sum += audio_data[idx];
}
}
let avg_sample = channel_sum / channels as f32;
min_val = min_val.min(avg_sample);
max_val = max_val.max(avg_sample);
}
waveform.push(WaveformPeak {
min: min_val,
max: max_val,
});
}
waveform
}
#[tauri::command]
pub async fn video_set_cache_size(
state: tauri::State<'_, Arc<Mutex<VideoState>>>,
cache_size: usize,
) -> Result<(), String> {
let mut video_state = state.lock().unwrap();
video_state.cache_size = cache_size;
Ok(())
}
#[tauri::command]
pub async fn video_get_pool_info(
state: tauri::State<'_, Arc<Mutex<VideoState>>>,
pool_index: usize,
) -> Result<(u32, u32, f64), String> {
let video_state = state.lock().unwrap();
let decoder = video_state.pool.get(pool_index)
.ok_or("Invalid pool index")?
.lock().unwrap();
Ok((
decoder.output_width, // Return scaled dimensions
decoder.output_height,
decoder.fps
))
}
/// Stream a decoded video frame over WebSocket (zero-copy performance testing)
#[tauri::command]
pub async fn video_stream_frame(
video_state: tauri::State<'_, Arc<Mutex<VideoState>>>,
frame_streamer: tauri::State<'_, Arc<Mutex<crate::frame_streamer::FrameStreamer>>>,
pool_index: usize,
timestamp: f64,
) -> Result<(), String> {
use std::time::Instant;
let t_start = Instant::now();
// Get decoder
let state = video_state.lock().unwrap();
let decoder = state.pool.get(pool_index)
.ok_or("Invalid pool index")?
.clone();
drop(state);
// Decode frame
let mut decoder = decoder.lock().unwrap();
let width = decoder.output_width;
let height = decoder.output_height;
let t_decode_start = Instant::now();
let rgba_data = decoder.get_frame(timestamp)?; // Note: get_frame returns RGBA, not RGB
let t_decode = t_decode_start.elapsed().as_micros();
drop(decoder);
// Stream over WebSocket
let t_stream_start = Instant::now();
let streamer = frame_streamer.lock().unwrap();
streamer.send_frame(pool_index, timestamp, width, height, &rgba_data);
let t_stream = t_stream_start.elapsed().as_micros();
drop(streamer);
// Commented out per-frame logging
// let t_total = t_start.elapsed().as_micros();
// eprintln!("[Video Stream] Frame {}x{} @ {:.2}s | Decode: {}μs | Stream: {}μs | Total: {}μs",
// width, height, timestamp, t_decode, t_stream, t_total);
Ok(())
}

View File

@ -1,63 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lightningbeam",
"version": "1.0.4-alpha",
"identifier": "org.lightningbeam.core",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Lightningbeam",
"width": 1500,
"height": 1024,
"dragDropEnabled": false,
"zoomHotkeysEnabled": false
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"../src/assets/instruments/**/*"
],
"linux": {
"appimage": {
"bundleMediaFramework": true,
"files": {}
},
"deb": {
"files": {}
},
"rpm": {
"epoch": 0,
"files": {},
"release": "1"
}
},
"fileAssociations": [
{
"ext": [
"beam"
],
"mimeType": "application/lightningbeam"
}
]
}
}

View File

@ -1,3 +0,0 @@
{
"version": "0.6.4-0"
}