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>
|
|
@ -130,14 +130,6 @@ jobs:
|
|||
# LLVM/libclang for bindgen
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -327,7 +319,7 @@ jobs:
|
|||
mkdir -p "$APP/Contents/Resources/presets"
|
||||
|
||||
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/"
|
||||
|
||||
cat > "$APP/Contents/Info.plist" << EOF
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ dist-ssr
|
|||
.gitignore
|
||||
|
||||
# Build
|
||||
src-tauri/gen
|
||||
src-tauri/target
|
||||
lightningbeam-core/target
|
||||
daw-backend/target
|
||||
target/
|
||||
|
|
|
|||
|
|
@ -486,8 +486,7 @@ lightningbeam-2/
|
|||
│ ├── synth/ # Synthesizers
|
||||
│ └── midi/ # MIDI handling
|
||||
│
|
||||
├── src-tauri/ # Legacy Tauri backend
|
||||
├── src/ # Legacy JavaScript frontend
|
||||
├── src/ # Legacy JavaScript frontend (browser-only)
|
||||
├── CONTRIBUTING.md # Contributor guide
|
||||
├── ARCHITECTURE.md # This file
|
||||
├── README.md # Project overview
|
||||
|
|
|
|||
|
|
@ -122,8 +122,7 @@ lightningbeam-2/
|
|||
│ │ ├── track.rs # Track management
|
||||
│ │ └── project.rs # Project state
|
||||
│ └── effects/ # Audio effects
|
||||
├── src-tauri/ # Legacy Tauri backend
|
||||
└── src/ # Legacy JavaScript frontend
|
||||
└── src/ # Legacy JavaScript frontend (browser-only)
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
Ok(img) => {
|
||||
let rgba = img.to_rgba8();
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"test": "wdio run wdio.conf.js",
|
||||
"test:watch": "wdio run wdio.conf.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@wdio/cli": "^9.20.0",
|
||||
"@wdio/globals": "^9.17.0",
|
||||
"@wdio/local-runner": "8",
|
||||
|
|
@ -19,9 +17,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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.js": "^4.2.9003"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ pkgbuild: setup-icons
|
|||
setup-icons:
|
||||
@echo "==> Setting up icons..."
|
||||
@mkdir -p $(EDITOR)/assets/icons
|
||||
@cp -u $(REPO_ROOT)/src-tauri/icons/32x32.png $(EDITOR)/assets/icons/ 2>/dev/null || true
|
||||
@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
|
||||
@# Icons are committed under $(EDITOR)/assets/icons (32x32, 128x128, 256x256, icon.icns).
|
||||
@echo " Icons ready"
|
||||
|
||||
clean:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 260 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"version": "0.6.4-0"
|
||||
}
|
||||