Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

8 changed files with 103 additions and 552 deletions

View file

@ -1,23 +1,11 @@
name: Windows Installer bauen 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: on:
push: push:
branches: [main] branches: [main]
paths:
- 'tauri-app/**'
- '.github/workflows/build.yml'
tags: tags:
- 'v*.*.*' - 'v*.*.*'
workflow_dispatch: {} workflow_dispatch: {}
@ -34,8 +22,6 @@ jobs:
steps: steps:
- name: Code auschecken - name: Code auschecken
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Node.js einrichten - name: Node.js einrichten
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -43,11 +29,7 @@ jobs:
node-version: 20 node-version: 20
- name: Rust einrichten - name: Rust einrichten
shell: pwsh uses: dtolnay/rust-toolchain@stable
run: |
rustup toolchain install stable --profile minimal
rustup default stable
rustup target add x86_64-pc-windows-msvc
- name: Node Abhängigkeiten installieren - name: Node Abhängigkeiten installieren
working-directory: tauri-app working-directory: tauri-app
@ -61,44 +43,24 @@ jobs:
New-Item -ItemType Directory -Force -Path $binDir | Out-Null New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$triple = "x86_64-pc-windows-msvc" $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..." Write-Host "Lade auto-editor..."
Invoke-WebRequest -Uri "https://github.com/WyattBlue/auto-editor/releases/latest/download/auto-editor-windows-x86_64.exe" ` Invoke-WebRequest -Uri "https://github.com/WyattBlue/auto-editor/releases/latest/download/auto-editor-windows-x86_64.exe" `
-OutFile "$binDir/auto-editor-$triple.exe" -OutFile "$binDir/auto-editor-$triple.exe"
Assert-ValidExe "$binDir/auto-editor-$triple.exe"
Write-Host "Lade FFmpeg (gyan.dev essentials build)..." Write-Host "Lade FFmpeg (gyan.dev essentials build)..."
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip" Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip"
Expand-Archive -Path "ffmpeg.zip" -DestinationPath "ffmpeg-extracted" Expand-Archive -Path "ffmpeg.zip" -DestinationPath "ffmpeg-extracted"
$ffmpegExe = Get-ChildItem -Path "ffmpeg-extracted" -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1 $ffmpegExe = Get-ChildItem -Path "ffmpeg-extracted" -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1
Copy-Item $ffmpegExe.FullName "$binDir/ffmpeg-$triple.exe" Copy-Item $ffmpegExe.FullName "$binDir/ffmpeg-$triple.exe"
Assert-ValidExe "$binDir/ffmpeg-$triple.exe"
Write-Host "Ermittle aktuelle DeepFilterNet Windows Datei..." 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" } $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)deep-filter-.*x86_64-pc-windows-msvc\.exe$" } | Select-Object -First 1 $winAsset = $release.assets | Where-Object { $_.name -match "(?i)win" -and $_.name -match "(?i)deep-filter" } | Select-Object -First 1
if (-not $winAsset) { 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-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)" Write-Host "Gefunden: $($winAsset.name)"
Invoke-WebRequest -Uri $winAsset.browser_download_url -OutFile "deepfilter-download" -Headers @{ "User-Agent" = "novaphonic-build" } Invoke-WebRequest -Uri $winAsset.browser_download_url -OutFile "deepfilter-download"
if ($winAsset.name -match "\.zip$") { if ($winAsset.name -match "\.zip$") {
Expand-Archive -Path "deepfilter-download" -DestinationPath "deepfilter-extracted" Expand-Archive -Path "deepfilter-download" -DestinationPath "deepfilter-extracted"
$dfExe = Get-ChildItem -Path "deepfilter-extracted" -Recurse -Filter "*.exe" | Select-Object -First 1 $dfExe = Get-ChildItem -Path "deepfilter-extracted" -Recurse -Filter "*.exe" | Select-Object -First 1
@ -106,7 +68,6 @@ jobs:
} else { } else {
Copy-Item "deepfilter-download" "$binDir/deep-filter-$triple.exe" 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) - name: Nur bauen, kein Release (normaler Push ohne Versions-Tag)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
@ -115,117 +76,22 @@ jobs:
- name: Installer als Artefakt hochladen (nur bei normalem Push) - name: Installer als Artefakt hochladen (nur bei normalem Push)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: novaphonic-windows-installer name: novaphonic-windows-installer
path: | path: |
tauri-app/src-tauri/target/release/bundle/nsis/*.exe tauri-app/src-tauri/target/release/bundle/nsis/*.exe
- name: Änderungsliste seit dem letzten Tag ermitteln (nur bei Versions-Tag) - name: Bauen, signieren und GitHub Release veröffentlichen (nur bei Versions-Tag)
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
id: changelog uses: tauri-apps/tauri-action@v0
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: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }} with:
run: | projectPath: tauri-app
$ErrorActionPreference = "Stop" tagName: ${{ github.ref_name }}
$repoApi = "https://forgejo.kiw-schmiede.eu/api/v1/repos/kiw-schmiede/novaphonic" releaseName: 'NovaPhonic ${{ github.ref_name }}'
$headers = @{ Authorization = "token $env:FORGEJO_TOKEN" } releaseBody: 'Automatisch erstelltes Release. Enthält den Windows Installer sowie das Update Manifest für die In App Aktualisierung.'
$tag = "${{ github.ref_name }}" releaseDraft: false
prerelease: false
$bundleDir = "tauri-app/src-tauri/target/release/bundle/nsis" includeUpdaterJson: true
$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,67 +148,8 @@
loudnormToggle.addEventListener('change', syncLoudnormOptions); loudnormToggle.addEventListener('change', syncLoudnormOptions);
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 ---------- // ---------- Pipeline Ablauf ----------
const STEP_IDS = ['denoise', 'cut', 'loudnorm']; const STEP_IDS = ['cut', 'denoise', 'loudnorm'];
function resetSteps() { function resetSteps() {
for (const id of STEP_IDS) { for (const id of STEP_IDS) {
@ -274,31 +215,8 @@
} }
}); });
async function showResult(result) { function showResult(result) {
const name = result.outputPath.split(/[\\/]/).pop(); 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 = $('resultArea').innerHTML =
'<div class="result-box">' + '<div class="result-box">' +
'<span class="rb-name">' + name.replace(/</g, '&lt;') + '</span>' + '<span class="rb-name">' + name.replace(/</g, '&lt;') + '</span>' +
@ -386,6 +304,5 @@
// ---------- Start ---------- // ---------- Start ----------
initProgressListener(); initProgressListener();
checkTools(); checkTools();
loadSettings();
checkForUpdate(true); checkForUpdate(true);
})(); })();

View file

@ -52,6 +52,11 @@
nichts hochgeladen und keine Verbindung zu einem Server aufgebaut. nichts hochgeladen und keine Verbindung zu einem Server aufgebaut.
</div> </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 class="grid">
<div> <div>
<div class="panel"> <div class="panel">
@ -81,28 +86,15 @@
<option value="-14">-14 LUFS (Streaming, laut)</option> <option value="-14">-14 LUFS (Streaming, laut)</option>
</select> </select>
</div> </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>
<div class="panel"> <div class="panel">
<h2>Über die Werkzeuge</h2> <h2>Über die Werkzeuge</h2>
<div class="hint-small"> <div class="hint-small">
Schnitt: <strong>auto-editor</strong> (Public Domain). Entrauschen: Schnitt: <strong>auto-editor</strong> (Public Domain). Entrauschen:
<strong>DeepFilterNet</strong> (MIT/Apache 2.0). Klangveredelung &amp; <strong>DeepFilterNet</strong> (MIT/Apache 2.0). Lautstärke: <strong>FFmpeg</strong>
Lautstärke: <strong>FFmpeg</strong> (EQ, Kompressor, loudnorm Filter, LGPL/GPL). loudnorm Filter (LGPL/GPL). Alle drei laufen als eigenständige Programme neben
Alle drei laufen als eigenständige Programme neben NovaPhonic auf diesem NovaPhonic auf diesem Rechner, keines davon braucht eine Grafikkarte.
Rechner, keines davon braucht eine Grafikkarte.
</div> </div>
</div> </div>
</div> </div>
@ -137,19 +129,19 @@
<div class="panel"> <div class="panel">
<h2>Ablauf</h2> <h2>Ablauf</h2>
<div class="pipeline-steps"> <div class="pipeline-steps">
<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"> <div class="pipeline-step" id="step-cut">
<span class="ps-dot"></span> <span class="ps-dot"></span>
<span class="ps-label">Füllwörter &amp; Stille schneiden</span> <span class="ps-label">Füllwörter &amp; Stille schneiden</span>
<span class="ps-detail" id="step-cut-detail"></span> <span class="ps-detail" id="step-cut-detail"></span>
</div> </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-loudnorm"> <div class="pipeline-step" id="step-loudnorm">
<span class="ps-dot"></span> <span class="ps-dot"></span>
<span class="ps-label">Klang veredeln &amp; Lautstärke angleichen</span> <span class="ps-label">Lautstärke angleichen</span>
<span class="ps-detail" id="step-loudnorm-detail"></span> <span class="ps-detail" id="step-loudnorm-detail"></span>
</div> </div>
</div> </div>
@ -174,11 +166,6 @@
Veröffentlichung kurz ansehen. Veröffentlichung kurz ansehen.
</div> </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> </div>
<script src="app.js"></script> <script src="app.js"></script>

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "novaphonic", "name": "novaphonic",
"version": "0.1.3", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"tauri": "tauri" "tauri": "tauri"
@ -9,4 +9,3 @@
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
} }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "novaphonic" name = "novaphonic"
version = "0.1.3" version = "0.1.0"
edition = "2021" edition = "2021"
description = "NovaPhonic, ein Tool von KI-WERKE zum automatischen Videoschnitt und zur Audio Veredelung" description = "NovaPhonic, ein Tool von KI-WERKE zum automatischen Videoschnitt und zur Audio Veredelung"
@ -19,4 +19,3 @@ tauri-plugin-shell = "2"
[[bin]] [[bin]]
name = "novaphonic" name = "novaphonic"
path = "src/main.rs" path = "src/main.rs"

View file

@ -6,21 +6,18 @@
// Dateipfade verarbeitet, nie Videoinhalte selbst in den Arbeitsspeicher der // Dateipfade verarbeitet, nie Videoinhalte selbst in den Arbeitsspeicher der
// Oberfläche geladen. // Oberfläche geladen.
// //
// Alle Zwischenschritte (geschnittenes Video, rohe/entrauschte Tonspur, usw.) // Wichtiger Hinweis für den ersten echten Build: In dieser Entwicklungsumgebung
// laufen in einem echten temporären Systemordner (std::env::temp_dir()), nicht im // steht kein Rust Compiler zur Verfügung, dieser Code wurde also nicht kompiliert,
// Ordner der Eingabedatei. Grund: Bei einem echten Testlauf am 2026-08-19 lag das // nur sorgfältig von Hand geprüft. Zwei Stellen bitte beim ersten Testlauf gezielt
// Eingabevideo in einem Nextcloud-synchronisierten Ordner, der Sync-Client hat // gegenprüfen: 1) die genauen Kommandozeilen-Optionen von `deep-filter` (siehe
// neu entstehende Zwischen-Dateien sofort selbst kurz geöffnet, was zu "Zugriff // `deep-filter --help`, hier als `-o <Zieldatei>` angenommen), 2) ob `auto-editor`
// verweigert" Fehlern führte, sobald NovaPhonic dieselbe Datei gleich danach // den Flag `--no-open` in der installierten Version so kennt. Beides ist unten mit
// weiterverarbeiten wollte. Der temporäre Arbeitsordner wird bei einem Fehler // Kommentaren markiert.
// 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 serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter};
use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
@ -50,118 +47,12 @@ fn save_output_as(source_path: String, suggested_name: String) -> Result<String,
Some(dest) => { Some(dest) => {
fs::copy(&source_path, &dest) fs::copy(&source_path, &dest)
.map_err(|e| format!("Konnte Datei nicht speichern: {e}"))?; .map_err(|e| format!("Konnte Datei nicht speichern: {e}"))?;
cleanup_temp_work_dir(&source_path);
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
None => Err("abgebrochen".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 // Wichtig: Tauri spricht Sidecar Programme zur Laufzeit nur über ihren bloßen
// Namen an (z.B. "auto-editor"), der Ordnerpfad "binaries/" wird ausschließlich // Namen an (z.B. "auto-editor"), der Ordnerpfad "binaries/" wird ausschließlich
// beim Einrichten in tauri.conf.json (externalBin) und in den Capabilities // beim Einrichten in tauri.conf.json (externalBin) und in den Capabilities
@ -295,58 +186,44 @@ async fn process_video(app: AppHandle, options: ProcessOptions) -> Result<Proces
let stem = input let stem = input
.file_stem() .file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or("video") .unwrap_or("video");
.to_string(); let ext = input.extension().and_then(|s| s.to_str()).unwrap_or("mp4");
let ext = input let parent = input.parent().unwrap_or_else(|| Path::new("."));
.extension()
.and_then(|s| s.to_str())
.unwrap_or("mp4")
.to_string();
// Eigener, garantiert eindeutiger Arbeitsordner im System-Temp-Verzeichnis, // ---------- 1) Schnitt (auto-editor) ----------
// pro Verarbeitungslauf neu, damit sich mehrere Läufe nie in die Quere kommen. emit_progress(&app, "cut", "running", Some("startet…".into()), None);
let work_dir = let cut_path = parent.join(format!("{stem}_geschnitten.{ext}"));
std::env::temp_dir().join(format!("novaphonic-{stem}-{}", std::process::id())); let margin = format!("{}sec", options.margin_seconds);
fs::create_dir_all(&work_dir).map_err(|e| e.to_string())?; 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);
match run_pipeline(&app, &options, &input, &work_dir, &stem, &ext).await { let mut current = cut_path;
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)
}
}
}
async fn run_pipeline( // ---------- 2) Entrauschen (DeepFilterNet), optional ----------
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 { if options.denoise {
emit_progress(app, "denoise", "running", Some("startet…".into()), None); emit_progress(&app, "denoise", "running", Some("startet…".into()), None);
let raw_audio = work_dir.join(format!("{stem}_audio.wav")); let raw_audio = parent.join(format!("{stem}_audio.wav"));
let denoised_audio = work_dir.join(format!("{stem}_entrauscht.wav")); let denoised_audio = parent.join(format!("{stem}_entrauscht.wav"));
run_sidecar( run_sidecar(
&app, &app,
@ -372,29 +249,18 @@ async fn run_pipeline(
e e
})?; })?;
// deep-filter erwartet bei -o einen AusgabeORDNER, keine Zieldatei, und // Achtung, bitte gegen `deep-filter --help` prüfen: hier wird eine exakte
// schreibt dort eine Datei mit demselben Namen wie die Eingabedatei hinein // Ausgabedatei per -o angenommen. Manche deep-filter Versionen erwarten
// (per echtem Testlauf bestätigt, 2026-08-19). Deshalb erst in einen eigenen // stattdessen einen Ausgabeordner (z.B. -o <Ordner>), in dem eine Datei
// Zwischenordner schreiben lassen und die entstandene Datei danach auf den // mit demselben Namen wie die Eingabe erzeugt wird. Falls das der Fall
// gewünschten Namen umbenennen. // ist, muss diese Stelle entsprechend angepasst werden.
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( run_sidecar(
&app, &app,
"deep-filter", "deep-filter",
vec![ vec![
raw_audio.to_string_lossy().to_string(), 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(), "-o".into(),
denoise_out_dir.to_string_lossy().to_string(), denoised_audio.to_string_lossy().to_string(),
], ],
"denoise", "denoise",
) )
@ -403,24 +269,8 @@ async fn run_pipeline(
emit_progress(&app, "denoise", "error", Some(e.clone()), None); emit_progress(&app, "denoise", "error", Some(e.clone()), None);
e 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 = work_dir.join(format!("{stem}_entrauscht_video.{ext}")); let remuxed = parent.join(format!("{stem}_entrauscht_video.{ext}"));
run_sidecar( run_sidecar(
&app, &app,
"ffmpeg", "ffmpeg",
@ -449,89 +299,22 @@ async fn run_pipeline(
let _ = fs::remove_file(&raw_audio); let _ = fs::remove_file(&raw_audio);
let _ = fs::remove_file(&denoised_audio); let _ = fs::remove_file(&denoised_audio);
let _ = fs::remove_file(&current);
current = remuxed; current = remuxed;
emit_progress(&app, "denoise", "done", Some("fertig".into()), None); emit_progress(&app, "denoise", "done", Some("fertig".into()), None);
} else { } else {
emit_progress(&app, "denoise", "done", Some("übersprungen".into()), None); emit_progress(&app, "denoise", "done", Some("übersprungen".into()), None);
} }
// ---------- 2) Schnitt (auto-editor) ---------- // ---------- 3) Lautstärke & Klangbalance (FFmpeg loudnorm), optional ----------
emit_progress(app, "cut", "running", Some("startet…".into()), None); let final_path = parent.join(format!("{stem}_nova.{ext}"));
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 { if options.loudnorm {
emit_progress(&app, "loudnorm", "running", Some("startet…".into()), None); emit_progress(&app, "loudnorm", "running", Some("startet…".into()), None);
// Bewusst Einzelpass-Loudnorm statt klassischem Zweipass-Verfahren: kein // Bewusst Einzelpass-Loudnorm statt klassischem Zweipass-Verfahren: kein
// Parsen von FFmpeg Messwerten aus stderr nötig, dadurch robuster. Etwas // Parsen von FFmpeg Messwerten aus stderr nötig, dadurch robuster. Etwas
// weniger präzise als Zweipass, für Sprachaufnahmen in der Praxis meist // weniger präzise als Zweipass, für Sprachaufnahmen in der Praxis meist
// ausreichend. Lässt sich als Ausbaustufe später nachrüsten. // 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( run_sidecar(
&app, &app,
"ffmpeg", "ffmpeg",
@ -560,7 +343,9 @@ async fn run_pipeline(
emit_progress(&app, "loudnorm", "done", Some("übersprungen".into()), None); emit_progress(&app, "loudnorm", "done", Some("übersprungen".into()), None);
} }
Ok(final_path) Ok(ProcessResult {
output_path: final_path.to_string_lossy().to_string(),
})
} }
fn main() { fn main() {
@ -572,11 +357,7 @@ fn main() {
pick_video_file, pick_video_file,
save_output_as, save_output_as,
check_tools, check_tools,
process_video, process_video
load_settings,
save_settings,
pick_save_folder,
auto_save_output
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Fehler beim Starten der Tauri Anwendung"); .expect("Fehler beim Starten der Tauri Anwendung");

View file

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