Compare commits

...

15 commits
v0.1.1 ... main

Author SHA1 Message Date
Jürgen Zwiener
74a8517f4f CI: Versionsnummer wird bei jedem Versions-Tag automatisch in tauri.conf.json, package.json und Cargo.toml gesetzt
All checks were successful
Windows Installer bauen / build-windows (push) Successful in 7m52s
2026-09-17 16:42:26 +02:00
Jürgen Zwiener
f2cf99a724 CI: rustup -y Flag entfernt, Changelog-Schritt von bash auf pwsh umgestellt (kein WSL auf Runner)
All checks were successful
Windows Installer bauen / build-windows (push) Successful in 8m54s
2026-09-17 16:02:07 +02:00
Jürgen Zwiener
273c066d7e Neuen Minisign Signierschluessel eingetragen (Forgejo Secrets + Infisical), altes GitHub Secret war nicht wiederherstellbar
Some checks failed
Windows Installer bauen / build-windows (push) Failing after 59s
2026-09-17 10:05:57 +02:00
JJ (via Claude)
42854c16da CI von GitHub Actions auf selbstgehostete Forgejo Actions umgestellt (rustup statt dtolnay/rust-toolchain, manuelles Release statt tauri-action, upload-artifact v3)
Some checks are pending
Windows Installer bauen / build-windows (push) Waiting to run
2026-09-17 07:32:38 +00:00
Jürgen Zwiener
fd3f4c679a Automatischer Actions Start bei Versions-Tags repariert (paths Filter entfernt)
Some checks are pending
Windows Installer bauen / build-windows (push) Waiting to run
2026-08-29 22:35:41 +02:00
Jürgen Zwiener
f4e7ce6dff Standardspeicherziel eingebaut, echte Changelog Liste im Release, build.yml Stand repariert
Some checks failed
Windows Installer bauen / build-windows (push) Failing after 3s
2026-08-29 20:52:33 +02:00
Jürgen Zwiener
4654636d49 Version 0.1.2, Release-Beschreibung jetzt automatisch aus Commits generiert 2026-08-29 14:30:33 +02:00
Jürgen Zwiener
6a8a4a4d59 Version 0.1.2, Release-Beschreibung jetzt automatisch aus Commits generiert
Some checks failed
Windows Installer bauen / build-windows (push) Failing after 4s
2026-08-19 18:24:00 +02:00
Jürgen Zwiener
ee23e4c146 Pipeline Reihenfolge umgestellt (erst Entrauschen, dann Schnitt), Klangkette nachgeschaerft 2026-08-19 17:53:06 +02:00
Jürgen Zwiener
85b9e785bc Zeiteinheit beim auto-editor Transition Flag ergaenzt 2026-08-19 17:05:21 +02:00
Jürgen Zwiener
fde2451a81 Temp Ordner Fix, weiche Uebergaenge an Schnittpunkten, Klangaufwertung (EQ und Kompressor) 2026-08-19 16:46:13 +02:00
Jürgen Zwiener
d7d8c41953 Zwischendateien in echten Temp Ordner verlagern, Nextcloud Sperrfehler beheben 2026-08-19 12:41:08 +02:00
Jürgen Zwiener
3c7e77524e deep-filter Ausgabeordner statt Zieldatei korrekt behandeln 2026-08-19 12:05:37 +02:00
Jürgen Zwiener
b817782e34 DeepFilterNet Downloadschritt: windows statt win, um Verwechslung mit darwin zu vermeiden 2026-08-19 11:37:54 +02:00
Jürgen Zwiener
4acb944e2b Hinweis auf fehlende Werkzeuge dezenter gestaltet, Pruefung der Sidecar Downloads in der Build Pipeline ergaenzt 2026-08-19 11:32:06 +02:00
8 changed files with 549 additions and 103 deletions

View file

@ -1,11 +1,23 @@
name: Windows Installer bauen
# Von GitHub Actions auf selbstgehostete Forgejo Actions umgestellt (Windows
# Runner rcp-24, derselbe Runner wie NovaStudioCast, siehe NOVA-SERVER/forgejo-runner).
# Zwei Aenderungen gegenueber der alten GitHub Version:
# 1. dtolnay/rust-toolchain ist auf dieser Forgejo Instanz nicht gespiegelt
# (nur die offiziellen actions/* Actions sind das), deshalb Rust hier
# direkt per rustup eingerichtet.
# 2. tauri-apps/tauri-action ist ebenfalls nicht gespiegelt, deshalb wird bei
# einem Versions-Tag von Hand gebaut, signiert und als Forgejo Release
# veroeffentlicht (Installer + latest.json), statt diese GitHub Action zu
# nutzen. "npm run tauri build" erzeugt bei gesetzten
# TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) Umgebungsvariablen automatisch eine
# .sig Signaturdatei neben dem Installer, unabhaengig von tauri-action.
# actions/upload-artifact bewusst bei v3 belassen, v4+ wird von Forgejo nicht
# unterstuetzt (GHESNotSupportedError, siehe NovaStudioCast build.yml).
on:
push:
branches: [main]
paths:
- 'tauri-app/**'
- '.github/workflows/build.yml'
tags:
- 'v*.*.*'
workflow_dispatch: {}
@ -22,6 +34,8 @@ jobs:
steps:
- name: Code auschecken
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Node.js einrichten
uses: actions/setup-node@v4
@ -29,7 +43,11 @@ jobs:
node-version: 20
- name: Rust einrichten
uses: dtolnay/rust-toolchain@stable
shell: pwsh
run: |
rustup toolchain install stable --profile minimal
rustup default stable
rustup target add x86_64-pc-windows-msvc
- name: Node Abhängigkeiten installieren
working-directory: tauri-app
@ -43,24 +61,44 @@ jobs:
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$triple = "x86_64-pc-windows-msvc"
function Assert-ValidExe($path) {
if (-not (Test-Path $path)) {
Write-Error "$path wurde nicht angelegt, Download ist fehlgeschlagen."
}
$size = (Get-Item $path).Length
if ($size -lt 1MB) {
Write-Error "$path ist mit $size Bytes verdaechtig klein fuer ein echtes Programm, vermutlich ist beim Download eine Fehlerseite statt der Datei angekommen. Abgebrochen, bevor eine kaputte Datei eingepackt wird."
}
$stream = [System.IO.File]::OpenRead($path)
$header = New-Object byte[] 2
$stream.Read($header, 0, 2) | Out-Null
$stream.Close()
if ($header[0] -ne 0x4D -or $header[1] -ne 0x5A) {
Write-Error "$path beginnt nicht mit der erwarteten MZ Kennung einer Windows exe Datei, vermutlich keine echte ausfuehrbare Datei."
}
Write-Host " $path geprueft, $size Bytes, gueltige exe Kennung."
}
Write-Host "Lade auto-editor..."
Invoke-WebRequest -Uri "https://github.com/WyattBlue/auto-editor/releases/latest/download/auto-editor-windows-x86_64.exe" `
-OutFile "$binDir/auto-editor-$triple.exe"
Assert-ValidExe "$binDir/auto-editor-$triple.exe"
Write-Host "Lade FFmpeg (gyan.dev essentials build)..."
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip"
Expand-Archive -Path "ffmpeg.zip" -DestinationPath "ffmpeg-extracted"
$ffmpegExe = Get-ChildItem -Path "ffmpeg-extracted" -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1
Copy-Item $ffmpegExe.FullName "$binDir/ffmpeg-$triple.exe"
Assert-ValidExe "$binDir/ffmpeg-$triple.exe"
Write-Host "Ermittle aktuelle DeepFilterNet Windows Datei..."
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/Rikorose/DeepFilterNet/releases/latest" -Headers @{ "User-Agent" = "novaphonic-build" }
$winAsset = $release.assets | Where-Object { $_.name -match "(?i)win" -and $_.name -match "(?i)deep-filter" } | Select-Object -First 1
$winAsset = $release.assets | Where-Object { $_.name -match "(?i)deep-filter-.*x86_64-pc-windows-msvc\.exe$" } | Select-Object -First 1
if (-not $winAsset) {
Write-Error "Kein passendes DeepFilterNet Windows Asset in der aktuellen Release gefunden. Bitte https://github.com/Rikorose/DeepFilterNet/releases manuell pruefen und diesen Schritt anpassen."
}
Write-Host "Gefunden: $($winAsset.name)"
Invoke-WebRequest -Uri $winAsset.browser_download_url -OutFile "deepfilter-download"
Invoke-WebRequest -Uri $winAsset.browser_download_url -OutFile "deepfilter-download" -Headers @{ "User-Agent" = "novaphonic-build" }
if ($winAsset.name -match "\.zip$") {
Expand-Archive -Path "deepfilter-download" -DestinationPath "deepfilter-extracted"
$dfExe = Get-ChildItem -Path "deepfilter-extracted" -Recurse -Filter "*.exe" | Select-Object -First 1
@ -68,6 +106,7 @@ jobs:
} else {
Copy-Item "deepfilter-download" "$binDir/deep-filter-$triple.exe"
}
Assert-ValidExe "$binDir/deep-filter-$triple.exe"
- name: Nur bauen, kein Release (normaler Push ohne Versions-Tag)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
@ -76,22 +115,117 @@ jobs:
- name: Installer als Artefakt hochladen (nur bei normalem Push)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: novaphonic-windows-installer
path: |
tauri-app/src-tauri/target/release/bundle/nsis/*.exe
- name: Bauen, signieren und GitHub Release veröffentlichen (nur bei Versions-Tag)
- name: Änderungsliste seit dem letzten Tag ermitteln (nur bei Versions-Tag)
if: startsWith(github.ref, 'refs/tags/v')
uses: tauri-apps/tauri-action@v0
id: changelog
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$currentTag = "${{ github.ref_name }}"
$prevTag = git tag --sort=-v:refname | Where-Object { $_ -ne $currentTag } | Select-Object -First 1
Write-Host "Aktueller Tag: $currentTag"
Write-Host "Vorheriger Tag: $(if ($prevTag) { $prevTag } else { 'keiner, es ist das erste Release' })"
if ($prevTag) {
$log = git log --pretty=format:"- %s" "$prevTag..$currentTag"
} else {
$log = git log --pretty=format:"- %s" "$currentTag"
}
$changelog = "changelog<<CHANGELOG_EOF`n$($log -join "`n")`nCHANGELOG_EOF"
Add-Content -Path $env:GITHUB_OUTPUT -Value $changelog
- name: Versionsnummer aus Tag automatisch setzen (nur bei Versions-Tag)
if: startsWith(github.ref, 'refs/tags/v')
working-directory: tauri-app
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ github.ref_name }}" -replace '^v', ''
Write-Host "Setze Version auf $version in tauri.conf.json, package.json, Cargo.toml ..."
$confPath = "src-tauri/tauri.conf.json"
$content = Get-Content $confPath -Raw
$neu = $content -replace '("version"\s*:\s*)"[^"]*"', "`$1`"$version`""
if ($neu -eq $content) { Write-Error "Version in $confPath nicht gefunden/ersetzt." }
Set-Content -Path $confPath -Value $neu -NoNewline
$pkgPath = "package.json"
$content = Get-Content $pkgPath -Raw
$neu = $content -replace '("version"\s*:\s*)"[^"]*"', "`$1`"$version`""
if ($neu -eq $content) { Write-Error "Version in $pkgPath nicht gefunden/ersetzt." }
Set-Content -Path $pkgPath -Value $neu -NoNewline
$cargoPath = "src-tauri/Cargo.toml"
$content = Get-Content $cargoPath -Raw
$neu = $content -replace '(?m)^version = "[^"]*"', "version = `"$version`""
if ($neu -eq $content) { Write-Error "Version in $cargoPath nicht gefunden/ersetzt." }
Set-Content -Path $cargoPath -Value $neu -NoNewline
Write-Host " Erledigt, alle drei Dateien jetzt auf $version."
- name: Bauen und signieren (nur bei Versions-Tag)
if: startsWith(github.ref, 'refs/tags/v')
working-directory: tauri-app
run: npm run tauri build
- name: Forgejo Release veröffentlichen (nur bei Versions-Tag)
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri-app
tagName: ${{ github.ref_name }}
releaseName: 'NovaPhonic ${{ github.ref_name }}'
releaseBody: 'Automatisch erstelltes Release. Enthält den Windows Installer sowie das Update Manifest für die In App Aktualisierung.'
releaseDraft: false
prerelease: false
includeUpdaterJson: true
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
run: |
$ErrorActionPreference = "Stop"
$repoApi = "https://forgejo.kiw-schmiede.eu/api/v1/repos/kiw-schmiede/novaphonic"
$headers = @{ Authorization = "token $env:FORGEJO_TOKEN" }
$tag = "${{ github.ref_name }}"
$bundleDir = "tauri-app/src-tauri/target/release/bundle/nsis"
$installer = Get-ChildItem -Path $bundleDir -Filter "*.exe" | Select-Object -First 1
$sigFile = "$($installer.FullName).sig"
if (-not (Test-Path $sigFile)) {
Write-Error "Keine .sig Datei neben $($installer.Name) gefunden, Signierung ist vermutlich fehlgeschlagen (TAURI_SIGNING_PRIVATE_KEY gesetzt?)."
}
$signature = Get-Content $sigFile -Raw
Write-Host "Lege Forgejo Release fuer $tag an ..."
$releaseBody = @{
tag_name = $tag
name = "NovaPhonic $tag"
body = "NovaPhonic $tag. Enthaelt den Windows Installer sowie das Update Manifest fuer die In App Aktualisierung.`n`nAenderungen in diesem Release:`n$env:CHANGELOG"
draft = $false
prerelease = $false
} | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$repoApi/releases" -Method Post -Headers $headers -ContentType "application/json" -Body $releaseBody
Write-Host " Release ID $($release.id) angelegt."
Write-Host "Lade Installer hoch ..."
$installerUploadUrl = "$repoApi/releases/$($release.id)/assets?name=$($installer.Name)"
Invoke-RestMethod -Uri $installerUploadUrl -Method Post -Headers $headers -Form @{ attachment = Get-Item $installer.FullName } | Out-Null
$installerAsset = (Invoke-RestMethod -Uri "$repoApi/releases/$($release.id)" -Headers $headers).assets | Where-Object { $_.name -eq $installer.Name }
Write-Host "Baue latest.json (Update Manifest) ..."
$latestJson = @{
version = $tag
notes = "NovaPhonic $tag"
pub_date = (Get-Date -AsUTC -Format "yyyy-MM-ddTHH:mm:ssZ")
platforms = @{
"windows-x86_64" = @{
signature = $signature
url = $installerAsset.browser_download_url
}
}
} | ConvertTo-Json -Depth 5
Set-Content -Path "latest.json" -Value $latestJson -NoNewline
Write-Host "Lade latest.json hoch ..."
$manifestUploadUrl = "$repoApi/releases/$($release.id)/assets?name=latest.json"
Invoke-RestMethod -Uri $manifestUploadUrl -Method Post -Headers $headers -Form @{ attachment = Get-Item "latest.json" } | Out-Null
Write-Host "Fertig: https://forgejo.kiw-schmiede.eu/kiw-schmiede/novaphonic/releases/tag/$tag"

View file

@ -148,8 +148,67 @@
loudnormToggle.addEventListener('change', syncLoudnormOptions);
syncLoudnormOptions();
// ---------- Automatisches Speichern ----------
// Standard bleibt der "Speichern unter" Dialog nach jeder Verarbeitung (wie
// bisher). Wer die Checkbox aktiviert, bekommt stattdessen jedes Ergebnis
// automatisch in einem einmal gewählten Ordner abgelegt, ohne Dialog. Die
// Einstellung wird über zwei kleine Rust Commands (load_settings/save_settings)
// in einer settings Datei im App Konfigurationsordner gespeichert, damit sie
// App-Neustarts übersteht.
const autoSaveToggle = $('autoSaveToggle');
const autoSaveFolderRow = $('autoSaveFolderRow');
const autoSaveFolderPath = $('autoSaveFolderPath');
const chooseAutoSaveFolderBtn = $('chooseAutoSaveFolderBtn');
let appSettings = { autoSaveEnabled: false, autoSaveFolder: null };
function syncAutoSaveUi() {
autoSaveFolderRow.style.display = autoSaveToggle.checked ? 'block' : 'none';
autoSaveFolderPath.textContent = appSettings.autoSaveFolder || 'Kein Ordner ausgewählt';
}
async function persistSettings() {
appSettings.autoSaveEnabled = autoSaveToggle.checked;
if (!isTauri()) return;
try {
await window.__TAURI__.core.invoke('save_settings', { settings: appSettings });
} catch (e) {
console.error(e);
}
}
autoSaveToggle.addEventListener('change', () => {
syncAutoSaveUi();
persistSettings();
});
chooseAutoSaveFolderBtn.addEventListener('click', async () => {
if (!isTauri()) return;
try {
const folder = await window.__TAURI__.core.invoke('pick_save_folder');
if (folder) {
appSettings.autoSaveFolder = folder;
syncAutoSaveUi();
persistSettings();
}
} catch (e) {
console.error(e);
}
});
async function loadSettings() {
if (!isTauri()) { syncAutoSaveUi(); return; }
try {
const loaded = await window.__TAURI__.core.invoke('load_settings');
if (loaded) appSettings = loaded;
autoSaveToggle.checked = !!appSettings.autoSaveEnabled;
} catch (e) {
console.error(e);
}
syncAutoSaveUi();
}
// ---------- Pipeline Ablauf ----------
const STEP_IDS = ['cut', 'denoise', 'loudnorm'];
const STEP_IDS = ['denoise', 'cut', 'loudnorm'];
function resetSteps() {
for (const id of STEP_IDS) {
@ -215,8 +274,31 @@
}
});
function showResult(result) {
async function showResult(result) {
const name = result.outputPath.split(/[\\/]/).pop();
// Automatisches Speichern: wenn aktiviert und ein Ordner gewählt wurde,
// landet das Ergebnis sofort dort, ohne "Speichern unter" Dialog.
if (appSettings.autoSaveEnabled && appSettings.autoSaveFolder) {
try {
const saved = await window.__TAURI__.core.invoke('auto_save_output', {
sourcePath: result.outputPath,
suggestedName: name,
targetFolder: appSettings.autoSaveFolder
});
$('resultArea').innerHTML =
'<div class="result-box"><span class="rb-name">' + name.replace(/</g, '&lt;') + '</span></div>' +
'<div class="status-ok" style="margin-top:8px;">Automatisch gespeichert unter: ' +
String(saved).replace(/</g, '&lt;') + '</div>';
return;
} catch (e) {
console.error(e);
// Bei einem Fehler beim automatischen Speichern (z.B. Ordner nicht mehr
// vorhanden) auf den manuellen Weg zurückfallen, damit das fertige
// Ergebnis nicht verloren geht.
}
}
$('resultArea').innerHTML =
'<div class="result-box">' +
'<span class="rb-name">' + name.replace(/</g, '&lt;') + '</span>' +
@ -304,5 +386,6 @@
// ---------- Start ----------
initProgressListener();
checkTools();
loadSettings();
checkForUpdate(true);
})();

View file

@ -52,11 +52,6 @@
nichts hochgeladen und keine Verbindung zu einem Server aufgebaut.
</div>
<div id="toolWarning" class="tool-warning" style="display:none;">
<strong>Werkzeuge fehlen.</strong>
<span id="toolWarningText">Es konnten nicht alle benötigten Programme gefunden werden.</span>
</div>
<div class="grid">
<div>
<div class="panel">
@ -86,15 +81,28 @@
<option value="-14">-14 LUFS (Streaming, laut)</option>
</select>
</div>
<div class="checkbox-row" style="margin-top:16px;">
<input type="checkbox" id="autoSaveToggle">
<label for="autoSaveToggle">Ergebnis automatisch speichern, ohne Dialog</label>
</div>
<div class="row" id="autoSaveFolderRow" style="margin-top:10px; display:none;">
<div class="selected-file">
<span class="sf-name" id="autoSaveFolderPath">Kein Ordner ausgewählt</span>
<button class="secondary" type="button" id="chooseAutoSaveFolderBtn">Ordner wählen</button>
</div>
<div class="hint-small">Jedes fertige Video landet automatisch in diesem Ordner, ohne dass du jedes Mal einen Speicherort auswählen musst.</div>
</div>
</div>
<div class="panel">
<h2>Über die Werkzeuge</h2>
<div class="hint-small">
Schnitt: <strong>auto-editor</strong> (Public Domain). Entrauschen:
<strong>DeepFilterNet</strong> (MIT/Apache 2.0). Lautstärke: <strong>FFmpeg</strong>
loudnorm Filter (LGPL/GPL). Alle drei laufen als eigenständige Programme neben
NovaPhonic auf diesem Rechner, keines davon braucht eine Grafikkarte.
<strong>DeepFilterNet</strong> (MIT/Apache 2.0). Klangveredelung &amp;
Lautstärke: <strong>FFmpeg</strong> (EQ, Kompressor, loudnorm Filter, LGPL/GPL).
Alle drei laufen als eigenständige Programme neben NovaPhonic auf diesem
Rechner, keines davon braucht eine Grafikkarte.
</div>
</div>
</div>
@ -129,19 +137,19 @@
<div class="panel">
<h2>Ablauf</h2>
<div class="pipeline-steps">
<div class="pipeline-step" id="step-cut">
<span class="ps-dot"></span>
<span class="ps-label">Füllwörter &amp; Stille schneiden</span>
<span class="ps-detail" id="step-cut-detail"></span>
</div>
<div class="pipeline-step" id="step-denoise">
<span class="ps-dot"></span>
<span class="ps-label">Rauschen entfernen</span>
<span class="ps-detail" id="step-denoise-detail"></span>
</div>
<div class="pipeline-step" id="step-cut">
<span class="ps-dot"></span>
<span class="ps-label">Füllwörter &amp; Stille schneiden</span>
<span class="ps-detail" id="step-cut-detail"></span>
</div>
<div class="pipeline-step" id="step-loudnorm">
<span class="ps-dot"></span>
<span class="ps-label">Lautstärke angleichen</span>
<span class="ps-label">Klang veredeln &amp; Lautstärke angleichen</span>
<span class="ps-detail" id="step-loudnorm-detail"></span>
</div>
</div>
@ -166,6 +174,11 @@
Veröffentlichung kurz ansehen.
</div>
<div id="toolWarning" class="tool-warning" style="display:none;">
<strong>Hinweis:</strong>
<span id="toolWarningText">Es konnten nicht alle benötigten Programme gefunden werden.</span>
</div>
</div>
<script src="app.js"></script>

View file

@ -331,15 +331,12 @@ button:disabled { opacity: 0.4; cursor: not-allowed; }
.privacy-note strong { color: var(--accent-text); }
.tool-warning {
font-size: 13px;
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--text);
padding: 10px 12px;
border-radius: 10px;
margin-bottom: 18px;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
margin-top: 10px;
}
.tool-warning strong { color: var(--danger); }
.tool-warning strong { color: var(--danger); font-weight: 600; }
.update-banner {
display: flex;

View file

@ -1,6 +1,6 @@
{
"name": "novaphonic",
"version": "0.1.1",
"version": "0.1.3",
"private": true,
"scripts": {
"tauri": "tauri"

View file

@ -1,6 +1,6 @@
[package]
name = "novaphonic"
version = "0.1.1"
version = "0.1.3"
edition = "2021"
description = "NovaPhonic, ein Tool von KI-WERKE zum automatischen Videoschnitt und zur Audio Veredelung"

View file

@ -6,18 +6,21 @@
// Dateipfade verarbeitet, nie Videoinhalte selbst in den Arbeitsspeicher der
// Oberfläche geladen.
//
// Wichtiger Hinweis für den ersten echten Build: In dieser Entwicklungsumgebung
// steht kein Rust Compiler zur Verfügung, dieser Code wurde also nicht kompiliert,
// nur sorgfältig von Hand geprüft. Zwei Stellen bitte beim ersten Testlauf gezielt
// gegenprüfen: 1) die genauen Kommandozeilen-Optionen von `deep-filter` (siehe
// `deep-filter --help`, hier als `-o <Zieldatei>` angenommen), 2) ob `auto-editor`
// den Flag `--no-open` in der installierten Version so kennt. Beides ist unten mit
// Kommentaren markiert.
// Alle Zwischenschritte (geschnittenes Video, rohe/entrauschte Tonspur, usw.)
// laufen in einem echten temporären Systemordner (std::env::temp_dir()), nicht im
// Ordner der Eingabedatei. Grund: Bei einem echten Testlauf am 2026-08-19 lag das
// Eingabevideo in einem Nextcloud-synchronisierten Ordner, der Sync-Client hat
// neu entstehende Zwischen-Dateien sofort selbst kurz geöffnet, was zu "Zugriff
// verweigert" Fehlern führte, sobald NovaPhonic dieselbe Datei gleich danach
// weiterverarbeiten wollte. Der temporäre Arbeitsordner wird bei einem Fehler
// sofort wieder vollständig gelöscht, bei Erfolg bleibt nur das fertige Ergebnis
// bis zum Speichern über den "Speichern unter" Dialog liegen, siehe
// `save_output_as`, das räumt danach ebenfalls auf.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
@ -47,12 +50,118 @@ fn save_output_as(source_path: String, suggested_name: String) -> Result<String,
Some(dest) => {
fs::copy(&source_path, &dest)
.map_err(|e| format!("Konnte Datei nicht speichern: {e}"))?;
cleanup_temp_work_dir(&source_path);
Ok(dest.to_string_lossy().to_string())
}
None => Err("abgebrochen".to_string()),
}
}
// Löscht den temporären Arbeitsordner einer fertigen Ergebnisdatei, nachdem sie
// erfolgreich an ihr endgültiges Ziel kopiert wurde. Sicherheitshalber nur, wenn
// der Pfad wirklich im System-Temp-Ordner liegt und unserem eigenen Namensmuster
// entspricht, damit hier niemals versehentlich ein echter Nutzerordner betroffen
// sein kann.
fn cleanup_temp_work_dir(source_path: &str) {
let path = PathBuf::from(source_path);
let Some(work_dir) = path.parent() else {
return;
};
let Some(folder_name) = work_dir.file_name().and_then(|n| n.to_str()) else {
return;
};
if work_dir.starts_with(std::env::temp_dir()) && folder_name.starts_with("novaphonic-") {
let _ = fs::remove_dir_all(work_dir);
}
}
// ---------- Einstellungen (automatisches Standardspeicherziel) ----------
// Wird als kleine JSON Datei im App Konfigurationsordner des Nutzers abgelegt
// (z.B. unter Windows in AppData\Roaming\...), nicht im Temp-Ordner, damit sie
// App-Neustarts übersteht. Betrifft ausschließlich diese Einstellung, niemals
// Videoinhalte oder Zwischenergebnisse.
#[derive(Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct AppSettings {
auto_save_enabled: bool,
auto_save_folder: Option<String>,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("Konnte Konfigurationsordner nicht ermitteln: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("settings.json"))
}
#[tauri::command]
fn load_settings(app: AppHandle) -> AppSettings {
let Ok(path) = settings_path(&app) else {
return AppSettings::default();
};
match fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => AppSettings::default(),
}
}
#[tauri::command]
fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
let path = settings_path(&app)?;
let text = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(&path, text).map_err(|e| format!("Konnte Einstellungen nicht speichern: {e}"))
}
#[tauri::command]
fn pick_save_folder() -> Option<String> {
let folder = rfd::FileDialog::new().pick_folder()?;
Some(folder.to_string_lossy().to_string())
}
// Kopiert das fertige Ergebnis direkt in den vom Nutzer festgelegten Standard-
// Ordner, ohne "Speichern unter" Dialog, und räumt danach wie gewohnt den
// temporären Arbeitsordner auf. Bei einem Namenskonflikt wird die bestehende
// Datei bewusst nicht überschrieben, sondern ein Zähler an den Dateinamen
// angehängt, damit nie versehentlich ein älteres Ergebnis verloren geht.
#[tauri::command]
fn auto_save_output(
source_path: String,
suggested_name: String,
target_folder: String,
) -> Result<String, String> {
let folder = PathBuf::from(&target_folder);
if !folder.is_dir() {
return Err(format!(
"Zielordner nicht gefunden: {}",
folder.display()
));
}
let name_path = PathBuf::from(&suggested_name);
let stem = name_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("nova_ergebnis")
.to_string();
let ext = name_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("mp4")
.to_string();
let mut dest = folder.join(&suggested_name);
let mut counter = 1;
while dest.exists() {
dest = folder.join(format!("{stem} ({counter}).{ext}"));
counter += 1;
}
fs::copy(&source_path, &dest).map_err(|e| format!("Konnte Datei nicht speichern: {e}"))?;
cleanup_temp_work_dir(&source_path);
Ok(dest.to_string_lossy().to_string())
}
// Wichtig: Tauri spricht Sidecar Programme zur Laufzeit nur über ihren bloßen
// Namen an (z.B. "auto-editor"), der Ordnerpfad "binaries/" wird ausschließlich
// beim Einrichten in tauri.conf.json (externalBin) und in den Capabilities
@ -186,44 +295,58 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("video");
let ext = input.extension().and_then(|s| s.to_str()).unwrap_or("mp4");
let parent = input.parent().unwrap_or_else(|| Path::new("."));
.unwrap_or("video")
.to_string();
let ext = input
.extension()
.and_then(|s| s.to_str())
.unwrap_or("mp4")
.to_string();
// ---------- 1) Schnitt (auto-editor) ----------
emit_progress(&app, "cut", "running", Some("startet…".into()), None);
let cut_path = parent.join(format!("{stem}_geschnitten.{ext}"));
let margin = format!("{}sec", options.margin_seconds);
run_sidecar(
&app,
"auto-editor",
vec![
input.to_string_lossy().to_string(),
"--margin".into(),
margin,
// Bitte beim ersten Testlauf prüfen, ob dieses Flag in der installierten
// auto-editor Version noch so heißt (verhindert, dass sich nach dem
// Schneiden automatisch ein Videoplayer öffnet).
"--no-open".into(),
"-o".into(),
cut_path.to_string_lossy().to_string(),
],
"cut",
)
.await
.map_err(|e| {
emit_progress(&app, "cut", "error", Some(e.clone()), None);
e
})?;
emit_progress(&app, "cut", "done", Some("fertig".into()), None);
// Eigener, garantiert eindeutiger Arbeitsordner im System-Temp-Verzeichnis,
// pro Verarbeitungslauf neu, damit sich mehrere Läufe nie in die Quere kommen.
let work_dir =
std::env::temp_dir().join(format!("novaphonic-{stem}-{}", std::process::id()));
fs::create_dir_all(&work_dir).map_err(|e| e.to_string())?;
let mut current = cut_path;
match run_pipeline(&app, &options, &input, &work_dir, &stem, &ext).await {
Ok(final_path) => Ok(ProcessResult {
output_path: final_path.to_string_lossy().to_string(),
}),
Err(e) => {
// Bei einem Fehler bleibt nichts Halbfertiges im Temp-Ordner liegen.
let _ = fs::remove_dir_all(&work_dir);
Err(e)
}
}
}
// ---------- 2) Entrauschen (DeepFilterNet), optional ----------
async fn run_pipeline(
app: &AppHandle,
options: &ProcessOptions,
input: &Path,
work_dir: &Path,
stem: &str,
ext: &str,
) -> Result<PathBuf, String> {
// ---------- 1) Entrauschen (DeepFilterNet), optional ----------
// Bewusst VOR dem Schnitt statt danach (bis 2026-08-19 war das andersherum):
// auto-editor erkennt Stille anhand einer festen Lautstärke-Schwelle
// (Standard ca. -28 dB). Enthält die Aufnahme ein leises, aber durchgehendes
// Hintergrundgeräusch (Raumbrummen, Lüfter), liegt das oft über dieser
// Schwelle, auto-editor findet dann an keiner Stelle echte Stille und
// schneidet nichts weg. Nutzerfeedback vom 2026-08-19: "Der Clip ist
// durchgehend ungeschnitten, die natürlichen Sprechpausen sind noch
// vollständig erhalten." Wenn DeepFilterNet das Hintergrundgeräusch schon
// vor dem Schnitt entfernt, bleibt an den Pausen echte digitale Stille
// übrig, und die Standard-Schwelle von auto-editor funktioniert wieder
// zuverlässig, auch bei künftigen Aufnahmen mit anderem Grundrauschen,
// ohne dass man die Schwelle manuell pro Video nachjustieren muss.
let mut current = input.to_path_buf();
if options.denoise {
emit_progress(&app, "denoise", "running", Some("startet…".into()), None);
let raw_audio = parent.join(format!("{stem}_audio.wav"));
let denoised_audio = parent.join(format!("{stem}_entrauscht.wav"));
emit_progress(app, "denoise", "running", Some("startet…".into()), None);
let raw_audio = work_dir.join(format!("{stem}_audio.wav"));
let denoised_audio = work_dir.join(format!("{stem}_entrauscht.wav"));
run_sidecar(
&app,
@ -249,18 +372,29 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
e
})?;
// Achtung, bitte gegen `deep-filter --help` prüfen: hier wird eine exakte
// Ausgabedatei per -o angenommen. Manche deep-filter Versionen erwarten
// stattdessen einen Ausgabeordner (z.B. -o <Ordner>), in dem eine Datei
// mit demselben Namen wie die Eingabe erzeugt wird. Falls das der Fall
// ist, muss diese Stelle entsprechend angepasst werden.
// deep-filter erwartet bei -o einen AusgabeORDNER, keine Zieldatei, und
// schreibt dort eine Datei mit demselben Namen wie die Eingabedatei hinein
// (per echtem Testlauf bestätigt, 2026-08-19). Deshalb erst in einen eigenen
// Zwischenordner schreiben lassen und die entstandene Datei danach auf den
// gewünschten Namen umbenennen.
let denoise_out_dir = work_dir.join(format!("{stem}_denoise_tmp"));
fs::create_dir_all(&denoise_out_dir).map_err(|e| e.to_string())?;
run_sidecar(
&app,
"deep-filter",
vec![
raw_audio.to_string_lossy().to_string(),
// Begrenzt, wie stark DeepFilterNet maximal absenken darf. Ohne
// dieses Limit greift das Modell an manchen Stellen so stark ein,
// dass mit dem Rauschen auch ein Teil der hohen Frequenzen der
// Stimme mit entfernt wird, das klingt dann dumpf und mattiert
// statt klar. Nutzerfeedback vom 2026-08-19: Klang zu dumpf im
// Vergleich zu Auphonic. 30 dB ist ein vorsichtiger Mittelweg,
// lässt sich bei Bedarf noch weiter anpassen.
"--atten-lim-db".into(),
"30".into(),
"-o".into(),
denoised_audio.to_string_lossy().to_string(),
denoise_out_dir.to_string_lossy().to_string(),
],
"denoise",
)
@ -269,8 +403,24 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
emit_progress(&app, "denoise", "error", Some(e.clone()), None);
e
})?;
let denoise_result = denoise_out_dir.join(
raw_audio
.file_name()
.ok_or_else(|| "Ungültiger Audio Dateiname.".to_string())?,
);
fs::rename(&denoise_result, &denoised_audio)
.or_else(|_| fs::copy(&denoise_result, &denoised_audio).map(|_| ()))
.map_err(|e| {
let msg = format!(
"Entrauschte Datei nicht am erwarteten Ort {} gefunden: {e}",
denoise_result.display()
);
emit_progress(&app, "denoise", "error", Some(msg.clone()), None);
msg
})?;
let _ = fs::remove_dir_all(&denoise_out_dir);
let remuxed = parent.join(format!("{stem}_entrauscht_video.{ext}"));
let remuxed = work_dir.join(format!("{stem}_entrauscht_video.{ext}"));
run_sidecar(
&app,
"ffmpeg",
@ -299,22 +449,89 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
let _ = fs::remove_file(&raw_audio);
let _ = fs::remove_file(&denoised_audio);
let _ = fs::remove_file(&current);
current = remuxed;
emit_progress(&app, "denoise", "done", Some("fertig".into()), None);
} else {
emit_progress(&app, "denoise", "done", Some("übersprungen".into()), None);
}
// ---------- 3) Lautstärke & Klangbalance (FFmpeg loudnorm), optional ----------
let final_path = parent.join(format!("{stem}_nova.{ext}"));
// ---------- 2) Schnitt (auto-editor) ----------
emit_progress(app, "cut", "running", Some("startet…".into()), None);
let cut_path = work_dir.join(format!("{stem}_geschnitten.{ext}"));
let margin = format!("{}sec", options.margin_seconds);
// Läuft jetzt auf `current`, also auf der schon entrauschten Datei (falls
// Entrauschen aktiv war) statt auf der Originaldatei, siehe Kommentar oben.
let cut_input = current.clone();
run_sidecar(
&app,
"auto-editor",
vec![
cut_input.to_string_lossy().to_string(),
"--margin".into(),
margin,
// Bitte beim ersten Testlauf prüfen, ob dieses Flag in der installierten
// auto-editor Version noch so heißt (verhindert, dass sich nach dem
// Schneiden automatisch ein Videoplayer öffnet).
"--no-open".into(),
// Weicher Übergang statt hartem Jump Cut: auto-editor blendet Bild und
// Ton an jedem Schnittpunkt kurz ineinander (0,15s), statt hart zu
// schneiden. Die zweite Zahl (0,05s) sorgt dafür, dass auch sehr kurze
// Schnitte (z.B. ein einzelnes rausgeschnittenes Füllwort) noch eine
// Überblendung bekommen, auto-editor würde sonst nur Schnitte ab 1
// Sekunde Länge überblenden. Nutzerfeedback vom 2026-08-19: harte,
// unruhige Schnittpunkte im Bild. Wichtig: die Zeitangaben brauchen
// zwingend eine Einheit ("sec"), eine nackte Kommazahl wie "0.15"
// lehnt auto-editor mit "Time format expects an integer" ab (per
// echtem Testlauf am 2026-08-19 bestätigt).
"--transition".into(),
"dissolve:0.15sec:0.05sec".into(),
"-o".into(),
cut_path.to_string_lossy().to_string(),
],
"cut",
)
.await
.map_err(|e| {
emit_progress(&app, "cut", "error", Some(e.clone()), None);
e
})?;
// Zwischenergebnis aus Schritt 1 aufräumen, aber niemals die Originaldatei
// des Nutzers löschen (die liegt außerhalb des Temp-Ordners, falls
// Entrauschen deaktiviert war und `cut_input` noch auf sie zeigt).
if cut_input.as_path() != input {
let _ = fs::remove_file(&cut_input);
}
current = cut_path;
emit_progress(&app, "cut", "done", Some("fertig".into()), None);
// ---------- 3) Klangveredelung & Lautstärke (FFmpeg), optional ----------
let final_path = work_dir.join(format!("{stem}_nova.{ext}"));
if options.loudnorm {
emit_progress(&app, "loudnorm", "running", Some("startet…".into()), None);
// Bewusst Einzelpass-Loudnorm statt klassischem Zweipass-Verfahren: kein
// Parsen von FFmpeg Messwerten aus stderr nötig, dadurch robuster. Etwas
// weniger präzise als Zweipass, für Sprachaufnahmen in der Praxis meist
// ausreichend. Lässt sich als Ausbaustufe später nachrüsten.
let filter = format!("loudnorm=I={}:TP=-1.5:LRA=11", options.loudnorm_target);
//
// Vor loudnorm zusätzlich vier Klangschritte, weil reines loudnorm nur die
// Lautheit regelt, aber keine Klangfarbe. Stand 2026-08-19, zweite
// Nachschärfung: nach dem ersten Versuch (Low/High Shelf) war der Klang
// laut Nutzerfeedback immer noch "bassbetont und im mittleren Bereich
// etwas topfig". Deshalb jetzt: 1) echter Hochpassfilter bei 90 Hz statt
// eines sanften Shelfs, entfernt Trittschall/Dröhnen konsequenter (Wert
// stammt aus einer externen Analyse-Empfehlung, die JJ eingeholt hat).
// 2) gezielte, moderate Absenkung um 350 Hz, genau der Bereich, der für
// den "topfigen" Klang bei nah sitzenden Lavaliermikrofonen typisch ist,
// das hatte die externe Empfehlung noch nicht abgedeckt. 3) ein
// schmalerer Presence Boost um 3500 Hz statt eines breiten High Shelf ab
// 4000 Hz, wirkt natürlicher (ebenfalls aus der externen Empfehlung
// übernommen). 4) unveränderter, leichter Kompressor für gleichmäßigere
// Aussteuerung. Alle Werte bewusst moderat, um keine unnatürlichen oder
// metallischen Artefakte zu erzeugen.
let filter = format!(
"highpass=f=90,equalizer=f=350:t=q:w=1.5:g=-3,equalizer=f=3500:t=q:w=1.2:g=3.5,acompressor=threshold=0.1:ratio=2.5:attack=15:release=200:makeup=1.5,loudnorm=I={}:TP=-1.5:LRA=11",
options.loudnorm_target
);
run_sidecar(
&app,
"ffmpeg",
@ -343,9 +560,7 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
emit_progress(&app, "loudnorm", "done", Some("übersprungen".into()), None);
}
Ok(ProcessResult {
output_path: final_path.to_string_lossy().to_string(),
})
Ok(final_path)
}
fn main() {
@ -357,7 +572,11 @@ fn main() {
pick_video_file,
save_output_as,
check_tools,
process_video
process_video,
load_settings,
save_settings,
pick_save_folder,
auto_save_output
])
.run(tauri::generate_context!())
.expect("Fehler beim Starten der Tauri Anwendung");

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "NovaPhonic",
"version": "0.1.1",
"version": "0.1.3",
"identifier": "com.kiwerkepro.novaphonic",
"build": {
"frontendDist": "../../app"
@ -44,7 +44,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMxOUJBODQxRTFFMzNFRjcKUldUM1B1UGhRYWliTVIySHBRUno3R2NvSC9SV2hzV1BXVFp3Rit5eThhSVBvdmxJM1k3ZzBLV1oK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE1NzVEMkZBOEM1NDhGRApSV1Q5U01Xb0wxMVhDaENFOHRPV29oeUxkdmEzYkQ1NTV6NHE0NHUwbERYeUliRkErVXBoU3AvTwo=",
"endpoints": [
"https://github.com/kiwerkepro-org/novaphonic/releases/latest/download/latest.json"
]