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:
brooklyn
2026-01-13 14:20:00 -05:00
parent 5590867f23
commit b3c030851d
41 changed files with 1498 additions and 348 deletions

View File

@@ -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"

View File

@@ -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
View File

@@ -26,7 +26,7 @@ dependencies = [
[[package]]
name = "alchemist"
version = "0.2.6-2"
version = "0.2.7"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "alchemist"
version = "0.2.6-2"
version = "0.2.7"
edition = "2021"
license = "GPL-3.0"

View File

@@ -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

View File

@@ -1 +1 @@
0.2.6-2
0.2.7

View File

@@ -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; AV1HEVC 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: inapp 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*

View File

@@ -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
```
---

View File

@@ -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!(

View File

@@ -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);

View File

@@ -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(),
));

View File

@@ -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));
}

View File

@@ -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())),

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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";

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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(),
}
}

View File

@@ -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 {

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-web",
"version": "0.2.6-2",
"0.2.7",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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.",
},
],
},
];

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.

View File

@@ -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');

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}