Implemented the setup wizard welcome step as requested. Added `web/src/c

This commit is contained in:
2026-03-30 21:00:11 -04:00
parent cfe85f0820
commit ffb5562f95
13 changed files with 1156 additions and 748 deletions

View File

@@ -326,12 +326,14 @@ Request:
### `GET /api/system/hardware`
Returns the current detected hardware backend, supported
codecs, backends, and any detection notes.
codecs, backends, selection reason, probe summary, and any
detection notes.
### `GET /api/system/hardware/probe-log`
Returns the per-encoder probe log with success/failure
status and stderr excerpts.
status, selected-flag metadata, summary text, and stderr
excerpts.
### `GET /api/system/resources`

View File

@@ -3,17 +3,26 @@ title: Hardware Acceleration
description: GPU detection, vendor selection, and fallback behavior.
---
Alchemist detects hardware automatically at startup and
selects the best available encoder. Override in
Alchemist detects hardware automatically at startup,
actively probes every plausible backend/codec candidate,
and selects a single active device/backend with a
deterministic scoring policy. Override in
**Settings → Hardware**.
## Detection order (auto mode)
## Detection flow (auto mode)
1. Apple VideoToolbox (macOS only)
2. NVIDIA NVENC (checks `/dev/nvidiactl`)
3. Intel VAAPI, then QSV fallback (checks `/dev/dri/renderD128`)
4. AMD VAAPI (Linux) or AMF (Windows)
5. CPU fallback (SVT-AV1, x265, x264)
1. Discover plausible candidates
- Apple VideoToolbox on macOS
- NVIDIA NVENC when NVIDIA is present
- Intel / AMD render nodes on Linux via `/sys/class/drm/renderD*`
- AMD AMF on Windows
2. Actively probe every candidate encoder with a short
FFmpeg test encode
3. Group successful probes by device path / vendor
4. Choose one active device/backend using codec coverage,
backend preference, and stable vendor ordering
5. Fall back to CPU only if no GPU probe succeeds and CPU
fallback is enabled
## Encoder support by vendor
@@ -27,9 +36,19 @@ selects the best available encoder. Override in
## Hardware probe
Alchemist probes each encoder at startup with a test encode.
See results in **Settings → Hardware → Probe Log**. Probe
failures include the FFmpeg stderr explaining why.
Alchemist probes each encoder at startup with a test encode
using a standardized `256x256` lavfi input.
See results in **Settings → Hardware → Probe Log**. The UI
shows:
- the selected device/backend reason
- probe counts (attempted / succeeded / failed)
- per-probe summaries for success and failure
- full FFmpeg stderr for failed probes
On Linux, explicit device paths only apply to render-node
backends such as VAAPI and QSV.
## Vendor-specific guides

View File

@@ -790,6 +790,8 @@ mod tests {
supported_codecs: vec!["av1".to_string()],
backends: Vec::new(),
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: hardware::ProbeSummary::default(),
}));
let hardware_probe_log = Arc::new(RwLock::new(hardware::HardwareProbeLog::default()));
let transcoder = Arc::new(Transcoder::new());

View File

@@ -851,6 +851,8 @@ mod tests {
device_path: Some(path.to_string()),
}],
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: crate::system::hardware::ProbeSummary::default(),
}
}
@@ -866,14 +868,6 @@ mod tests {
stderr: Vec::new(),
})
}
fn status(
&self,
_program: &str,
_args: &[String],
) -> std::io::Result<std::process::ExitStatus> {
Ok(exit_status(true))
}
}
fn exit_status(success: bool) -> std::process::ExitStatus {

View File

@@ -1534,6 +1534,8 @@ mod tests {
supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()],
backends: Vec::new(),
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: crate::system::hardware::ProbeSummary::default(),
}));
let (tx, _rx) = broadcast::channel(8);
let (jobs_tx, _) = broadcast::channel(100);

View File

@@ -55,6 +55,8 @@ where
supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()],
backends: Vec::new(),
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: crate::system::hardware::ProbeSummary::default(),
}));
let hardware_probe_log = Arc::new(RwLock::new(HardwareProbeLog::default()));
let (tx, _rx) = broadcast::channel(tx_capacity);
@@ -468,6 +470,10 @@ async fn hardware_probe_log_route_returns_runtime_log()
device_path: None,
success: false,
stderr: Some("Unknown encoder".to_string()),
vendor: "apple".to_string(),
codec: "hevc".to_string(),
selected: false,
summary: "Encoder unavailable in current FFmpeg build".to_string(),
}],
};

File diff suppressed because it is too large Load Diff

View File

@@ -314,6 +314,8 @@ where
supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()],
backends: Vec::new(),
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: alchemist::system::hardware::ProbeSummary::default(),
})),
Arc::new(broadcast::channel(16).0),
event_channels,

View File

@@ -117,6 +117,8 @@ where
supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()],
backends: Vec::new(),
detection_notes: Vec::new(),
selection_reason: String::new(),
probe_summary: alchemist::system::hardware::ProbeSummary::default(),
})),
Arc::new(broadcast::channel(16).0),
event_channels,

View File

@@ -7,6 +7,12 @@ interface HardwareInfo {
vendor: string;
device_path: string | null;
supported_codecs: string[];
selection_reason?: string;
probe_summary?: {
attempted: number;
succeeded: number;
failed: number;
};
backends?: Array<{
kind: string;
codec: string;
@@ -17,10 +23,14 @@ interface HardwareInfo {
}
interface HardwareProbeEntry {
vendor: string;
codec: string;
encoder: string;
backend: string;
device_path: string | null;
success: boolean;
selected: boolean;
summary: string;
stderr?: string | null;
}
@@ -205,8 +215,6 @@ export default function HardwareSettings() {
const vendor = normalizeVendor(info.vendor);
const details = getVendorDetails(info.vendor);
const detectionNotes = info.detection_notes ?? [];
const failedProbeEntries = probeLog.entries.filter((entry) => !entry.success);
const shouldShowProbeLog = vendor === "cpu" || failedProbeEntries.length > 0;
const intelVaapiDetected = vendor === "intel" && (info.backends ?? []).some((backend) => backend.kind.toLowerCase() === "vaapi");
const handleHardwareSettingsBlur = (event: React.FocusEvent<HTMLDivElement>) => {
@@ -254,6 +262,14 @@ export default function HardwareSettings() {
{info.device_path || (vendor === "nvidia" ? "NVIDIA Driver (Direct)" : "Auto-detected Interface")}
</div>
</div>
{info.selection_reason && (
<div>
<span className="text-xs font-medium text-helios-slate block mb-1.5 ml-0.5">Selection Reason</span>
<div className="bg-helios-surface-soft border border-helios-line/30 rounded-lg px-3 py-2 text-xs text-helios-slate shadow-inner">
{info.selection_reason}
</div>
</div>
)}
</div>
</div>
@@ -280,6 +296,23 @@ export default function HardwareSettings() {
</div>
)}
</div>
{info.probe_summary && (
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
<div className="rounded-lg bg-helios-surface-soft border border-helios-line/20 px-3 py-2">
<div className="text-helios-slate">Attempted</div>
<div className="mt-1 font-bold text-helios-ink">{info.probe_summary.attempted}</div>
</div>
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-3 py-2">
<div className="text-emerald-500">Succeeded</div>
<div className="mt-1 font-bold text-emerald-500">{info.probe_summary.succeeded}</div>
</div>
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2">
<div className="text-red-500">Failed</div>
<div className="mt-1 font-bold text-red-500">{info.probe_summary.failed}</div>
</div>
</div>
)}
</div>
</div>
@@ -331,49 +364,52 @@ export default function HardwareSettings() {
</details>
)}
{shouldShowProbeLog && (
<details className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/30 px-4 py-3">
<summary className="cursor-pointer text-xs font-medium text-helios-slate hover:text-helios-ink">
Show detection log
</summary>
<div className="mt-3 space-y-2">
{probeLog.entries.length > 0 ? probeLog.entries.map((entry, index) => {
const firstLine = entry.stderr?.split("\n")[0]?.trim();
const iconClassName = entry.success ? "text-emerald-500" : "text-red-500";
<details className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/30 px-4 py-3">
<summary className="cursor-pointer text-xs font-medium text-helios-slate hover:text-helios-ink">
Show detection log
</summary>
<div className="mt-3 space-y-2">
{probeLog.entries.length > 0 ? probeLog.entries.map((entry, index) => {
const iconClassName = entry.success ? "text-emerald-500" : "text-red-500";
return (
<details
key={`${entry.encoder}-${entry.backend}-${entry.device_path ?? "auto"}-${index}`}
className="rounded-md border border-helios-line/15 bg-helios-surface/60 px-3 py-2"
>
<summary className="cursor-pointer text-xs text-helios-slate">
<span className="inline-flex items-center gap-2">
{entry.success ? <CheckCircle2 size={12} className={iconClassName} /> : <XCircle size={12} className={iconClassName} />}
<span className="font-medium text-helios-ink">{entry.encoder}</span>
{!entry.success && firstLine && (
<span className="text-helios-slate">{firstLine}</span>
)}
return (
<details
key={`${entry.encoder}-${entry.backend}-${entry.device_path ?? "auto"}-${index}`}
className="rounded-md border border-helios-line/15 bg-helios-surface/60 px-3 py-2"
>
<summary className="cursor-pointer text-xs text-helios-slate">
<span className="inline-flex flex-wrap items-center gap-2">
{entry.success ? <CheckCircle2 size={12} className={iconClassName} /> : <XCircle size={12} className={iconClassName} />}
<span className="font-medium text-helios-ink">{entry.encoder}</span>
<span className="rounded bg-helios-surface-soft px-2 py-0.5 font-mono text-[11px] uppercase text-helios-slate">
{entry.codec}
</span>
</summary>
<div className="mt-2 space-y-2">
<p className="text-xs text-helios-slate">
{entry.backend}
{entry.device_path ? `${entry.device_path}` : ""}
</p>
{entry.stderr && (
<pre className="overflow-x-auto rounded bg-helios-main/70 p-2 text-xs text-helios-slate font-mono whitespace-pre-wrap break-words">
{entry.stderr}
</pre>
{entry.selected && (
<span className="rounded bg-helios-solar/10 px-2 py-0.5 text-[11px] font-semibold text-helios-solar">
Selected
</span>
)}
</div>
</details>
);
}) : (
<p className="text-xs text-helios-slate">No encoder probes were recorded during detection.</p>
)}
</div>
</details>
)}
<span className="text-helios-slate">{entry.summary}</span>
</span>
</summary>
<div className="mt-2 space-y-2">
<p className="text-xs text-helios-slate">
{entry.vendor} {entry.backend}
{entry.device_path ? `${entry.device_path}` : ""}
</p>
{entry.stderr && (
<pre className="overflow-x-auto rounded bg-helios-main/70 p-2 text-xs text-helios-slate font-mono whitespace-pre-wrap break-words">
{entry.stderr}
</pre>
)}
</div>
</details>
);
}) : (
<p className="text-xs text-helios-slate">No encoder probes were recorded during detection.</p>
)}
</div>
</details>
{settings && (
<div

View File

@@ -6,6 +6,7 @@ import ProcessingStep from "./setup/ProcessingStep";
import ReviewStep from "./setup/ReviewStep";
import RuntimeStep from "./setup/RuntimeStep";
import SetupFrame from "./setup/SetupFrame";
import WelcomeStep from "./setup/WelcomeStep";
import {
DEFAULT_NOTIFICATION_DRAFT,
DEFAULT_SCHEDULE_DRAFT,
@@ -27,7 +28,7 @@ import type {
} from "./setup/types";
export default function SetupWizard() {
const [step, setStep] = useState(1);
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
@@ -153,6 +154,12 @@ export default function SetupWizard() {
const currentStep = (() => {
switch (step) {
case 0:
return (
<WelcomeStep
onGetStarted={() => setStep(1)}
/>
);
case 1:
return (
<AdminAccountStep

View File

@@ -26,16 +26,18 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
<div className="flex flex-col flex-1 min-h-0">
{/* Progress bar — 2px solar line at top of app-main */}
<div className="h-0.5 w-full bg-helios-surface-soft shrink-0">
<motion.div
className="bg-helios-solar h-full"
initial={{ width: 0 }}
animate={{
width: `${(step / SETUP_STEP_COUNT) * 100}%`
}}
transition={{ duration: 0.3 }}
/>
</div>
{step > 0 && (
<div className="h-0.5 w-full bg-helios-surface-soft shrink-0">
<motion.div
className="bg-helios-solar h-full"
initial={{ width: 0 }}
animate={{
width: `${(step / SETUP_STEP_COUNT) * 100}%`
}}
transition={{ duration: 0.3 }}
/>
</div>
)}
{/* Read-only config warning */}
{!configMutable && (
@@ -57,7 +59,7 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
</div>
{/* Navigation footer */}
{step < 6 && (
{step > 0 && step < 6 && (
<div className="shrink-0 border-t border-helios-line/20
bg-helios-surface/50 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center

View File

@@ -0,0 +1,42 @@
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
interface WelcomeStepProps {
onGetStarted: () => void;
}
export default function WelcomeStep(
{ onGetStarted }: WelcomeStepProps
) {
return (
<motion.div
key="welcome"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex flex-col items-center justify-center min-h-[60vh] gap-8 text-center px-6"
>
<div className="space-y-3">
<h1 className="text-5xl font-extrabold tracking-tight text-helios-ink leading-none">
Alchemist
</h1>
<div className="h-0.5 w-12 bg-helios-solar mx-auto rounded-full" />
</div>
<p className="text-base text-helios-slate max-w-sm leading-relaxed">
Self-hosted video transcoding.
Set it up once, let it run.
</p>
<button
type="button"
onClick={onGetStarted}
className="flex items-center gap-2 rounded-lg bg-helios-solar px-8 py-3 text-sm font-semibold text-helios-main hover:opacity-90 transition-opacity"
>
Get Started
<ArrowRight size={16} />
</button>
</motion.div>
);
}