mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Improve FFmpeg encoder probing on macOS; minor fixes for Windows/Linux
Refine UI across the app Optimize mobile and narrow-width layouts
This commit is contained in:
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
# WINDOWS BUILD (MSI + EXE)
|
||||
# WINDOWS BUILD (EXE)
|
||||
# ------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
build-windows:
|
||||
name: Windows ${{ matrix.target_name }}
|
||||
@@ -38,19 +38,6 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install WiX Toolset
|
||||
shell: powershell
|
||||
run: |
|
||||
choco install wixtoolset -y
|
||||
$wixBin = Get-ChildItem "C:\\Program Files (x86)\\WiX Toolset v3.*\\bin" |
|
||||
Sort-Object FullName -Descending |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $wixBin) { throw "WiX Toolset bin directory not found." }
|
||||
echo $wixBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Install cargo-wix
|
||||
run: cargo install cargo-wix
|
||||
|
||||
# Frontend Build
|
||||
- name: Build Frontend
|
||||
shell: bash
|
||||
@@ -59,16 +46,11 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
# Backend Build & MSI Generation
|
||||
# using cargo wix (which runs cargo build --release underneath)
|
||||
- name: Build MSI and EXE
|
||||
# Backend Build (EXE)
|
||||
- name: Build EXE
|
||||
shell: cmd
|
||||
env:
|
||||
# We pass the target architecture to wix.
|
||||
# For aarch64, this might fail if Wix 3 doesn't support it fully, but we try.
|
||||
CARGO_WIX_EXTRA_ARGS: "--nocapture -v"
|
||||
run: |
|
||||
cargo wix --target ${{ matrix.target }} --output target\wix\alchemist-${{ matrix.target_name }}.msi
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
# Move EXE to a predictable name for upload
|
||||
- name: Prepare EXE
|
||||
@@ -81,7 +63,6 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
target/wix/alchemist-${{ matrix.target_name }}.msi
|
||||
alchemist-${{ matrix.target_name }}.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -146,15 +127,23 @@ jobs:
|
||||
<string>com.alchemist.app</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${{ github.ref_name }}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${{ github.ref_name }}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.video</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Ad-hoc codesign to reduce "damaged" warnings on launch
|
||||
codesign --force --deep --sign - "$APP_DIR"
|
||||
xattr -cr "$APP_DIR" || true
|
||||
|
||||
# Zip for release (No signing/notarization)
|
||||
ditto -c -k --keepParent "$APP_DIR" "alchemist-macos-${{ matrix.target_name }}.zip"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v0.2.6-2] - 2026-01-12
|
||||
## [v0.2.7] - 2026-01-12
|
||||
- Setup wizard auth fixes, scheduler time validation, and watcher reliability improvements.
|
||||
- DB stability pass (WAL, FK enforcement, indexes, session cleanup, legacy watch_dirs compatibility).
|
||||
- Build pipeline updates (rustls for reqwest, cross-platform build script, WiX workflow fix).
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -26,7 +26,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alchemist"
|
||||
version = "0.2.6-2"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alchemist"
|
||||
version = "0.2.6-2"
|
||||
version = "0.2.7"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -6,13 +6,14 @@ Alchemist is a Rust-based video transcoding system that automatically converts y
|
||||
|
||||
## Features
|
||||
|
||||
- **Hardware Acceleration**: Supports NVIDIA (NVENC), Intel (QSV), AMD (VAAPI) (macOS VideoToolbox is experimental/unsupported)
|
||||
- **Hardware Acceleration**: Supports NVIDIA (NVENC), Intel (QSV), AMD (VAAPI), Apple (VideoToolbox)
|
||||
- **CPU Fallback**: Automatic software encoding with libsvtav1 when GPU is unavailable
|
||||
- **Intelligent Analysis**: Only transcodes files that will benefit from AV1 encoding
|
||||
- **Web Dashboard**: Real-time monitoring and control via Axum/Askama/HTMX-based UI
|
||||
- **Single Binary**: All assets and templates are embedded into the binary for easy deployment
|
||||
- **Background Processing**: Queue-based system with concurrent job support
|
||||
- **Performance Metrics**: Detailed logging and statistics for each transcode job
|
||||
- **HDR Handling**: Preserve HDR metadata or tonemap to SDR for compatibility
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -32,8 +33,8 @@ cd alchemist
|
||||
# Build the project
|
||||
cargo build --release
|
||||
|
||||
# Run the server
|
||||
./target/release/alchemist --server
|
||||
# Run the server (default)
|
||||
./target/release/alchemist
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
@@ -88,17 +89,24 @@ directories = [ # Auto-scan directories
|
||||
|
||||
```bash
|
||||
# Scan and transcode specific directories
|
||||
alchemist /path/to/videos /another/path
|
||||
alchemist --cli --dir /path/to/videos --dir /another/path
|
||||
|
||||
# Dry run (analyze only, don't transcode)
|
||||
alchemist --dry-run /path/to/videos
|
||||
alchemist --cli --dry-run --dir /path/to/videos
|
||||
```
|
||||
|
||||
### Server Mode
|
||||
|
||||
```bash
|
||||
# Start web server on default port (3000)
|
||||
alchemist --server
|
||||
alchemist
|
||||
```
|
||||
|
||||
### Reset Auth
|
||||
|
||||
```bash
|
||||
# Clear users/sessions and re-run setup
|
||||
alchemist --reset-auth
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -184,7 +184,7 @@ cd .. && cargo build --release
|
||||
|
||||
```bash
|
||||
# Pull from GitHub Container Registry
|
||||
docker pull ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
docker pull ghcr.io/bybrooklyn/alchemist:latest
|
||||
|
||||
# Or build locally
|
||||
docker build -t alchemist .
|
||||
@@ -212,9 +212,12 @@ Configuration is stored in `config.toml`. On first run, the setup wizard creates
|
||||
# TRANSCODING SETTINGS
|
||||
#──────────────────────────────────────────────────────────────────
|
||||
[transcode]
|
||||
# Output codec: "av1" or "hevc"
|
||||
# Preferred codec: "av1", "hevc", or "h264"
|
||||
output_codec = "av1"
|
||||
|
||||
# Allow fallback to other codecs if preferred is unavailable
|
||||
allow_fallback = true
|
||||
|
||||
# Quality profile: "quality", "balanced", or "speed"
|
||||
quality_profile = "balanced"
|
||||
|
||||
@@ -232,6 +235,18 @@ min_file_size_mb = 50
|
||||
# Number of concurrent transcode jobs
|
||||
concurrent_jobs = 2
|
||||
|
||||
# HDR handling: "preserve" (keep HDR) or "tonemap" (convert to SDR)
|
||||
hdr_mode = "preserve"
|
||||
|
||||
# Tonemap algorithm: "hable", "mobius", "reinhard", or "clip"
|
||||
tonemap_algorithm = "hable"
|
||||
|
||||
# Tonemap peak luminance (nits)
|
||||
tonemap_peak = 100
|
||||
|
||||
# Tonemap desaturation (0.0 - 1.0)
|
||||
tonemap_desat = 0.2
|
||||
|
||||
#──────────────────────────────────────────────────────────────────
|
||||
# HARDWARE ACCELERATION
|
||||
#──────────────────────────────────────────────────────────────────
|
||||
@@ -530,7 +545,13 @@ Content-Type: application/json
|
||||
"min_bpp_threshold": 0.1,
|
||||
"min_file_size_mb": 50,
|
||||
"output_codec": "av1",
|
||||
"quality_profile": "balanced"
|
||||
"allow_fallback": true,
|
||||
"quality_profile": "balanced",
|
||||
"threads": 0,
|
||||
"hdr_mode": "preserve",
|
||||
"tonemap_algorithm": "hable",
|
||||
"tonemap_peak": 100,
|
||||
"tonemap_desat": 0.2
|
||||
}
|
||||
```
|
||||
|
||||
@@ -687,7 +708,7 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v /path/to/media:/media \
|
||||
-v alchemist_data:/app/data \
|
||||
ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
ghcr.io/bybrooklyn/alchemist:latest
|
||||
```
|
||||
|
||||
### With NVIDIA GPU
|
||||
@@ -699,7 +720,7 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v /path/to/media:/media \
|
||||
-v alchemist_data:/app/data \
|
||||
ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
ghcr.io/bybrooklyn/alchemist:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
@@ -708,7 +729,7 @@ docker run -d \
|
||||
version: "3.8"
|
||||
services:
|
||||
alchemist:
|
||||
image: ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
image: ghcr.io/bybrooklyn/alchemist:latest
|
||||
container_name: alchemist
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -830,10 +851,27 @@ cargo check
|
||||
cargo test
|
||||
|
||||
# Run with debug logging
|
||||
RUST_LOG=debug cargo run -- --server
|
||||
RUST_LOG=debug cargo run --
|
||||
|
||||
# Run in production mode
|
||||
./target/release/alchemist --server
|
||||
./target/release/alchemist
|
||||
```
|
||||
|
||||
#### CLI Mode
|
||||
|
||||
```bash
|
||||
# Process directories in CLI mode
|
||||
./target/release/alchemist --cli --dir /path/to/videos --dir /another/path
|
||||
|
||||
# Dry run (analyze only)
|
||||
./target/release/alchemist --cli --dry-run --dir /path/to/videos
|
||||
```
|
||||
|
||||
#### Reset Auth
|
||||
|
||||
```bash
|
||||
# Clear users/sessions and re-run setup
|
||||
./target/release/alchemist --reset-auth
|
||||
```
|
||||
|
||||
#### Frontend (Astro + React)
|
||||
@@ -1044,7 +1082,7 @@ Run with verbose logging to diagnose issues:
|
||||
|
||||
```bash
|
||||
# Binary
|
||||
RUST_LOG=debug ./alchemist --server
|
||||
RUST_LOG=debug ./alchemist
|
||||
|
||||
# Docker
|
||||
docker run -e RUST_LOG=debug ...
|
||||
@@ -1150,7 +1188,7 @@ docker run -v /path/on/host:/media ...
|
||||
**Q: How do I update to a new version?**
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
docker pull ghcr.io/bybrooklyn/alchemist:latest
|
||||
docker stop alchemist
|
||||
docker rm alchemist
|
||||
docker run ... # same options as before
|
||||
@@ -1182,6 +1220,25 @@ A:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.2.7
|
||||
- ✅ Default server mode; explicit CLI with `--cli --dir ...` and new `--reset-auth` flow
|
||||
- ✅ Login redirects to setup when no users exist
|
||||
- ✅ Startup banner now includes version/build info
|
||||
- ✅ Telemetry reliability: shared client, timeout, retry/backoff, and speed sanitization
|
||||
- ✅ Dashboard redesign, spacing polish, dynamic Quick Start, and mobile responsiveness
|
||||
- ✅ Sidebar brand/logo now links to dashboard
|
||||
- ✅ Settings nav auto-scrolls to active tab; section separators removed
|
||||
- ✅ Setup wizard: telemetry toggle removed; CPU encoding defaults on
|
||||
- ✅ Added 10 new themes, including dark mint (`Mint Night`)
|
||||
- ✅ Release pipeline: Windows EXE-only; macOS app ad-hoc signed with improved Info.plist
|
||||
- ✅ VideoToolbox quality tuning + HEVC `hvc1` tagging; AV1→HEVC fallback for Apple
|
||||
- ✅ HDR metadata detection, tone mapping controls, and color metadata preservation
|
||||
- ✅ Transcode settings expanded: HDR/tonemap options + fallback policy
|
||||
- ✅ Codec/encoder negotiation: AV1 is preference with hardware/CPU fallback chain
|
||||
- ✅ FFmpeg encoder cache probed at startup; no hard assumptions about encoder availability
|
||||
- ✅ Planner logic refined: H.264 always transcodes; BPP gate only when bitrate/fps are known
|
||||
- ✅ Jobs UI upgraded: in‑app confirm modals, contextual action menu, retry/cancel/delete polish
|
||||
|
||||
### v0.2.6-2
|
||||
- ✅ Setup wizard now authenticates scan and hardware calls to prevent endless loading
|
||||
- ✅ Scheduler window validation and normalized time handling
|
||||
@@ -1231,4 +1288,4 @@ Alchemist is licensed under the **GPL-3.0 License**. See `LICENSE` for details.
|
||||
|
||||
---
|
||||
|
||||
*Documentation for Alchemist v0.2.6-2*
|
||||
*Documentation for Alchemist v0.2.7*
|
||||
|
||||
@@ -29,7 +29,7 @@ sudo systemctl restart docker
|
||||
```yaml
|
||||
services:
|
||||
alchemist:
|
||||
image: ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
image: ghcr.io/bybrooklyn/alchemist:latest
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
docker run --gpus all \
|
||||
-p 3000:3000 \
|
||||
-v /media:/media \
|
||||
ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
ghcr.io/bybrooklyn/alchemist:latest
|
||||
```
|
||||
|
||||
---
|
||||
@@ -73,7 +73,7 @@ vainfo
|
||||
```yaml
|
||||
services:
|
||||
alchemist:
|
||||
image: ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
image: ghcr.io/bybrooklyn/alchemist:latest
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
@@ -91,7 +91,7 @@ docker run --device /dev/dri:/dev/dri \
|
||||
-e LIBVA_DRIVER_NAME=iHD \
|
||||
-p 3000:3000 \
|
||||
-v /media:/media \
|
||||
ghcr.io/brooklynloveszelda/alchemist:latest
|
||||
ghcr.io/bybrooklyn/alchemist:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -68,6 +68,15 @@ impl QualityProfile {
|
||||
Self::Speed => "p1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg quality value for Apple VideoToolbox
|
||||
pub fn videotoolbox_quality(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Quality => "55",
|
||||
Self::Balanced => "65",
|
||||
Self::Speed => "75",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -114,6 +123,7 @@ pub enum OutputCodec {
|
||||
#[default]
|
||||
Av1,
|
||||
Hevc,
|
||||
H264,
|
||||
}
|
||||
|
||||
impl OutputCodec {
|
||||
@@ -121,6 +131,47 @@ impl OutputCodec {
|
||||
match self {
|
||||
Self::Av1 => "av1",
|
||||
Self::Hevc => "hevc",
|
||||
Self::H264 => "h264",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum HdrMode {
|
||||
#[default]
|
||||
Preserve,
|
||||
Tonemap,
|
||||
}
|
||||
|
||||
impl HdrMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Preserve => "preserve",
|
||||
Self::Tonemap => "tonemap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum TonemapAlgorithm {
|
||||
#[default]
|
||||
Hable,
|
||||
Mobius,
|
||||
Reinhard,
|
||||
Clip,
|
||||
}
|
||||
|
||||
impl TonemapAlgorithm {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Hable => "hable",
|
||||
Self::Mobius => "mobius",
|
||||
Self::Reinhard => "reinhard",
|
||||
Self::Clip => "clip",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +217,16 @@ pub struct TranscodeConfig {
|
||||
pub quality_profile: QualityProfile,
|
||||
#[serde(default)]
|
||||
pub output_codec: OutputCodec,
|
||||
#[serde(default = "default_allow_fallback")]
|
||||
pub allow_fallback: bool,
|
||||
#[serde(default)]
|
||||
pub hdr_mode: HdrMode,
|
||||
#[serde(default)]
|
||||
pub tonemap_algorithm: TonemapAlgorithm,
|
||||
#[serde(default = "default_tonemap_peak")]
|
||||
pub tonemap_peak: f32,
|
||||
#[serde(default = "default_tonemap_desat")]
|
||||
pub tonemap_desat: f32,
|
||||
#[serde(default)]
|
||||
pub subtitle_mode: SubtitleMode,
|
||||
}
|
||||
@@ -189,6 +250,18 @@ fn default_allow_cpu_encoding() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn default_allow_fallback() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn default_tonemap_peak() -> f32 {
|
||||
100.0
|
||||
}
|
||||
|
||||
pub(crate) fn default_tonemap_desat() -> f32 {
|
||||
0.2
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct NotificationsConfig {
|
||||
pub enabled: bool,
|
||||
@@ -251,6 +324,11 @@ impl Default for Config {
|
||||
threads: 0,
|
||||
quality_profile: QualityProfile::Balanced,
|
||||
output_codec: OutputCodec::Av1,
|
||||
allow_fallback: default_allow_fallback(),
|
||||
hdr_mode: HdrMode::Preserve,
|
||||
tonemap_algorithm: TonemapAlgorithm::Hable,
|
||||
tonemap_peak: default_tonemap_peak(),
|
||||
tonemap_desat: default_tonemap_desat(),
|
||||
subtitle_mode: SubtitleMode::Copy,
|
||||
},
|
||||
hardware: HardwareConfig {
|
||||
@@ -320,6 +398,20 @@ impl Config {
|
||||
anyhow::bail!("concurrent_jobs must be >= 1");
|
||||
}
|
||||
|
||||
if self.transcode.tonemap_peak < 50.0 || self.transcode.tonemap_peak > 1000.0 {
|
||||
anyhow::bail!(
|
||||
"tonemap_peak must be between 50 and 1000, got {}",
|
||||
self.transcode.tonemap_peak
|
||||
);
|
||||
}
|
||||
|
||||
if !(0.0..=1.0).contains(&self.transcode.tonemap_desat) {
|
||||
anyhow::bail!(
|
||||
"tonemap_desat must be between 0.0 and 1.0, got {}",
|
||||
self.transcode.tonemap_desat
|
||||
);
|
||||
}
|
||||
|
||||
// Validate VMAF threshold
|
||||
if self.quality.min_vmaf_score < 0.0 || self.quality.min_vmaf_score > 100.0 {
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -1279,6 +1279,12 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reset_auth(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions").execute(&self.pool).await?;
|
||||
sqlx::query("DELETE FROM users").execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn has_column(&self, table: &str, column: &str) -> Result<bool> {
|
||||
let table = table.replace('\'', "''");
|
||||
let sql = format!("PRAGMA table_info('{}')", table);
|
||||
|
||||
38
src/main.rs
38
src/main.rs
@@ -16,8 +16,12 @@ use tokio::sync::RwLock;
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Directories to scan for media files
|
||||
#[arg()]
|
||||
/// Run in CLI mode (process directories and exit)
|
||||
#[arg(long)]
|
||||
cli: bool,
|
||||
|
||||
/// Directories to scan for media files (CLI mode only)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
directories: Vec<PathBuf>,
|
||||
|
||||
/// Dry run (don't actually transcode)
|
||||
@@ -27,10 +31,9 @@ struct Args {
|
||||
/// Output directory (optional, defaults to same as input with .av1)
|
||||
#[arg(short, long)]
|
||||
output_dir: Option<PathBuf>,
|
||||
|
||||
/// Run as web server
|
||||
/// Reset admin user/password and sessions (forces setup mode)
|
||||
#[arg(long)]
|
||||
server: bool,
|
||||
reset_auth: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -99,18 +102,21 @@ async fn run() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
info!(
|
||||
target: "startup",
|
||||
"Parsed CLI args: server_mode={}, dry_run={}, output_dir={:?}, directories={}",
|
||||
args.server,
|
||||
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, output_dir={:?}, directories={}",
|
||||
args.cli,
|
||||
args.reset_auth,
|
||||
args.dry_run,
|
||||
args.output_dir,
|
||||
args.directories.len()
|
||||
);
|
||||
|
||||
// ... rest of logic remains largely the same, just inside run()
|
||||
// Default to server mode if no arguments are provided (e.g. double-click run)
|
||||
// or if explicit --server flag is used
|
||||
let is_server_mode = args.server || args.directories.is_empty();
|
||||
// Default to server mode unless CLI is explicitly requested.
|
||||
let is_server_mode = !args.cli;
|
||||
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
||||
if is_server_mode && !args.directories.is_empty() {
|
||||
warn!("Directories were provided without --cli; ignoring CLI inputs.");
|
||||
}
|
||||
|
||||
// 0. Load Configuration
|
||||
let config_start = Instant::now();
|
||||
@@ -161,6 +167,11 @@ async fn run() -> Result<()> {
|
||||
"Database initialized in {} ms",
|
||||
db_start.elapsed().as_millis()
|
||||
);
|
||||
if args.reset_auth {
|
||||
db.reset_auth().await?;
|
||||
warn!("Auth reset requested. All users and sessions cleared.");
|
||||
setup_mode = true;
|
||||
}
|
||||
if is_server_mode {
|
||||
let users_start = Instant::now();
|
||||
let has_users = db.has_users().await?;
|
||||
@@ -225,6 +236,7 @@ async fn run() -> Result<()> {
|
||||
if let Some(ref path) = hw_info.device_path {
|
||||
info!(" Device Path: {}", path);
|
||||
}
|
||||
alchemist::media::ffmpeg::warm_encoder_cache();
|
||||
|
||||
// Check CPU encoding policy
|
||||
if !setup_mode && hw_info.vendor == hardware::Vendor::Cpu {
|
||||
@@ -472,7 +484,7 @@ async fn run() -> Result<()> {
|
||||
} else {
|
||||
// CLI Mode
|
||||
if setup_mode {
|
||||
error!("Configuration required. Run with --server to use the web-based setup wizard, or create config.toml manually.");
|
||||
error!("Configuration required. Run without --cli to use the web-based setup wizard, or create config.toml manually.");
|
||||
|
||||
// CLI early exit - error
|
||||
// (Caller will handle pause-on-exit if needed)
|
||||
@@ -482,9 +494,7 @@ async fn run() -> Result<()> {
|
||||
}
|
||||
|
||||
if args.directories.is_empty() {
|
||||
error!(
|
||||
"No directories provided. Usage: alchemist <DIRECTORIES>... or alchemist --server"
|
||||
);
|
||||
error!("No directories provided. Usage: alchemist --cli --dir <DIR> [--dir <DIR> ...]");
|
||||
return Err(alchemist::error::AlchemistError::Config(
|
||||
"Missing directories for CLI mode".into(),
|
||||
));
|
||||
|
||||
@@ -22,6 +22,10 @@ pub struct Stream {
|
||||
pub channel_layout: Option<String>,
|
||||
pub channels: Option<u32>,
|
||||
pub r_frame_rate: Option<String>,
|
||||
pub color_primaries: Option<String>,
|
||||
pub color_transfer: Option<String>,
|
||||
pub color_space: Option<String>,
|
||||
pub color_range: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -72,6 +76,10 @@ impl AnalyzerTrait for FfmpegAnalyzer {
|
||||
|
||||
let audio_stream = metadata.streams.iter().find(|s| s.codec_type == "audio");
|
||||
|
||||
let color_transfer = video_stream.color_transfer.clone();
|
||||
let color_primaries = video_stream.color_primaries.clone();
|
||||
let is_hdr = Analyzer::is_hdr(color_transfer.as_deref(), color_primaries.as_deref());
|
||||
|
||||
Ok(MediaMetadata {
|
||||
path: path.clone(),
|
||||
duration_secs: metadata.format.duration.parse().unwrap_or(0.0),
|
||||
@@ -84,6 +92,11 @@ impl AnalyzerTrait for FfmpegAnalyzer {
|
||||
.unwrap_or("8")
|
||||
.parse()
|
||||
.unwrap_or(8),
|
||||
color_primaries,
|
||||
color_transfer,
|
||||
color_space: video_stream.color_space.clone(),
|
||||
color_range: video_stream.color_range.clone(),
|
||||
is_hdr,
|
||||
size_bytes: metadata.format.size.parse().unwrap_or(0),
|
||||
bit_rate: video_stream
|
||||
.bit_rate
|
||||
@@ -230,6 +243,11 @@ impl Analyzer {
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
fn is_hdr(color_transfer: Option<&str>, color_primaries: Option<&str>) -> bool {
|
||||
matches!(color_transfer, Some("smpte2084") | Some("arib-std-b67"))
|
||||
|| matches!(color_primaries, Some("bt2020"))
|
||||
}
|
||||
|
||||
pub fn should_transcode_audio(stream: &Stream) -> bool {
|
||||
if stream.codec_type != "audio" {
|
||||
return false;
|
||||
@@ -281,6 +299,10 @@ mod tests {
|
||||
channel_layout: None,
|
||||
channels: None,
|
||||
r_frame_rate: None,
|
||||
color_primaries: None,
|
||||
color_transfer: None,
|
||||
color_space: None,
|
||||
color_range: None,
|
||||
};
|
||||
assert!(Analyzer::should_transcode_audio(&heavy));
|
||||
|
||||
@@ -294,6 +316,10 @@ mod tests {
|
||||
channel_layout: None,
|
||||
channels: None,
|
||||
r_frame_rate: None,
|
||||
color_primaries: None,
|
||||
color_transfer: None,
|
||||
color_space: None,
|
||||
color_range: None,
|
||||
};
|
||||
assert!(!Analyzer::should_transcode_audio(&standard));
|
||||
|
||||
@@ -307,6 +333,10 @@ mod tests {
|
||||
channel_layout: None,
|
||||
channels: None,
|
||||
r_frame_rate: None,
|
||||
color_primaries: None,
|
||||
color_transfer: None,
|
||||
color_space: None,
|
||||
color_range: None,
|
||||
};
|
||||
assert!(Analyzer::should_transcode_audio(&high_bitrate_ac3));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ impl Executor for FfmpegExecutor {
|
||||
self.config.hardware.cpu_preset,
|
||||
self.config.transcode.output_codec,
|
||||
self.config.transcode.threads,
|
||||
self.config.transcode.allow_fallback,
|
||||
self.config.transcode.hdr_mode,
|
||||
self.config.transcode.tonemap_algorithm,
|
||||
self.config.transcode.tonemap_peak,
|
||||
self.config.transcode.tonemap_desat,
|
||||
self.dry_run,
|
||||
metadata,
|
||||
Some((job.id, self.event_tx.clone())),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! FFmpeg wrapper module for Alchemist
|
||||
//! Provides typed FFmpeg commands, encoder detection, and structured progress parsing.
|
||||
|
||||
use crate::config::{CpuPreset, QualityProfile};
|
||||
use crate::config::{CpuPreset, HdrMode, QualityProfile, TonemapAlgorithm};
|
||||
use crate::error::{AlchemistError, Result};
|
||||
use crate::system::hardware::{HardwareInfo, Vendor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -9,6 +9,7 @@ use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Available hardware accelerators detected from FFmpeg
|
||||
@@ -136,6 +137,18 @@ impl EncoderCapabilities {
|
||||
pub fn has_libsvtav1(&self) -> bool {
|
||||
self.has_video_encoder("libsvtav1")
|
||||
}
|
||||
|
||||
pub fn has_hevc_videotoolbox(&self) -> bool {
|
||||
self.has_video_encoder("hevc_videotoolbox")
|
||||
}
|
||||
|
||||
pub fn has_libx265(&self) -> bool {
|
||||
self.has_video_encoder("libx265")
|
||||
}
|
||||
|
||||
pub fn has_libx264(&self) -> bool {
|
||||
self.has_video_encoder("libx264")
|
||||
}
|
||||
}
|
||||
|
||||
// QualityProfile moved to config.rs
|
||||
@@ -148,6 +161,12 @@ pub struct FFmpegCommandBuilder<'a> {
|
||||
cpu_preset: CpuPreset,
|
||||
target_codec: crate::config::OutputCodec,
|
||||
threads: usize,
|
||||
allow_fallback: bool,
|
||||
hdr_mode: HdrMode,
|
||||
tonemap_algorithm: TonemapAlgorithm,
|
||||
tonemap_peak: f32,
|
||||
tonemap_desat: f32,
|
||||
metadata: Option<&'a crate::media::pipeline::MediaMetadata>,
|
||||
}
|
||||
|
||||
impl<'a> FFmpegCommandBuilder<'a> {
|
||||
@@ -160,6 +179,12 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
cpu_preset: CpuPreset::Medium,
|
||||
target_codec: crate::config::OutputCodec::Av1,
|
||||
threads: 0,
|
||||
allow_fallback: true,
|
||||
hdr_mode: HdrMode::Preserve,
|
||||
tonemap_algorithm: TonemapAlgorithm::Hable,
|
||||
tonemap_peak: crate::config::default_tonemap_peak(),
|
||||
tonemap_desat: crate::config::default_tonemap_desat(),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,15 +213,61 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_allow_fallback(mut self, allow_fallback: bool) -> Self {
|
||||
self.allow_fallback = allow_fallback;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hdr_settings(
|
||||
mut self,
|
||||
hdr_mode: HdrMode,
|
||||
tonemap_algorithm: TonemapAlgorithm,
|
||||
tonemap_peak: f32,
|
||||
tonemap_desat: f32,
|
||||
metadata: Option<&'a crate::media::pipeline::MediaMetadata>,
|
||||
) -> Self {
|
||||
self.hdr_mode = hdr_mode;
|
||||
self.tonemap_algorithm = tonemap_algorithm;
|
||||
self.tonemap_peak = tonemap_peak;
|
||||
self.tonemap_desat = tonemap_desat;
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
cmd.arg("-hide_banner").arg("-y").arg("-i").arg(self.input);
|
||||
|
||||
match self.hw_info {
|
||||
Some(hw) => self.apply_hardware_params(&mut cmd, hw),
|
||||
None => self.apply_cpu_params(&mut cmd),
|
||||
let caps = encoder_caps();
|
||||
if let Some(selection) = self.select_encoder(caps) {
|
||||
if selection.requested_codec != selection.effective_codec {
|
||||
info!(
|
||||
"Requested codec: {} | Effective codec: {} ({}). Reason: {}",
|
||||
selection.requested_codec.as_str(),
|
||||
selection.effective_codec.as_str(),
|
||||
selection.encoder,
|
||||
selection.reason
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Requested codec: {} | Effective codec: {} ({})",
|
||||
selection.requested_codec.as_str(),
|
||||
selection.effective_codec.as_str(),
|
||||
selection.encoder
|
||||
);
|
||||
}
|
||||
self.apply_encoder(&mut cmd, &selection);
|
||||
} else {
|
||||
warn!(
|
||||
"No suitable encoder found for {} (fallbacks {}). Encoding may fail.",
|
||||
self.target_codec.as_str(),
|
||||
if self.allow_fallback { "allowed" } else { "disabled" }
|
||||
);
|
||||
self.apply_cpu_params(&mut cmd);
|
||||
}
|
||||
|
||||
self.apply_hdr_settings(&mut cmd);
|
||||
|
||||
if self.threads > 0 {
|
||||
cmd.arg("-threads").arg(self.threads.to_string());
|
||||
}
|
||||
@@ -208,93 +279,355 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
cmd
|
||||
}
|
||||
|
||||
fn apply_hardware_params(&self, cmd: &mut tokio::process::Command, hw: &HardwareInfo) {
|
||||
let codec_str = self.target_codec.as_str();
|
||||
|
||||
// Check if target codec is supported by hardware
|
||||
let supports_codec = hw.supported_codecs.iter().any(|c| c == codec_str);
|
||||
|
||||
if !supports_codec {
|
||||
warn!(
|
||||
"Hardware {:?} does not support {}. Falling back to CPU encoding.",
|
||||
hw.vendor, codec_str
|
||||
);
|
||||
self.apply_cpu_params(cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
match (hw.vendor, self.target_codec) {
|
||||
(Vendor::Intel, crate::config::OutputCodec::Av1) => {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-init_hw_device")
|
||||
.arg(format!("qsv=qsv:{}", device_path));
|
||||
cmd.arg("-filter_hw_device").arg("qsv");
|
||||
fn apply_encoder(&self, cmd: &mut tokio::process::Command, selection: &EncoderSelection) {
|
||||
match selection.encoder.as_str() {
|
||||
"av1_qsv" => {
|
||||
if let Some(hw) = self.hw_info {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-init_hw_device")
|
||||
.arg(format!("qsv=qsv:{}", device_path));
|
||||
cmd.arg("-filter_hw_device").arg("qsv");
|
||||
}
|
||||
}
|
||||
cmd.arg("-c:v").arg("av1_qsv");
|
||||
cmd.arg("-global_quality").arg(self.profile.qsv_quality());
|
||||
cmd.arg("-look_ahead").arg("1");
|
||||
}
|
||||
(Vendor::Intel, crate::config::OutputCodec::Hevc) => {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-init_hw_device")
|
||||
.arg(format!("qsv=qsv:{}", device_path));
|
||||
cmd.arg("-filter_hw_device").arg("qsv");
|
||||
"hevc_qsv" => {
|
||||
if let Some(hw) = self.hw_info {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-init_hw_device")
|
||||
.arg(format!("qsv=qsv:{}", device_path));
|
||||
cmd.arg("-filter_hw_device").arg("qsv");
|
||||
}
|
||||
}
|
||||
cmd.arg("-c:v").arg("hevc_qsv");
|
||||
cmd.arg("-global_quality").arg(self.profile.qsv_quality());
|
||||
cmd.arg("-look_ahead").arg("1");
|
||||
}
|
||||
(Vendor::Nvidia, crate::config::OutputCodec::Av1) => {
|
||||
"av1_nvenc" => {
|
||||
cmd.arg("-c:v").arg("av1_nvenc");
|
||||
cmd.arg("-preset").arg(self.profile.nvenc_preset());
|
||||
cmd.arg("-cq").arg("25");
|
||||
}
|
||||
(Vendor::Nvidia, crate::config::OutputCodec::Hevc) => {
|
||||
"hevc_nvenc" => {
|
||||
cmd.arg("-c:v").arg("hevc_nvenc");
|
||||
cmd.arg("-preset").arg(self.profile.nvenc_preset());
|
||||
cmd.arg("-cq").arg("25");
|
||||
}
|
||||
(Vendor::Apple, crate::config::OutputCodec::Av1) => {
|
||||
"h264_nvenc" => {
|
||||
cmd.arg("-c:v").arg("h264_nvenc");
|
||||
cmd.arg("-preset").arg(self.profile.nvenc_preset());
|
||||
cmd.arg("-cq").arg("23");
|
||||
}
|
||||
"av1_vaapi" => {
|
||||
if let Some(hw) = self.hw_info {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
}
|
||||
cmd.arg("-c:v").arg("av1_vaapi");
|
||||
}
|
||||
"hevc_vaapi" => {
|
||||
if let Some(hw) = self.hw_info {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
}
|
||||
cmd.arg("-c:v").arg("hevc_vaapi");
|
||||
}
|
||||
"h264_vaapi" => {
|
||||
if let Some(hw) = self.hw_info {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
}
|
||||
cmd.arg("-c:v").arg("h264_vaapi");
|
||||
}
|
||||
"av1_amf" => cmd.arg("-c:v").arg("av1_amf"),
|
||||
"hevc_amf" => cmd.arg("-c:v").arg("hevc_amf"),
|
||||
"h264_amf" => cmd.arg("-c:v").arg("h264_amf"),
|
||||
"av1_videotoolbox" => {
|
||||
cmd.arg("-c:v").arg("av1_videotoolbox");
|
||||
cmd.arg("-b:v").arg("0");
|
||||
cmd.arg("-q:v").arg(self.profile.videotoolbox_quality());
|
||||
}
|
||||
(Vendor::Apple, crate::config::OutputCodec::Hevc) => {
|
||||
"hevc_videotoolbox" => {
|
||||
cmd.arg("-c:v").arg("hevc_videotoolbox");
|
||||
// Allow hardware to choose profile, generally reliable
|
||||
cmd.arg("-q:v").arg("60"); // Quality factor
|
||||
cmd.arg("-b:v").arg("0");
|
||||
cmd.arg("-q:v").arg(self.profile.videotoolbox_quality());
|
||||
cmd.arg("-tag:v").arg("hvc1");
|
||||
}
|
||||
(Vendor::Amd, crate::config::OutputCodec::Av1) => {
|
||||
// Ensure VAAPI device is set if needed
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
cmd.arg("-c:v").arg("av1_amf");
|
||||
} else {
|
||||
cmd.arg("-c:v").arg("av1_vaapi");
|
||||
}
|
||||
"h264_videotoolbox" => {
|
||||
cmd.arg("-c:v").arg("h264_videotoolbox");
|
||||
cmd.arg("-b:v").arg("0");
|
||||
cmd.arg("-q:v").arg("65");
|
||||
}
|
||||
(Vendor::Amd, crate::config::OutputCodec::Hevc) => {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
cmd.arg("-c:v").arg("hevc_amf");
|
||||
} else {
|
||||
cmd.arg("-c:v").arg("hevc_vaapi");
|
||||
}
|
||||
}
|
||||
(Vendor::Cpu, _) => self.apply_cpu_params(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_cpu_params(&self, cmd: &mut tokio::process::Command) {
|
||||
match self.target_codec {
|
||||
crate::config::OutputCodec::Av1 => {
|
||||
"libsvtav1" => {
|
||||
let (preset_str, crf_str) = self.cpu_preset.params();
|
||||
cmd.arg("-c:v").arg("libsvtav1");
|
||||
cmd.arg("-preset").arg(preset_str);
|
||||
cmd.arg("-crf").arg(crf_str);
|
||||
}
|
||||
"libaom-av1" => {
|
||||
cmd.arg("-c:v").arg("libaom-av1");
|
||||
cmd.arg("-crf").arg("32");
|
||||
cmd.arg("-cpu-used").arg("6");
|
||||
}
|
||||
"libx265" => {
|
||||
let preset = self.cpu_preset.as_str();
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "20",
|
||||
CpuPreset::Medium => "24",
|
||||
CpuPreset::Fast => "26",
|
||||
CpuPreset::Faster => "28",
|
||||
};
|
||||
cmd.arg("-c:v").arg("libx265");
|
||||
cmd.arg("-preset").arg(preset);
|
||||
cmd.arg("-crf").arg(crf);
|
||||
cmd.arg("-tag:v").arg("hvc1");
|
||||
}
|
||||
"libx264" => {
|
||||
let preset = self.cpu_preset.as_str();
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "18",
|
||||
CpuPreset::Medium => "21",
|
||||
CpuPreset::Fast => "23",
|
||||
CpuPreset::Faster => "25",
|
||||
};
|
||||
cmd.arg("-c:v").arg("libx264");
|
||||
cmd.arg("-preset").arg(preset);
|
||||
cmd.arg("-crf").arg(crf);
|
||||
}
|
||||
_ => self.apply_cpu_params(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_encoder(&self, caps: &EncoderCapabilities) -> Option<EncoderSelection> {
|
||||
let preferred = self.target_codec;
|
||||
|
||||
let mut candidates: Vec<EncoderCandidate> = Vec::new();
|
||||
match preferred {
|
||||
crate::config::OutputCodec::Av1 => {
|
||||
self.push_av1_candidates(&mut candidates, caps);
|
||||
if self.allow_fallback {
|
||||
self.push_hevc_candidates(&mut candidates, caps, "AV1 encoders unavailable");
|
||||
self.push_h264_candidates(&mut candidates, caps, "HEVC encoders unavailable");
|
||||
}
|
||||
}
|
||||
crate::config::OutputCodec::Hevc => {
|
||||
self.push_hevc_candidates(&mut candidates, caps, "Preferred HEVC");
|
||||
if self.allow_fallback {
|
||||
self.push_h264_candidates(&mut candidates, caps, "HEVC encoders unavailable");
|
||||
}
|
||||
}
|
||||
crate::config::OutputCodec::H264 => {
|
||||
self.push_h264_candidates(&mut candidates, caps, "Preferred H.264");
|
||||
if self.allow_fallback {
|
||||
self.push_hevc_candidates(&mut candidates, caps, "H.264 encoders unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let selection = candidates.into_iter().find(|c| c.available);
|
||||
selection.map(|c| EncoderSelection {
|
||||
encoder: c.encoder.to_string(),
|
||||
requested_codec: preferred,
|
||||
effective_codec: c.effective_codec,
|
||||
reason: c.reason.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn push_av1_candidates(&self, out: &mut Vec<EncoderCandidate>, caps: &EncoderCapabilities) {
|
||||
match self.hw_info.map(|h| h.vendor) {
|
||||
Some(Vendor::Apple) => out.push(EncoderCandidate::new(
|
||||
"av1_videotoolbox",
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_video_encoder("av1_videotoolbox"),
|
||||
"Hardware AV1 (VideoToolbox)",
|
||||
)),
|
||||
Some(Vendor::Intel) => out.push(EncoderCandidate::new(
|
||||
"av1_qsv",
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_video_encoder("av1_qsv"),
|
||||
"Hardware AV1 (QSV)",
|
||||
)),
|
||||
Some(Vendor::Nvidia) => out.push(EncoderCandidate::new(
|
||||
"av1_nvenc",
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_video_encoder("av1_nvenc"),
|
||||
"Hardware AV1 (NVENC)",
|
||||
)),
|
||||
Some(Vendor::Amd) => out.push(EncoderCandidate::new(
|
||||
if cfg!(target_os = "windows") { "av1_amf" } else { "av1_vaapi" },
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_video_encoder(if cfg!(target_os = "windows") { "av1_amf" } else { "av1_vaapi" }),
|
||||
"Hardware AV1 (AMF/VAAPI)",
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
out.push(EncoderCandidate::new(
|
||||
"libsvtav1",
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_libsvtav1(),
|
||||
"CPU AV1 (SVT-AV1)",
|
||||
));
|
||||
out.push(EncoderCandidate::new(
|
||||
"libaom-av1",
|
||||
crate::config::OutputCodec::Av1,
|
||||
caps.has_video_encoder("libaom-av1"),
|
||||
"CPU AV1 (libaom)",
|
||||
));
|
||||
}
|
||||
|
||||
fn push_hevc_candidates(&self, out: &mut Vec<EncoderCandidate>, caps: &EncoderCapabilities, reason: &str) {
|
||||
match self.hw_info.map(|h| h.vendor) {
|
||||
Some(Vendor::Apple) => out.push(EncoderCandidate::new(
|
||||
"hevc_videotoolbox",
|
||||
crate::config::OutputCodec::Hevc,
|
||||
caps.has_hevc_videotoolbox(),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Intel) => out.push(EncoderCandidate::new(
|
||||
"hevc_qsv",
|
||||
crate::config::OutputCodec::Hevc,
|
||||
caps.has_video_encoder("hevc_qsv"),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Nvidia) => out.push(EncoderCandidate::new(
|
||||
"hevc_nvenc",
|
||||
crate::config::OutputCodec::Hevc,
|
||||
caps.has_video_encoder("hevc_nvenc"),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Amd) => out.push(EncoderCandidate::new(
|
||||
if cfg!(target_os = "windows") { "hevc_amf" } else { "hevc_vaapi" },
|
||||
crate::config::OutputCodec::Hevc,
|
||||
caps.has_video_encoder(if cfg!(target_os = "windows") { "hevc_amf" } else { "hevc_vaapi" }),
|
||||
reason,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
out.push(EncoderCandidate::new(
|
||||
"libx265",
|
||||
crate::config::OutputCodec::Hevc,
|
||||
caps.has_libx265(),
|
||||
reason,
|
||||
));
|
||||
}
|
||||
|
||||
fn push_h264_candidates(&self, out: &mut Vec<EncoderCandidate>, caps: &EncoderCapabilities, reason: &str) {
|
||||
match self.hw_info.map(|h| h.vendor) {
|
||||
Some(Vendor::Apple) => out.push(EncoderCandidate::new(
|
||||
"h264_videotoolbox",
|
||||
crate::config::OutputCodec::H264,
|
||||
caps.has_video_encoder("h264_videotoolbox"),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Intel) => out.push(EncoderCandidate::new(
|
||||
"h264_qsv",
|
||||
crate::config::OutputCodec::H264,
|
||||
caps.has_video_encoder("h264_qsv"),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Nvidia) => out.push(EncoderCandidate::new(
|
||||
"h264_nvenc",
|
||||
crate::config::OutputCodec::H264,
|
||||
caps.has_video_encoder("h264_nvenc"),
|
||||
reason,
|
||||
)),
|
||||
Some(Vendor::Amd) => out.push(EncoderCandidate::new(
|
||||
if cfg!(target_os = "windows") { "h264_amf" } else { "h264_vaapi" },
|
||||
crate::config::OutputCodec::H264,
|
||||
caps.has_video_encoder(if cfg!(target_os = "windows") { "h264_amf" } else { "h264_vaapi" }),
|
||||
reason,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
out.push(EncoderCandidate::new(
|
||||
"libx264",
|
||||
crate::config::OutputCodec::H264,
|
||||
caps.has_libx264(),
|
||||
reason,
|
||||
));
|
||||
}
|
||||
|
||||
fn apply_hdr_settings(&self, cmd: &mut tokio::process::Command) {
|
||||
let Some(metadata) = self.metadata else {
|
||||
return;
|
||||
};
|
||||
|
||||
if metadata.is_hdr && self.hdr_mode == HdrMode::Tonemap {
|
||||
let filter = format!(
|
||||
"zscale=t=linear:npl={},tonemap=tonemap={}:desat={},zscale=t=bt709:m=bt709:r=tv,format=yuv420p",
|
||||
self.tonemap_peak,
|
||||
self.tonemap_algorithm.as_str(),
|
||||
self.tonemap_desat
|
||||
);
|
||||
cmd.arg("-vf").arg(filter);
|
||||
cmd.arg("-color_primaries").arg("bt709");
|
||||
cmd.arg("-color_trc").arg("bt709");
|
||||
cmd.arg("-colorspace").arg("bt709");
|
||||
cmd.arg("-color_range").arg("tv");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref primaries) = metadata.color_primaries {
|
||||
cmd.arg("-color_primaries").arg(primaries);
|
||||
}
|
||||
if let Some(ref transfer) = metadata.color_transfer {
|
||||
cmd.arg("-color_trc").arg(transfer);
|
||||
}
|
||||
if let Some(ref space) = metadata.color_space {
|
||||
cmd.arg("-colorspace").arg(space);
|
||||
}
|
||||
if let Some(ref range) = metadata.color_range {
|
||||
cmd.arg("-color_range").arg(range);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_cpu_params(&self, cmd: &mut tokio::process::Command) {
|
||||
let caps = encoder_caps();
|
||||
match self.target_codec {
|
||||
crate::config::OutputCodec::Av1 => {
|
||||
if caps.has_libsvtav1() {
|
||||
let (preset_str, crf_str) = self.cpu_preset.params();
|
||||
cmd.arg("-c:v").arg("libsvtav1");
|
||||
cmd.arg("-preset").arg(preset_str);
|
||||
cmd.arg("-crf").arg(crf_str);
|
||||
} else if caps.has_libx265() {
|
||||
warn!("libsvtav1 not available. Falling back to libx265.");
|
||||
let preset = self.cpu_preset.as_str();
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "20",
|
||||
CpuPreset::Medium => "24",
|
||||
CpuPreset::Fast => "26",
|
||||
CpuPreset::Faster => "28",
|
||||
};
|
||||
cmd.arg("-c:v").arg("libx265");
|
||||
cmd.arg("-preset").arg(preset);
|
||||
cmd.arg("-crf").arg(crf);
|
||||
cmd.arg("-tag:v").arg("hvc1");
|
||||
} else if caps.has_libx264() {
|
||||
warn!("libsvtav1 not available. Falling back to libx264.");
|
||||
let preset = self.cpu_preset.as_str();
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "18",
|
||||
CpuPreset::Medium => "21",
|
||||
CpuPreset::Fast => "23",
|
||||
CpuPreset::Faster => "25",
|
||||
};
|
||||
cmd.arg("-c:v").arg("libx264");
|
||||
cmd.arg("-preset").arg(preset);
|
||||
cmd.arg("-crf").arg(crf);
|
||||
} else {
|
||||
warn!("No AV1/HEVC/H.264 CPU encoder available. Encoding will likely fail.");
|
||||
let (preset_str, crf_str) = self.cpu_preset.params();
|
||||
cmd.arg("-c:v").arg("libsvtav1");
|
||||
cmd.arg("-preset").arg(preset_str);
|
||||
cmd.arg("-crf").arg(crf_str);
|
||||
}
|
||||
}
|
||||
crate::config::OutputCodec::Hevc => {
|
||||
// For HEVC CPU, we use libx265
|
||||
// Map presets roughly:
|
||||
@@ -414,6 +747,54 @@ impl FFmpegProgress {
|
||||
}
|
||||
}
|
||||
|
||||
struct EncoderCandidate<'a> {
|
||||
encoder: &'a str,
|
||||
effective_codec: crate::config::OutputCodec,
|
||||
available: bool,
|
||||
reason: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> EncoderCandidate<'a> {
|
||||
fn new(
|
||||
encoder: &'a str,
|
||||
effective_codec: crate::config::OutputCodec,
|
||||
available: bool,
|
||||
reason: &'a str,
|
||||
) -> Self {
|
||||
Self {
|
||||
encoder,
|
||||
effective_codec,
|
||||
available,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EncoderSelection {
|
||||
encoder: String,
|
||||
requested_codec: crate::config::OutputCodec,
|
||||
effective_codec: crate::config::OutputCodec,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
fn encoder_caps() -> &'static EncoderCapabilities {
|
||||
static CAPS: OnceLock<EncoderCapabilities> = OnceLock::new();
|
||||
CAPS.get_or_init(|| EncoderCapabilities::detect().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn encoder_caps_clone() -> EncoderCapabilities {
|
||||
encoder_caps().clone()
|
||||
}
|
||||
|
||||
pub fn warm_encoder_cache() {
|
||||
let caps = encoder_caps();
|
||||
info!(
|
||||
"Encoder capabilities cached: video_encoders={}, audio_encoders={}",
|
||||
caps.video_encoders.len(),
|
||||
caps.audio_encoders.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// VMAF quality score result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QualityScore {
|
||||
|
||||
@@ -12,6 +12,11 @@ pub struct MediaMetadata {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bit_depth: u8,
|
||||
pub color_primaries: Option<String>,
|
||||
pub color_transfer: Option<String>,
|
||||
pub color_space: Option<String>,
|
||||
pub color_range: Option<String>,
|
||||
pub is_hdr: bool,
|
||||
pub size_bytes: u64,
|
||||
pub bit_rate: f64,
|
||||
pub fps: f64,
|
||||
|
||||
@@ -10,19 +10,32 @@ use std::sync::Arc;
|
||||
pub struct BasicPlanner {
|
||||
config: Arc<Config>,
|
||||
hw_info: Option<HardwareInfo>,
|
||||
encoder_caps: Arc<crate::media::ffmpeg::EncoderCapabilities>,
|
||||
}
|
||||
|
||||
impl BasicPlanner {
|
||||
pub fn new(config: Arc<Config>, hw_info: Option<HardwareInfo>) -> Self {
|
||||
Self { config, hw_info }
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
hw_info: Option<HardwareInfo>,
|
||||
encoder_caps: Arc<crate::media::ffmpeg::EncoderCapabilities>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
hw_info,
|
||||
encoder_caps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Planner for BasicPlanner {
|
||||
async fn plan(&self, metadata: &MediaMetadata) -> Result<Decision> {
|
||||
let (should_transcode, reason) =
|
||||
should_transcode(metadata, &self.config, self.hw_info.as_ref());
|
||||
let (should_transcode, reason) = should_transcode(
|
||||
metadata,
|
||||
&self.config,
|
||||
self.hw_info.as_ref(),
|
||||
&self.encoder_caps,
|
||||
);
|
||||
|
||||
// We don't have job_id here in plan() signature...
|
||||
// Wait, Decision struct requires job_id, created_at, id (db fields).
|
||||
@@ -57,11 +70,25 @@ fn should_transcode(
|
||||
metadata: &MediaMetadata,
|
||||
config: &Config,
|
||||
hw_info: Option<&HardwareInfo>,
|
||||
encoder_caps: &crate::media::ffmpeg::EncoderCapabilities,
|
||||
) -> (bool, String) {
|
||||
// 0. Hardware Capability Check
|
||||
let target_codec = config.transcode.output_codec;
|
||||
let target_codec_str = target_codec.as_str();
|
||||
|
||||
if !config.transcode.allow_fallback {
|
||||
let preferred_available = encoder_available_for_codec(target_codec, hw_info, encoder_caps);
|
||||
if !preferred_available {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"Preferred codec {} unavailable and fallback disabled",
|
||||
target_codec_str
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hw) = hw_info {
|
||||
if hw.vendor == Vendor::Cpu && !config.hardware.allow_cpu_encoding {
|
||||
return (false, "CPU encoding disabled in configuration".to_string());
|
||||
@@ -101,25 +128,44 @@ fn should_transcode(
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Codec Check (skip if already target codec + 10-bit)
|
||||
if metadata.codec_name == target_codec_str && metadata.bit_depth == 10 {
|
||||
// 1. Codec Check (skip if already target codec + 10-bit, or H.264 preferred and already H.264)
|
||||
if metadata.codec_name.eq_ignore_ascii_case(target_codec_str) && metadata.bit_depth == 10 {
|
||||
return (false, format!("Already {} 10-bit", target_codec_str));
|
||||
}
|
||||
|
||||
if target_codec == crate::config::OutputCodec::H264
|
||||
&& metadata.codec_name.eq_ignore_ascii_case("h264")
|
||||
&& metadata.bit_depth <= 8
|
||||
{
|
||||
return (false, "Already H.264".to_string());
|
||||
}
|
||||
|
||||
// 1b. Always transcode H.264 sources
|
||||
if metadata.codec_name.eq_ignore_ascii_case("h264") {
|
||||
return (
|
||||
true,
|
||||
"H.264 source prioritized for transcode".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Efficiency Rules (BPP)
|
||||
let bitrate = metadata.bit_rate;
|
||||
let width = metadata.width as f64;
|
||||
let height = metadata.height as f64;
|
||||
let fps = metadata.fps;
|
||||
|
||||
if width == 0.0 || height == 0.0 || bitrate == 0.0 {
|
||||
if width == 0.0 || height == 0.0 {
|
||||
return (
|
||||
false,
|
||||
"Incomplete metadata (bitrate/resolution)".to_string(),
|
||||
"Incomplete metadata (resolution missing)".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let bpp = bitrate / (width * height * fps);
|
||||
let bpp = if bitrate > 0.0 && fps > 0.0 {
|
||||
bitrate / (width * height * fps)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Normalize BPP based on resolution
|
||||
let res_correction = if width >= 3840.0 {
|
||||
@@ -131,8 +177,8 @@ fn should_transcode(
|
||||
};
|
||||
let normalized_bpp = bpp * res_correction;
|
||||
|
||||
// Heuristic via config
|
||||
if normalized_bpp < config.transcode.min_bpp_threshold {
|
||||
// Heuristic via config (only if bitrate/fps are known)
|
||||
if bpp > 0.0 && normalized_bpp < config.transcode.min_bpp_threshold {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
@@ -156,11 +202,61 @@ fn should_transcode(
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
true,
|
||||
let reason = if bpp > 0.0 {
|
||||
format!(
|
||||
"Ready for {} transcode (Current codec: {}, BPP: {:.4})",
|
||||
target_codec_str, metadata.codec_name, bpp
|
||||
),
|
||||
)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Ready for {} transcode (Current codec: {}, bitrate/fps unknown)",
|
||||
target_codec_str, metadata.codec_name
|
||||
)
|
||||
};
|
||||
|
||||
(true, reason)
|
||||
}
|
||||
|
||||
fn encoder_available_for_codec(
|
||||
target_codec: crate::config::OutputCodec,
|
||||
hw_info: Option<&HardwareInfo>,
|
||||
encoder_caps: &crate::media::ffmpeg::EncoderCapabilities,
|
||||
) -> bool {
|
||||
let hw_vendor = hw_info.map(|h| h.vendor);
|
||||
match target_codec {
|
||||
crate::config::OutputCodec::Av1 => {
|
||||
matches!(hw_vendor, Some(Vendor::Apple))
|
||||
&& encoder_caps.has_video_encoder("av1_videotoolbox")
|
||||
|| matches!(hw_vendor, Some(Vendor::Intel))
|
||||
&& encoder_caps.has_video_encoder("av1_qsv")
|
||||
|| matches!(hw_vendor, Some(Vendor::Nvidia))
|
||||
&& encoder_caps.has_video_encoder("av1_nvenc")
|
||||
|| matches!(hw_vendor, Some(Vendor::Amd))
|
||||
&& encoder_caps.has_video_encoder(if cfg!(target_os = "windows") { "av1_amf" } else { "av1_vaapi" })
|
||||
|| encoder_caps.has_libsvtav1()
|
||||
|| encoder_caps.has_video_encoder("libaom-av1")
|
||||
}
|
||||
crate::config::OutputCodec::Hevc => {
|
||||
matches!(hw_vendor, Some(Vendor::Apple))
|
||||
&& encoder_caps.has_video_encoder("hevc_videotoolbox")
|
||||
|| matches!(hw_vendor, Some(Vendor::Intel))
|
||||
&& encoder_caps.has_video_encoder("hevc_qsv")
|
||||
|| matches!(hw_vendor, Some(Vendor::Nvidia))
|
||||
&& encoder_caps.has_video_encoder("hevc_nvenc")
|
||||
|| matches!(hw_vendor, Some(Vendor::Amd))
|
||||
&& encoder_caps.has_video_encoder(if cfg!(target_os = "windows") { "hevc_amf" } else { "hevc_vaapi" })
|
||||
|| encoder_caps.has_libx265()
|
||||
}
|
||||
crate::config::OutputCodec::H264 => {
|
||||
matches!(hw_vendor, Some(Vendor::Apple))
|
||||
&& encoder_caps.has_video_encoder("h264_videotoolbox")
|
||||
|| matches!(hw_vendor, Some(Vendor::Intel))
|
||||
&& encoder_caps.has_video_encoder("h264_qsv")
|
||||
|| matches!(hw_vendor, Some(Vendor::Nvidia))
|
||||
&& encoder_caps.has_video_encoder("h264_nvenc")
|
||||
|| matches!(hw_vendor, Some(Vendor::Amd))
|
||||
&& encoder_caps.has_video_encoder(if cfg!(target_os = "windows") { "h264_amf" } else { "h264_vaapi" })
|
||||
|| encoder_caps.has_libx264()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,9 +344,11 @@ impl Agent {
|
||||
info!("[Job {}] Codec: {}", job.id, metadata.codec_name);
|
||||
|
||||
let config_snapshot = self.config.read().await.clone();
|
||||
let encoder_caps = Arc::new(crate::media::ffmpeg::encoder_caps_clone());
|
||||
let planner = BasicPlanner::new(
|
||||
Arc::new(config_snapshot.clone()),
|
||||
self.hw_info.as_ref().clone(),
|
||||
encoder_caps,
|
||||
);
|
||||
let decision = planner.plan(&metadata).await?;
|
||||
let should_encode = decision.action == "encode";
|
||||
|
||||
@@ -53,6 +53,11 @@ impl Transcoder {
|
||||
cpu_preset: CpuPreset,
|
||||
target_codec: crate::config::OutputCodec,
|
||||
threads: usize,
|
||||
allow_fallback: bool,
|
||||
hdr_mode: crate::config::HdrMode,
|
||||
tonemap_algorithm: crate::config::TonemapAlgorithm,
|
||||
tonemap_peak: f32,
|
||||
tonemap_desat: f32,
|
||||
dry_run: bool,
|
||||
metadata: &crate::media::pipeline::MediaMetadata,
|
||||
event_target: Option<(i64, Arc<broadcast::Sender<crate::db::AlchemistEvent>>)>,
|
||||
@@ -87,6 +92,14 @@ impl Transcoder {
|
||||
.with_cpu_preset(cpu_preset)
|
||||
.with_codec(target_codec)
|
||||
.with_threads(threads)
|
||||
.with_allow_fallback(allow_fallback)
|
||||
.with_hdr_settings(
|
||||
hdr_mode,
|
||||
tonemap_algorithm,
|
||||
tonemap_peak,
|
||||
tonemap_desat,
|
||||
Some(metadata),
|
||||
)
|
||||
.build();
|
||||
|
||||
info!("Executing FFmpeg command: {:?}", cmd);
|
||||
|
||||
@@ -300,6 +300,16 @@ struct TranscodeSettingsPayload {
|
||||
quality_profile: crate::config::QualityProfile,
|
||||
#[serde(default)]
|
||||
threads: usize,
|
||||
#[serde(default = "crate::config::default_allow_fallback")]
|
||||
allow_fallback: bool,
|
||||
#[serde(default)]
|
||||
hdr_mode: crate::config::HdrMode,
|
||||
#[serde(default)]
|
||||
tonemap_algorithm: crate::config::TonemapAlgorithm,
|
||||
#[serde(default = "crate::config::default_tonemap_peak")]
|
||||
tonemap_peak: f32,
|
||||
#[serde(default = "crate::config::default_tonemap_desat")]
|
||||
tonemap_desat: f32,
|
||||
}
|
||||
|
||||
async fn get_transcode_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
@@ -312,6 +322,11 @@ async fn get_transcode_settings_handler(State(state): State<Arc<AppState>>) -> i
|
||||
output_codec: config.transcode.output_codec,
|
||||
quality_profile: config.transcode.quality_profile,
|
||||
threads: config.transcode.threads,
|
||||
allow_fallback: config.transcode.allow_fallback,
|
||||
hdr_mode: config.transcode.hdr_mode,
|
||||
tonemap_algorithm: config.transcode.tonemap_algorithm,
|
||||
tonemap_peak: config.transcode.tonemap_peak,
|
||||
tonemap_desat: config.transcode.tonemap_desat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,6 +355,11 @@ async fn update_transcode_settings_handler(
|
||||
config.transcode.output_codec = payload.output_codec;
|
||||
config.transcode.quality_profile = payload.quality_profile;
|
||||
config.transcode.threads = payload.threads;
|
||||
config.transcode.allow_fallback = payload.allow_fallback;
|
||||
config.transcode.hdr_mode = payload.hdr_mode;
|
||||
config.transcode.tonemap_algorithm = payload.tonemap_algorithm;
|
||||
config.transcode.tonemap_peak = payload.tonemap_peak;
|
||||
config.transcode.tonemap_desat = payload.tonemap_desat;
|
||||
|
||||
if let Err(e) = config.save(std::path::Path::new("config.toml")) {
|
||||
return (
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn encoder_label(hw: Option<&HardwareInfo>, codec: OutputCodec) -> String {
|
||||
let cpu_encoder = match codec {
|
||||
OutputCodec::Av1 => "libsvtav1",
|
||||
OutputCodec::Hevc => "libx265",
|
||||
OutputCodec::H264 => "libx264",
|
||||
};
|
||||
|
||||
let Some(hw) = hw else {
|
||||
@@ -67,10 +68,13 @@ pub fn encoder_label(hw: Option<&HardwareInfo>, codec: OutputCodec) -> String {
|
||||
match (hw.vendor, codec) {
|
||||
(Vendor::Intel, OutputCodec::Av1) => "av1_qsv".to_string(),
|
||||
(Vendor::Intel, OutputCodec::Hevc) => "hevc_qsv".to_string(),
|
||||
(Vendor::Intel, OutputCodec::H264) => "h264_qsv".to_string(),
|
||||
(Vendor::Nvidia, OutputCodec::Av1) => "av1_nvenc".to_string(),
|
||||
(Vendor::Nvidia, OutputCodec::Hevc) => "hevc_nvenc".to_string(),
|
||||
(Vendor::Nvidia, OutputCodec::H264) => "h264_nvenc".to_string(),
|
||||
(Vendor::Apple, OutputCodec::Av1) => "av1_videotoolbox".to_string(),
|
||||
(Vendor::Apple, OutputCodec::Hevc) => "hevc_videotoolbox".to_string(),
|
||||
(Vendor::Apple, OutputCodec::H264) => "h264_videotoolbox".to_string(),
|
||||
(Vendor::Amd, OutputCodec::Av1) => {
|
||||
if cfg!(target_os = "windows") {
|
||||
"av1_amf".to_string()
|
||||
@@ -85,6 +89,13 @@ pub fn encoder_label(hw: Option<&HardwareInfo>, codec: OutputCodec) -> String {
|
||||
"hevc_vaapi".to_string()
|
||||
}
|
||||
}
|
||||
(Vendor::Amd, OutputCodec::H264) => {
|
||||
if cfg!(target_os = "windows") {
|
||||
"h264_amf".to_string()
|
||||
} else {
|
||||
"h264_vaapi".to_string()
|
||||
}
|
||||
}
|
||||
(Vendor::Cpu, _) => cpu_encoder.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ impl ConfigWizard {
|
||||
threads: 0,
|
||||
quality_profile: crate::config::QualityProfile::Balanced,
|
||||
output_codec: crate::config::OutputCodec::Av1,
|
||||
hdr_mode: crate::config::HdrMode::Preserve,
|
||||
tonemap_algorithm: crate::config::TonemapAlgorithm::Hable,
|
||||
tonemap_peak: crate::config::default_tonemap_peak(),
|
||||
tonemap_desat: crate::config::default_tonemap_desat(),
|
||||
subtitle_mode: crate::config::SubtitleMode::Copy,
|
||||
},
|
||||
hardware: crate::config::HardwareConfig {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "alchemist-web",
|
||||
"version": "0.2.6-2",
|
||||
"0.2.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -66,6 +66,16 @@ const THEME_CATEGORIES: ThemeCategory[] = [
|
||||
name: "Ember",
|
||||
description: "Molten oranges with glowing ember contrast.",
|
||||
},
|
||||
{
|
||||
id: "flare",
|
||||
name: "Flare",
|
||||
description: "Hot coral energy with bright ember highlights.",
|
||||
},
|
||||
{
|
||||
id: "tropic",
|
||||
name: "Tropic",
|
||||
description: "Sun-soaked warmth with lush teal sparks.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,6 +113,16 @@ const THEME_CATEGORIES: ThemeCategory[] = [
|
||||
name: "Aurora",
|
||||
description: "Teal greens with soft, luminous shimmer.",
|
||||
},
|
||||
{
|
||||
id: "mint-night",
|
||||
name: "Mint Night",
|
||||
description: "Dark mint depths with cool, calm glow.",
|
||||
},
|
||||
{
|
||||
id: "cascade",
|
||||
name: "Cascade",
|
||||
description: "Steel blues with gentle oceanic flow.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -140,6 +160,16 @@ const THEME_CATEGORIES: ThemeCategory[] = [
|
||||
name: "Dusk",
|
||||
description: "Evening violets with muted glow.",
|
||||
},
|
||||
{
|
||||
id: "mauve",
|
||||
name: "Mauve",
|
||||
description: "Soft mauve haze with velvety highlights.",
|
||||
},
|
||||
{
|
||||
id: "haze",
|
||||
name: "Haze",
|
||||
description: "Dreamy lilac fog with calm contrast.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -177,6 +207,16 @@ const THEME_CATEGORIES: ThemeCategory[] = [
|
||||
name: "Sage",
|
||||
description: "Soft green-white with herbaceous accents.",
|
||||
},
|
||||
{
|
||||
id: "sprout",
|
||||
name: "Sprout",
|
||||
description: "Fresh spring whites with leafy greens.",
|
||||
},
|
||||
{
|
||||
id: "glow",
|
||||
name: "Glow",
|
||||
description: "Warm daylight cream with soft gold accents.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -214,6 +254,16 @@ const THEME_CATEGORIES: ThemeCategory[] = [
|
||||
name: "Ink",
|
||||
description: "Deep navy blacks with electric accents.",
|
||||
},
|
||||
{
|
||||
id: "nocturne",
|
||||
name: "Nocturne",
|
||||
description: "Cool midnight tones with steel highlights.",
|
||||
},
|
||||
{
|
||||
id: "slate",
|
||||
name: "Slate",
|
||||
description: "Muted charcoal with calm, mineral edges.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -166,58 +166,9 @@ export default function Dashboard() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className="lg:col-span-3 p-6 rounded-3xl bg-gradient-to-br from-helios-surface via-helios-surface-soft to-helios-surface border border-helios-line/40 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 bg-[radial-gradient(circle_at_top,_rgba(255,186,73,0.18),_transparent_60%)]" />
|
||||
<div className="relative z-10 flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">System Snapshot</p>
|
||||
<h2 className="text-2xl font-bold text-helios-ink">Alchemist Dashboard</h2>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest bg-helios-solar/15 text-helios-solar">
|
||||
{stats.active > 0 ? "Processing" : "Ready"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="rounded-2xl bg-helios-surface/70 border border-helios-line/30 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">Active</p>
|
||||
<p className="text-2xl font-bold text-amber-500">{stats.active}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-helios-surface/70 border border-helios-line/30 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">Completed</p>
|
||||
<p className="text-2xl font-bold text-emerald-500">{stats.completed}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-helios-surface/70 border border-helios-line/30 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-500">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-helios-surface/70 border border-helios-line/30 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">Last Job</p>
|
||||
<p className="text-sm font-semibold text-helios-ink truncate" title={lastJob?.input_path}>
|
||||
{lastJob ? lastJob.input_path.split(/[/\\]/).pop() : "No jobs yet"}
|
||||
</p>
|
||||
<p className="text-[10px] text-helios-slate uppercase tracking-wide font-bold">
|
||||
{lastJob ? `${lastJob.status} · ${formatRelativeTime(lastJob.created_at)}` : "Waiting"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity size={18} className="text-helios-solar" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-helios-slate">System Health</h3>
|
||||
</div>
|
||||
<ResourceMonitor />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-hidden">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
label="Active Jobs"
|
||||
value={stats.active}
|
||||
@@ -244,9 +195,9 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||
{/* Recent Activity */}
|
||||
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm">
|
||||
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
|
||||
<Activity size={20} className="text-helios-solar" />
|
||||
@@ -288,12 +239,12 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Getting Started Tips */}
|
||||
<div className="p-6 rounded-3xl bg-gradient-to-br from-helios-surface to-helios-surface-soft border border-helios-line/40 shadow-sm self-start">
|
||||
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
|
||||
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm h-full">
|
||||
<h3 className="text-lg font-bold text-helios-ink mb-6 flex items-center gap-2">
|
||||
<Zap size={20} className="text-helios-solar" />
|
||||
Quick Start
|
||||
</h3>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
{quickStartItems.map(({ title, body, icon: Icon, tone, bg }) => (
|
||||
<div className="flex gap-4 items-start" key={title}>
|
||||
<div className={`p-2.5 rounded-xl ${bg} ${tone} mt-0.5 shadow-inner`}>
|
||||
@@ -310,6 +261,14 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Activity size={18} className="text-helios-solar" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-helios-slate">System Health</h3>
|
||||
</div>
|
||||
<ResourceMonitor />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function FileSettings() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Naming */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Output Suffix</label>
|
||||
<input
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Search, RefreshCw, Trash2, Ban, Play,
|
||||
AlertCircle, Clock, FileVideo,
|
||||
X, Info, Activity, Database, Zap, ArrowRight, Maximize2
|
||||
Search, RefreshCw, Trash2, Ban,
|
||||
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
@@ -68,6 +67,15 @@ export default function JobManager() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [focusedJob, setFocusedJob] = useState<JobDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [confirmState, setConfirmState] = useState<{
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
confirmTone?: "danger" | "primary";
|
||||
onConfirm: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
// Filter mapping
|
||||
const getStatusFilter = (tab: TabType) => {
|
||||
@@ -116,6 +124,17 @@ export default function JobManager() {
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuJobId) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuJobId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuJobId]);
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSet = new Set(selected);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
@@ -133,7 +152,6 @@ export default function JobManager() {
|
||||
|
||||
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
|
||||
if (selected.size === 0) return;
|
||||
if (!confirm(`Are you sure you want to ${action} ${selected.size} jobs?`)) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/jobs/batch", {
|
||||
@@ -154,7 +172,6 @@ export default function JobManager() {
|
||||
};
|
||||
|
||||
const clearCompleted = async () => {
|
||||
if (!confirm("Clear all completed jobs?")) return;
|
||||
await apiFetch("/api/jobs/clear-completed", { method: "POST" });
|
||||
fetchJobs();
|
||||
};
|
||||
@@ -219,6 +236,16 @@ export default function JobManager() {
|
||||
);
|
||||
};
|
||||
|
||||
const openConfirm = (config: {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
confirmTone?: "danger" | "primary";
|
||||
onConfirm: () => Promise<void> | void;
|
||||
}) => {
|
||||
setConfirmState(config);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
{/* Toolbar */}
|
||||
@@ -267,13 +294,48 @@ export default function JobManager() {
|
||||
{selected.size} jobs selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleBatch("restart")} className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar" title="Restart">
|
||||
<button
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Restart jobs",
|
||||
body: `Restart ${selected.size} selected jobs?`,
|
||||
confirmLabel: "Restart",
|
||||
onConfirm: () => handleBatch("restart"),
|
||||
})
|
||||
}
|
||||
className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleBatch("cancel")} className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar" title="Cancel">
|
||||
<button
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Cancel jobs",
|
||||
body: `Cancel ${selected.size} selected jobs?`,
|
||||
confirmLabel: "Cancel",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleBatch("cancel"),
|
||||
})
|
||||
}
|
||||
className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar"
|
||||
title="Cancel"
|
||||
>
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleBatch("delete")} className="p-2 hover:bg-red-500/10 rounded-lg text-red-500" title="Delete">
|
||||
<button
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Delete jobs",
|
||||
body: `Delete ${selected.size} selected jobs from history?`,
|
||||
confirmLabel: "Delete",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleBatch("delete"),
|
||||
})
|
||||
}
|
||||
className="p-2 hover:bg-red-500/10 rounded-lg text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -296,12 +358,13 @@ export default function JobManager() {
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4">Progress</th>
|
||||
<th className="px-6 py-4">Updated</th>
|
||||
<th className="px-6 py-4 w-14"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-helios-line/10">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-helios-slate">
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-helios-slate">
|
||||
{loading ? "Loading jobs..." : "No jobs found"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -361,6 +424,85 @@ export default function JobManager() {
|
||||
<td className="px-6 py-4 text-xs text-helios-slate font-mono">
|
||||
{new Date(job.updated_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative" ref={menuJobId === job.id ? menuRef : null}>
|
||||
<button
|
||||
onClick={() => setMenuJobId(menuJobId === job.id ? null : job.id)}
|
||||
className="p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft text-helios-slate"
|
||||
title="Actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{menuJobId === job.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
className="absolute right-0 mt-2 w-44 rounded-xl border border-helios-line/20 bg-helios-surface shadow-xl z-20 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuJobId(null);
|
||||
fetchJobDetails(job.id);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
||||
>
|
||||
View details
|
||||
</button>
|
||||
{(job.status === "failed" || job.status === "cancelled") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuJobId(null);
|
||||
openConfirm({
|
||||
title: "Retry job",
|
||||
body: "Retry this job now?",
|
||||
confirmLabel: "Retry",
|
||||
onConfirm: () => handleAction(job.id, "restart"),
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{(job.status === "encoding" || job.status === "analyzing") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuJobId(null);
|
||||
openConfirm({
|
||||
title: "Cancel job",
|
||||
body: "Stop this job immediately?",
|
||||
confirmLabel: "Cancel",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleAction(job.id, "cancel"),
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
||||
>
|
||||
Stop / Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuJobId(null);
|
||||
openConfirm({
|
||||
title: "Delete job",
|
||||
body: "Delete this job from history?",
|
||||
confirmLabel: "Delete",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleAction(job.id, "delete"),
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-xs font-semibold text-red-500 hover:bg-red-500/5"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -371,7 +513,18 @@ export default function JobManager() {
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<p className="text-xs text-helios-slate font-medium">Showing {jobs.length} jobs (Limit 50)</p>
|
||||
<button onClick={clearCompleted} className="text-xs text-red-500 hover:text-red-400 font-bold flex items-center gap-1 transition-colors">
|
||||
<button
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Clear completed jobs",
|
||||
body: "Remove all completed jobs from history?",
|
||||
confirmLabel: "Clear",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => clearCompleted(),
|
||||
})
|
||||
}
|
||||
className="text-xs text-red-500 hover:text-red-400 font-bold flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} /> Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
@@ -536,15 +689,30 @@ export default function JobManager() {
|
||||
<div className="flex gap-2">
|
||||
{(focusedJob.job.status === 'failed' || focusedJob.job.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={() => handleAction(focusedJob.job.id, 'restart')}
|
||||
className="px-4 py-2 bg-helios-solar text-white rounded-lg text-sm font-bold flex items-center gap-2 hover:brightness-110 active:scale-95 transition-all shadow-sm"
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Retry job",
|
||||
body: "Retry this job now?",
|
||||
confirmLabel: "Retry",
|
||||
onConfirm: () => handleAction(focusedJob.job.id, 'restart'),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 bg-helios-solar text-helios-main rounded-lg text-sm font-bold flex items-center gap-2 hover:brightness-110 active:scale-95 transition-all shadow-sm"
|
||||
>
|
||||
<RefreshCw size={14} /> Retry Job
|
||||
</button>
|
||||
)}
|
||||
{(focusedJob.job.status === 'encoding' || focusedJob.job.status === 'analyzing') && (
|
||||
<button
|
||||
onClick={() => handleAction(focusedJob.job.id, 'cancel')}
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Cancel job",
|
||||
body: "Stop this job immediately?",
|
||||
confirmLabel: "Cancel",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleAction(focusedJob.job.id, 'cancel'),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-helios-surface-soft active:scale-95 transition-all"
|
||||
>
|
||||
<Ban size={14} /> Stop / Cancel
|
||||
@@ -552,9 +720,15 @@ export default function JobManager() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this job from history?")) handleAction(focusedJob.job.id, 'delete');
|
||||
}}
|
||||
onClick={() =>
|
||||
openConfirm({
|
||||
title: "Delete job",
|
||||
body: "Delete this job from history?",
|
||||
confirmLabel: "Delete",
|
||||
confirmTone: "danger",
|
||||
onConfirm: () => handleAction(focusedJob.job.id, 'delete'),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 text-red-500 hover:bg-red-500/5 rounded-lg text-sm font-bold flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
@@ -566,6 +740,57 @@ export default function JobManager() {
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
<AnimatePresence>
|
||||
{confirmState && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setConfirmState(null)}
|
||||
className="fixed inset-0 bg-helios-ink/40 backdrop-blur-md z-[120]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[121]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
className="w-full max-w-sm bg-helios-surface border border-helios-line/20 rounded-2xl shadow-2xl pointer-events-auto overflow-hidden mx-4"
|
||||
>
|
||||
<div className="p-6 space-y-2">
|
||||
<h3 className="text-lg font-bold text-helios-ink">{confirmState.title}</h3>
|
||||
<p className="text-sm text-helios-slate">{confirmState.body}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||
<button
|
||||
onClick={() => setConfirmState(null)}
|
||||
className="px-4 py-2 text-sm font-semibold text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const action = confirmState.onConfirm;
|
||||
setConfirmState(null);
|
||||
await action();
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-semibold rounded-lg",
|
||||
confirmState.confirmTone === "danger"
|
||||
? "bg-red-500/15 text-red-500 hover:bg-red-500/25"
|
||||
: "bg-helios-solar text-helios-main hover:brightness-110"
|
||||
)}
|
||||
>
|
||||
{confirmState.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function NotificationSettings() {
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Name</label>
|
||||
<input
|
||||
@@ -194,7 +194,7 @@ export default function NotificationSettings() {
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Events</label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{['completed', 'failed', 'queued'].map(evt => (
|
||||
<label key={evt} className="flex items-center gap-2 text-sm text-helios-ink cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ScheduleSettings() {
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Start Time</label>
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FolderOpen, Bell, Calendar, FileCog, Cog, Server, LayoutGrid, Palette } from "lucide-react";
|
||||
import WatchFolders from "./WatchFolders";
|
||||
@@ -44,15 +44,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const tabSections = useMemo(
|
||||
() => [
|
||||
{ label: "Personalize", ids: ["appearance"] },
|
||||
{ label: "Library", ids: ["watch", "files", "schedule"] },
|
||||
{ label: "Processing", ids: ["transcode", "hardware"] },
|
||||
{ label: "System", ids: ["notifications", "system"] },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const navItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) {
|
||||
@@ -60,6 +52,13 @@ export default function SettingsPanel() {
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = navItemRefs.current[activeTab];
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
y: direction > 0 ? 20 : -20,
|
||||
@@ -81,44 +80,36 @@ export default function SettingsPanel() {
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Sidebar Navigation for Settings */}
|
||||
<nav className="w-full lg:w-64 flex-shrink-0">
|
||||
<div className="sticky top-8 space-y-6">
|
||||
{tabSections.map((section) => (
|
||||
<div key={section.label} className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.25em] text-helios-slate/50 px-2">
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{section.ids.map((tabId) => {
|
||||
const tab = TABS.find((item) => item.id === tabId);
|
||||
if (!tab) return null;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => paginate(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 relative overflow-hidden group ${isActive
|
||||
? "text-helios-ink bg-helios-surface-soft shadow-sm border border-helios-line/20"
|
||||
: "text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft/50"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-tab"
|
||||
className="absolute inset-0 bg-helios-surface-soft border border-helios-line/20 rounded-xl"
|
||||
initial={false}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<tab.icon size={18} className={isActive ? "text-helios-solar" : "opacity-70 group-hover:opacity-100"} />
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky top-8 max-h-[calc(100vh-8rem)] overflow-y-auto pr-1 space-y-1">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
navItemRefs.current[tab.id] = node;
|
||||
}}
|
||||
onClick={() => paginate(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 relative overflow-hidden group ${isActive
|
||||
? "text-helios-ink bg-helios-surface-soft shadow-sm border border-helios-line/20"
|
||||
: "text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft/50"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-tab"
|
||||
className="absolute inset-0 bg-helios-surface-soft border border-helios-line/20 rounded-xl"
|
||||
initial={false}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<tab.icon size={18} className={isActive ? "text-helios-solar" : "opacity-70 group-hover:opacity-100"} />
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
User,
|
||||
Lock,
|
||||
Video,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -55,9 +54,6 @@ export default function SetupWizard() {
|
||||
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||
|
||||
// Tooltip state
|
||||
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
username: '',
|
||||
password: '',
|
||||
@@ -67,7 +63,7 @@ export default function SetupWizard() {
|
||||
output_codec: 'av1',
|
||||
quality_profile: 'balanced',
|
||||
directories: ['/media/movies'],
|
||||
allow_cpu_encoding: false,
|
||||
allow_cpu_encoding: true,
|
||||
enable_telemetry: true
|
||||
});
|
||||
|
||||
@@ -315,7 +311,7 @@ export default function SetupWizard() {
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Transcoding Target</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, output_codec: "av1" })}
|
||||
className={clsx(
|
||||
@@ -361,44 +357,6 @@ export default function SetupWizard() {
|
||||
<p className="text-[10px] text-helios-slate italic">How many files to process at the same time.</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-helios-line/10">
|
||||
<label className="flex items-center justify-between group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">Anonymous Telemetry</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setActiveTooltip('telemetry')}
|
||||
onMouseLeave={() => setActiveTooltip(null)}
|
||||
className="text-helios-slate/40 hover:text-helios-solar transition-colors"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{activeTooltip === 'telemetry' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute bottom-full left-0 mb-2 w-48 p-2 bg-helios-surface-soft border border-helios-line/40 rounded-lg shadow-xl text-[10px] text-helios-slate z-50 leading-relaxed"
|
||||
>
|
||||
Help improve Alchemist by sending anonymous usage statistics and error reports. No filenames or personal data are ever collected.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_telemetry}
|
||||
onChange={(e) => setConfig({ ...config, enable_telemetry: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-helios-solar"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{hardware && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 flex items-center gap-3">
|
||||
@@ -487,7 +445,7 @@ export default function SetupWizard() {
|
||||
Final Review
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft/50 border border-helios-line/20 text-xs text-helios-slate space-y-2">
|
||||
<p>ACCOUNT: <span className="text-helios-ink font-bold">{config.username}</span></p>
|
||||
<p>TARGET: <span className="text-helios-ink font-bold uppercase">{config.output_codec}</span></p>
|
||||
|
||||
@@ -22,11 +22,11 @@ const navItems = [
|
||||
---
|
||||
|
||||
<aside
|
||||
class="w-64 bg-helios-surface border-r border-helios-line/60 flex flex-col p-4 gap-4"
|
||||
class="w-full lg:w-64 bg-helios-surface border-b lg:border-b-0 lg:border-r border-helios-line/60 flex flex-col lg:flex-col p-3 lg:p-4 gap-3 lg:gap-4"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 px-2 pb-4 border-b border-helios-line/40"
|
||||
class="flex items-center gap-3 px-2 pb-3 lg:pb-4 border-b border-helios-line/40"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-helios-solar text-helios-main flex items-center justify-center font-bold"
|
||||
@@ -36,7 +36,7 @@ const navItems = [
|
||||
<span class="font-bold text-lg text-helios-ink">Alchemist</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-col gap-2 flex-1">
|
||||
<nav class="flex flex-row lg:flex-col gap-2 flex-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0">
|
||||
{
|
||||
navItems.map(({ href, label, Icon }) => {
|
||||
const isActive =
|
||||
@@ -46,7 +46,7 @@ const navItems = [
|
||||
<a
|
||||
href={href}
|
||||
class:list={[
|
||||
"flex items-center gap-3 px-3 py-2 rounded-xl transition-colors",
|
||||
"flex items-center gap-3 px-3 py-2 rounded-xl transition-colors whitespace-nowrap shrink-0",
|
||||
isActive
|
||||
? "bg-helios-solar text-helios-main font-bold"
|
||||
: "text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink",
|
||||
@@ -60,7 +60,7 @@ const navItems = [
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="mt-auto hidden lg:block">
|
||||
<SystemStatus client:load />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function SystemStatus() {
|
||||
</div>
|
||||
|
||||
{/* Main Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-helios-surface-soft/50 rounded-2xl p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
|
||||
<Zap size={20} className="text-helios-solar opacity-80" />
|
||||
<div className="flex flex-col">
|
||||
@@ -198,7 +198,7 @@ export default function SystemStatus() {
|
||||
</div>
|
||||
|
||||
{/* Secondary Metrics Row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-xl bg-status-success/5 border border-status-success/10 flex flex-col items-center justify-center text-center">
|
||||
<CheckCircle2 size={16} className="text-status-success mb-1" />
|
||||
<span className="text-lg font-bold text-helios-ink">{stats.completed}</span>
|
||||
|
||||
@@ -21,9 +21,14 @@ interface TranscodeSettingsPayload {
|
||||
size_reduction_threshold: number;
|
||||
min_bpp_threshold: number;
|
||||
min_file_size_mb: number;
|
||||
output_codec: "av1" | "hevc";
|
||||
output_codec: "av1" | "hevc" | "h264";
|
||||
quality_profile: "quality" | "balanced" | "speed";
|
||||
threads: number;
|
||||
allow_fallback: boolean;
|
||||
hdr_mode: "preserve" | "tonemap";
|
||||
tonemap_algorithm: "hable" | "mobius" | "reinhard" | "clip";
|
||||
tonemap_peak: number;
|
||||
tonemap_desat: number;
|
||||
}
|
||||
|
||||
export default function TranscodeSettings() {
|
||||
@@ -108,9 +113,9 @@ export default function TranscodeSettings() {
|
||||
{/* Codec Selection */}
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Video size={14} /> Output Codec
|
||||
<Video size={14} /> Preferred Codec
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, output_codec: "av1" })}
|
||||
className={cn(
|
||||
@@ -121,7 +126,7 @@ export default function TranscodeSettings() {
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-lg">AV1</span>
|
||||
<span className="text-xs text-center opacity-70">Best compression, requires modern hardware.</span>
|
||||
<span className="text-xs text-center opacity-70">Best compression, depends on encoder availability.</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, output_codec: "hevc" })}
|
||||
@@ -135,6 +140,18 @@ export default function TranscodeSettings() {
|
||||
<span className="font-bold text-lg">HEVC (H.265)</span>
|
||||
<span className="text-xs text-center opacity-70">Broad compatibility, faster hardware encoding.</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, output_codec: "h264" })}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
|
||||
settings.output_codec === "h264"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
|
||||
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-lg">H.264</span>
|
||||
<span className="text-xs text-center opacity-70">Maximum compatibility, larger files.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +160,7 @@ export default function TranscodeSettings() {
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Gauge size={14} /> Quality Profile
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{(["speed", "balanced", "quality"] as const).map((profile) => (
|
||||
<button
|
||||
key={profile}
|
||||
@@ -161,6 +178,108 @@ export default function TranscodeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between rounded-2xl border border-helios-line/20 bg-helios-surface-soft/60 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">Allow Fallback</p>
|
||||
<p className="text-[10px] text-helios-slate mt-1">If preferred codec is unavailable, use the best available fallback.</p>
|
||||
</div>
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
id="fallback-toggle"
|
||||
type="checkbox"
|
||||
checked={settings.allow_fallback}
|
||||
onChange={(e) => setSettings({ ...settings, allow_fallback: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-5 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-helios-solar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HDR + Tonemapping */}
|
||||
<div className="md:col-span-2 space-y-3 pt-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Film size={14} /> HDR Handling
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, hdr_mode: "preserve" })}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
|
||||
settings.hdr_mode === "preserve"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
|
||||
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">Preserve HDR</span>
|
||||
<span className="text-xs text-center opacity-70">Keep HDR metadata and color space intact.</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, hdr_mode: "tonemap" })}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
|
||||
settings.hdr_mode === "tonemap"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
|
||||
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">Tonemap to SDR</span>
|
||||
<span className="text-xs text-center opacity-70">Convert HDR to SDR for compatibility.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.hdr_mode === "tonemap" && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Gauge size={14} /> Tonemap Algorithm
|
||||
</label>
|
||||
<select
|
||||
value={settings.tonemap_algorithm}
|
||||
onChange={(e) => setSettings({ ...settings, tonemap_algorithm: e.target.value as TranscodeSettingsPayload["tonemap_algorithm"] })}
|
||||
className="w-full bg-helios-surface border border-helios-line/30 rounded-xl px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
|
||||
>
|
||||
<option value="hable">Hable</option>
|
||||
<option value="mobius">Mobius</option>
|
||||
<option value="reinhard">Reinhard</option>
|
||||
<option value="clip">Clip</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-helios-slate ml-1">Choose the tone curve for HDR → SDR conversion.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Scale size={14} /> Tonemap Peak (nits)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="1000"
|
||||
value={settings.tonemap_peak}
|
||||
onChange={(e) => setSettings({ ...settings, tonemap_peak: parseFloat(e.target.value) || 100 })}
|
||||
className="w-full bg-helios-surface border border-helios-line/30 rounded-xl px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate ml-1">Peak brightness used for tone mapping.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Zap size={14} /> Tonemap Desaturation
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.tonemap_desat}
|
||||
onChange={(e) => setSettings({ ...settings, tonemap_desat: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-helios-surface border border-helios-line/30 rounded-xl px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate ml-1">Reduce oversaturated highlights after tonemapping.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Numeric Inputs */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
|
||||
@@ -8,11 +8,11 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
<Layout title="Alchemist | Dashboard">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-hidden">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
@@ -9,11 +9,11 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
<Layout title="Alchemist | Jobs">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-y-auto">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Jobs
|
||||
</h1>
|
||||
@@ -23,7 +23,7 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<JobManager client:load />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,11 +7,11 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
<Layout title="Alchemist | Library">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-y-auto">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Library
|
||||
</h1>
|
||||
@@ -21,9 +21,9 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<div
|
||||
class="p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
|
||||
class="p-6 sm:p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
|
||||
>
|
||||
<p class="text-helios-slate">
|
||||
Library browser interface coming soon.
|
||||
|
||||
@@ -59,6 +59,15 @@ import { User, Lock, ArrowRight, AlertTriangle } from "lucide-react";
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
fetch("/api/setup/status")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.setup_required) {
|
||||
window.location.href = "/setup";
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to check setup status", err));
|
||||
|
||||
const form = document.getElementById('login-form') as HTMLFormElement;
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
<Layout title="Alchemist | Logs">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-y-auto">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Logs
|
||||
</h1>
|
||||
@@ -22,7 +22,7 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="flex-1 p-6 min-h-0">
|
||||
<div class="flex-1 p-4 sm:p-6 min-h-0">
|
||||
<LogViewer client:load />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,11 +9,11 @@ import { Settings } from "lucide-react";
|
||||
<Layout title="Alchemist | Settings">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-y-auto">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
@@ -23,7 +23,7 @@ import { Settings } from "lucide-react";
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="px-6 pb-6">
|
||||
<div class="px-4 sm:px-6 pb-6">
|
||||
<SettingsPanel client:load />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,11 +9,11 @@ import { BarChart3 } from "lucide-react";
|
||||
<Layout title="Alchemist | Statistics">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<main class="app-main p-4 sm:p-6 lg:p-8 overflow-y-auto">
|
||||
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
class="text-2xl sm:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Statistics
|
||||
</h1>
|
||||
|
||||
@@ -76,6 +76,28 @@
|
||||
--border-subtle: 90 55 45;
|
||||
}
|
||||
|
||||
[data-color-profile="flare"] {
|
||||
--bg-main: 16 10 8;
|
||||
--bg-panel: 26 16 12;
|
||||
--bg-elevated: 36 22 16;
|
||||
--accent-primary: 255 95 60;
|
||||
--accent-secondary: 255 160 110;
|
||||
--text-primary: 250 234 225;
|
||||
--text-muted: 190 160 148;
|
||||
--border-subtle: 86 58 48;
|
||||
}
|
||||
|
||||
[data-color-profile="tropic"] {
|
||||
--bg-main: 12 10 8;
|
||||
--bg-panel: 22 16 12;
|
||||
--bg-elevated: 32 22 16;
|
||||
--accent-primary: 255 150 60;
|
||||
--accent-secondary: 120 235 180;
|
||||
--text-primary: 246 235 220;
|
||||
--text-muted: 180 165 145;
|
||||
--border-subtle: 78 60 42;
|
||||
}
|
||||
|
||||
[data-color-profile="deep-blue"] {
|
||||
--bg-main: 8 10 20;
|
||||
--bg-panel: 15 20 35;
|
||||
@@ -142,6 +164,28 @@
|
||||
--border-subtle: 40 80 70;
|
||||
}
|
||||
|
||||
[data-color-profile="mint-night"] {
|
||||
--bg-main: 6 12 12;
|
||||
--bg-panel: 12 20 20;
|
||||
--bg-elevated: 18 28 28;
|
||||
--accent-primary: 90 210 175;
|
||||
--accent-secondary: 140 235 205;
|
||||
--text-primary: 226 246 240;
|
||||
--text-muted: 145 175 170;
|
||||
--border-subtle: 40 70 62;
|
||||
}
|
||||
|
||||
[data-color-profile="cascade"] {
|
||||
--bg-main: 7 12 18;
|
||||
--bg-panel: 12 20 28;
|
||||
--bg-elevated: 18 28 38;
|
||||
--accent-primary: 80 170 220;
|
||||
--accent-secondary: 140 210 245;
|
||||
--text-primary: 225 242 250;
|
||||
--text-muted: 145 170 185;
|
||||
--border-subtle: 40 65 80;
|
||||
}
|
||||
|
||||
[data-color-profile="lavender"] {
|
||||
--bg-main: 15 15 25;
|
||||
--bg-panel: 25 25 35;
|
||||
@@ -208,6 +252,28 @@
|
||||
--border-subtle: 75 65 95;
|
||||
}
|
||||
|
||||
[data-color-profile="mauve"] {
|
||||
--bg-main: 18 14 22;
|
||||
--bg-panel: 28 22 32;
|
||||
--bg-elevated: 38 30 44;
|
||||
--accent-primary: 200 140 210;
|
||||
--accent-secondary: 220 180 230;
|
||||
--text-primary: 240 232 248;
|
||||
--text-muted: 175 165 190;
|
||||
--border-subtle: 80 70 100;
|
||||
}
|
||||
|
||||
[data-color-profile="haze"] {
|
||||
--bg-main: 20 18 22;
|
||||
--bg-panel: 30 26 32;
|
||||
--bg-elevated: 40 34 44;
|
||||
--accent-primary: 190 170 230;
|
||||
--accent-secondary: 210 200 240;
|
||||
--text-primary: 242 236 252;
|
||||
--text-muted: 180 170 198;
|
||||
--border-subtle: 88 78 108;
|
||||
}
|
||||
|
||||
[data-color-profile="ivory"] {
|
||||
--bg-main: 247 243 235;
|
||||
--bg-panel: 255 252 246;
|
||||
@@ -274,6 +340,28 @@
|
||||
--border-subtle: 205 220 212;
|
||||
}
|
||||
|
||||
[data-color-profile="sprout"] {
|
||||
--bg-main: 236 245 238;
|
||||
--bg-panel: 246 252 248;
|
||||
--bg-elevated: 255 255 255;
|
||||
--accent-primary: 90 170 140;
|
||||
--accent-secondary: 140 210 170;
|
||||
--text-primary: 34 46 40;
|
||||
--text-muted: 96 112 104;
|
||||
--border-subtle: 206 222 214;
|
||||
}
|
||||
|
||||
[data-color-profile="glow"] {
|
||||
--bg-main: 244 246 236;
|
||||
--bg-panel: 252 254 244;
|
||||
--bg-elevated: 255 255 255;
|
||||
--accent-primary: 190 170 90;
|
||||
--accent-secondary: 220 200 130;
|
||||
--text-primary: 42 44 32;
|
||||
--text-muted: 108 110 92;
|
||||
--border-subtle: 220 224 200;
|
||||
}
|
||||
|
||||
[data-color-profile="midnight"] {
|
||||
--bg-main: 0 0 0;
|
||||
--bg-panel: 5 5 5;
|
||||
@@ -339,6 +427,28 @@
|
||||
--text-muted: 145 150 175;
|
||||
--border-subtle: 45 50 75;
|
||||
}
|
||||
|
||||
[data-color-profile="nocturne"] {
|
||||
--bg-main: 8 9 12;
|
||||
--bg-panel: 14 16 20;
|
||||
--bg-elevated: 20 22 28;
|
||||
--accent-primary: 120 160 210;
|
||||
--accent-secondary: 170 200 235;
|
||||
--text-primary: 228 236 248;
|
||||
--text-muted: 150 162 178;
|
||||
--border-subtle: 42 50 66;
|
||||
}
|
||||
|
||||
[data-color-profile="slate"] {
|
||||
--bg-main: 10 12 14;
|
||||
--bg-panel: 16 18 22;
|
||||
--bg-elevated: 22 24 30;
|
||||
--accent-primary: 150 170 190;
|
||||
--accent-secondary: 190 210 230;
|
||||
--text-primary: 228 236 242;
|
||||
--text-muted: 156 168 178;
|
||||
--border-subtle: 50 58 68;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -370,11 +480,11 @@
|
||||
|
||||
@layer components {
|
||||
.app-shell {
|
||||
@apply flex h-full min-h-screen w-full;
|
||||
@apply flex flex-col lg:flex-row h-full min-h-screen w-full;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
@apply flex-1 flex flex-col h-full min-w-0;
|
||||
@apply flex-1 flex flex-col h-full min-w-0 overflow-x-hidden;
|
||||
background: inherit;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user