mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Implemented the setup wizard welcome step as requested. Added `web/src/c
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
42
web/src/components/setup/WelcomeStep.tsx
Normal file
42
web/src/components/setup/WelcomeStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user