mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
Keep the engine running and improve job status details
This commit is contained in:
@@ -359,17 +359,6 @@ impl Agent {
|
||||
}
|
||||
Ok(None) => {
|
||||
drop(permit);
|
||||
if !self.is_paused() && !self.is_draining() {
|
||||
info!(
|
||||
"Queue empty — engine returning to \
|
||||
paused state automatically."
|
||||
);
|
||||
self.pause();
|
||||
let _ = self
|
||||
.event_channels
|
||||
.system
|
||||
.send(crate::db::SystemEvent::EngineStatusChanged);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -315,7 +315,11 @@ pub(crate) async fn get_job_detail_handler(
|
||||
|
||||
// Avoid long probes while the job is still active.
|
||||
let metadata = match job.status {
|
||||
JobState::Queued | JobState::Analyzing | JobState::Encoding | JobState::Remuxing => None,
|
||||
JobState::Queued
|
||||
| JobState::Analyzing
|
||||
| JobState::Encoding
|
||||
| JobState::Remuxing
|
||||
| JobState::Completed => None,
|
||||
_ => {
|
||||
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
|
||||
use crate::media::pipeline::Analyzer;
|
||||
|
||||
@@ -488,6 +488,19 @@ fn pci_vendor_to_vendor(value: &str) -> Option<Vendor> {
|
||||
}
|
||||
}
|
||||
|
||||
fn vendor_from_explicit_device_path(device_path: &Path) -> Option<Vendor> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let render_node = device_path.file_name()?.to_str()?;
|
||||
let vendor_path = Path::new("/sys/class/drm")
|
||||
.join(render_node)
|
||||
.join("device/vendor");
|
||||
let vendor_id = std::fs::read_to_string(vendor_path).ok()?;
|
||||
pci_vendor_to_vendor(&vendor_id)
|
||||
}
|
||||
|
||||
fn enumerate_linux_render_nodes_under(
|
||||
sys_class_drm: &Path,
|
||||
dev_dri_root: &Path,
|
||||
@@ -1030,10 +1043,40 @@ fn detect_explicit_device_path_with_runner_and_log<R: CommandRunner + ?Sized>(
|
||||
}
|
||||
|
||||
let mut detection_notes = Vec::new();
|
||||
let candidates: Vec<_> = discover_probe_candidates_with_runner(runner, &mut detection_notes)
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.device_path.as_deref() == Some(device_path))
|
||||
.collect();
|
||||
let resolved_vendor = preferred_vendor.or_else(|| vendor_from_explicit_device_path(Path::new(device_path)));
|
||||
let candidates = match resolved_vendor {
|
||||
Some(Vendor::Intel) => probe_candidates_for_vendor(
|
||||
Vendor::Intel,
|
||||
Some(device_path),
|
||||
&format!("Using configured device path {}", device_path),
|
||||
),
|
||||
Some(Vendor::Amd) => probe_candidates_for_vendor(
|
||||
Vendor::Amd,
|
||||
Some(device_path),
|
||||
&format!("Using configured device path {}", device_path),
|
||||
),
|
||||
Some(Vendor::Cpu) | Some(Vendor::Apple) | Some(Vendor::Nvidia) => Vec::new(),
|
||||
None => {
|
||||
append_detection_note(
|
||||
&mut detection_notes,
|
||||
format!(
|
||||
"Configured device path '{}' could not be mapped to a vendor; probing as both Intel and AMD render node",
|
||||
device_path
|
||||
),
|
||||
);
|
||||
let intel = probe_candidates_for_vendor(
|
||||
Vendor::Intel,
|
||||
Some(device_path),
|
||||
&format!("Using configured device path {}", device_path),
|
||||
);
|
||||
let amd = probe_candidates_for_vendor(
|
||||
Vendor::Amd,
|
||||
Some(device_path),
|
||||
&format!("Using configured device path {}", device_path),
|
||||
);
|
||||
[intel, amd].concat()
|
||||
}
|
||||
};
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Info, LogOut, Play, Square } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import { apiAction, apiJson } from "../lib/api";
|
||||
import { useSharedStats } from "../lib/statsStore";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
interface EngineStatus {
|
||||
@@ -19,6 +20,7 @@ export default function HeaderActions() {
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
||||
const [engineLoading, setEngineLoading] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const { stats } = useSharedStats();
|
||||
|
||||
const statusConfig = {
|
||||
running: {
|
||||
@@ -26,6 +28,11 @@ export default function HeaderActions() {
|
||||
label: "Running",
|
||||
labelColor: "text-status-success",
|
||||
},
|
||||
idle: {
|
||||
dot: "bg-helios-slate",
|
||||
label: "Idle",
|
||||
labelColor: "text-helios-slate",
|
||||
},
|
||||
paused: {
|
||||
dot: "bg-helios-solar",
|
||||
label: "Paused",
|
||||
@@ -39,6 +46,8 @@ export default function HeaderActions() {
|
||||
} as const;
|
||||
|
||||
const status = engineStatus?.status ?? "paused";
|
||||
const isIdle = status === "running" && (stats?.active ?? 0) === 0;
|
||||
const displayStatus: keyof typeof statusConfig = isIdle ? "idle" : status;
|
||||
|
||||
const refreshEngineStatus = async () => {
|
||||
const data = await apiJson<EngineStatus>("/api/engine/status");
|
||||
@@ -147,9 +156,9 @@ export default function HeaderActions() {
|
||||
|
||||
{/* Status pill */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-helios-line/20 bg-helios-surface-soft/60">
|
||||
<div className={`h-1.5 w-1.5 rounded-full shrink-0 ${statusConfig[status].dot}`} />
|
||||
<span className={`text-xs font-medium ${statusConfig[status].labelColor}`}>
|
||||
{statusConfig[status].label}
|
||||
<div className={`h-1.5 w-1.5 rounded-full shrink-0 ${statusConfig[displayStatus].dot}`} />
|
||||
<span className={`text-xs font-medium ${statusConfig[displayStatus].labelColor}`}>
|
||||
{statusConfig[displayStatus].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,36 @@ export function humanizeSkipReason(reason: string): SkipDetail {
|
||||
measured[rawKey.trim()] = rawValueParts.join("=").trim();
|
||||
}
|
||||
|
||||
const fallbackDisabledMatch = key.match(
|
||||
/^Preferred codec\s+(.+?)\s+unavailable and fallback disabled$/i
|
||||
);
|
||||
if (fallbackDisabledMatch) {
|
||||
measured.codec ??= fallbackDisabledMatch[1];
|
||||
return {
|
||||
summary: "Preferred encoder unavailable",
|
||||
detail: `The preferred codec (${measured.codec ?? "target codec"}) is not available and CPU fallback is disabled in settings.`,
|
||||
action: "Go to Settings -> Hardware and enable CPU fallback, or check that your GPU encoder is working correctly.",
|
||||
measured,
|
||||
};
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "analysis_failed":
|
||||
return {
|
||||
summary: "File could not be analyzed",
|
||||
detail: `FFprobe failed to read this file. It may be corrupt, incomplete, or in an unsupported format. Error: ${measured.error ?? "unknown"}`,
|
||||
action: "Try playing the file in VLC or another media player. If it plays fine, re-run the scan. If not, the file may be damaged.",
|
||||
measured,
|
||||
};
|
||||
|
||||
case "planning_failed":
|
||||
return {
|
||||
summary: "Transcoding plan could not be created",
|
||||
detail: `An internal error occurred while planning the transcode for this file. This is likely a bug. Error: ${measured.error ?? "unknown"}`,
|
||||
action: "Check the logs below for details. If this happens repeatedly, please report it as a bug.",
|
||||
measured,
|
||||
};
|
||||
|
||||
case "already_target_codec":
|
||||
return {
|
||||
summary: "Already in target format",
|
||||
@@ -113,6 +142,22 @@ export function humanizeSkipReason(reason: string): SkipDetail {
|
||||
measured,
|
||||
};
|
||||
|
||||
case "Output path matches input path":
|
||||
return {
|
||||
summary: "Output would overwrite source",
|
||||
detail: "The configured output path is the same as the source file. Alchemist refused to proceed to avoid overwriting your original file.",
|
||||
action: "Go to Settings -> Files and configure a different output suffix or output folder.",
|
||||
measured,
|
||||
};
|
||||
|
||||
case "Output already exists":
|
||||
return {
|
||||
summary: "Output file already exists",
|
||||
detail: "A transcoded version of this file already exists at the output path. Alchemist skipped it to avoid duplicating work.",
|
||||
action: "If you want to re-transcode it, delete the existing output file first, then retry the job.",
|
||||
measured,
|
||||
};
|
||||
|
||||
case "incomplete_metadata":
|
||||
return {
|
||||
summary: "Missing file metadata",
|
||||
@@ -137,6 +182,14 @@ export function humanizeSkipReason(reason: string): SkipDetail {
|
||||
measured,
|
||||
};
|
||||
|
||||
case "Low quality (VMAF)":
|
||||
return {
|
||||
summary: "Quality check failed",
|
||||
detail: "The encoded file scored below the minimum VMAF quality threshold. Alchemist rejected the output to protect quality.",
|
||||
action: "The original file has been preserved. You can lower the VMAF threshold in Settings -> Quality, or disable VMAF checking entirely.",
|
||||
measured,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
summary: "Decision recorded",
|
||||
@@ -150,6 +203,9 @@ export function humanizeSkipReason(reason: string): SkipDetail {
|
||||
function explainFailureSummary(summary: string): string {
|
||||
const normalized = summary.toLowerCase();
|
||||
|
||||
if (normalized.includes("cancelled")) {
|
||||
return "This job was cancelled before encoding completed. The original file is untouched.";
|
||||
}
|
||||
if (normalized.includes("no such file or directory")) {
|
||||
return "The source file could not be found. It may have been moved or deleted.";
|
||||
}
|
||||
@@ -165,6 +221,18 @@ function explainFailureSummary(summary: string): string {
|
||||
if (normalized.includes("out of memory") || normalized.includes("cannot allocate memory")) {
|
||||
return "The system ran out of memory during encoding. Try reducing concurrent jobs.";
|
||||
}
|
||||
if (normalized.includes("transcode_failed") || normalized.includes("ffmpeg exited")) {
|
||||
return "FFmpeg failed during encoding. This is often caused by a corrupt source file or an encoder configuration issue. Check the logs below for the specific FFmpeg error.";
|
||||
}
|
||||
if (normalized.includes("probing failed")) {
|
||||
return "FFprobe could not read this file. It may be corrupt or in an unsupported format.";
|
||||
}
|
||||
if (normalized.includes("planning_failed") || normalized.includes("planner")) {
|
||||
return "An error occurred while planning the transcode. Check the logs below for details.";
|
||||
}
|
||||
if (normalized.includes("output_size=0") || normalized.includes("output was empty")) {
|
||||
return "Encoding produced an empty output file. This usually means FFmpeg crashed silently. Check the logs below for FFmpeg output.";
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -192,6 +260,7 @@ interface Job {
|
||||
updated_at: string;
|
||||
vmaf_score?: number;
|
||||
decision_reason?: string;
|
||||
encoder?: string;
|
||||
}
|
||||
|
||||
interface JobMetadata {
|
||||
@@ -269,6 +338,7 @@ export default function JobManager() {
|
||||
const detailDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const detailLastFocusedRef = useRef<HTMLElement | null>(null);
|
||||
const confirmOpenRef = useRef(false);
|
||||
const encodeStartTimes = useRef<Map<number, number>>(new Map());
|
||||
const [confirmState, setConfirmState] = useState<{
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -407,6 +477,11 @@ export default function JobManager() {
|
||||
job_id: number;
|
||||
status: string;
|
||||
};
|
||||
if (status === "encoding") {
|
||||
encodeStartTimes.current.set(job_id, Date.now());
|
||||
} else {
|
||||
encodeStartTimes.current.delete(job_id);
|
||||
}
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === job_id ? { ...job, status } : job
|
||||
@@ -459,6 +534,28 @@ export default function JobManager() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const encodingJobIds = new Set<number>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status !== "encoding") {
|
||||
continue;
|
||||
}
|
||||
|
||||
encodingJobIds.add(job.id);
|
||||
if (!encodeStartTimes.current.has(job.id)) {
|
||||
encodeStartTimes.current.set(job.id, now);
|
||||
}
|
||||
}
|
||||
|
||||
for (const jobId of Array.from(encodeStartTimes.current.keys())) {
|
||||
if (!encodingJobIds.has(jobId)) {
|
||||
encodeStartTimes.current.delete(jobId);
|
||||
}
|
||||
}
|
||||
}, [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuJobId) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
@@ -686,6 +783,32 @@ export default function JobManager() {
|
||||
return [h, m, s].map(v => v.toString().padStart(2, "0")).join(":");
|
||||
};
|
||||
|
||||
const calcEta = (jobId: number, progress: number): string | null => {
|
||||
if (progress <= 0 || progress >= 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMs = encodeStartTimes.current.get(jobId);
|
||||
if (!startMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
const totalMs = elapsedMs / (progress / 100);
|
||||
const remainingMs = totalMs - elapsedMs;
|
||||
const remainingSecs = Math.round(remainingMs / 1000);
|
||||
|
||||
if (remainingSecs < 0) {
|
||||
return null;
|
||||
}
|
||||
if (remainingSecs < 60) {
|
||||
return `~${remainingSecs}s remaining`;
|
||||
}
|
||||
|
||||
const mins = Math.ceil(remainingSecs / 60);
|
||||
return `~${mins} min remaining`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
queued: "bg-helios-slate/10 text-helios-slate border-helios-slate/20",
|
||||
@@ -723,6 +846,9 @@ export default function JobManager() {
|
||||
const shouldShowFfmpegOutput = focusedJob
|
||||
? ["failed", "completed", "skipped"].includes(focusedJob.job.status) && focusedJobLogs.length > 0
|
||||
: false;
|
||||
const completedEncodeStats = focusedJob?.job.status === "completed"
|
||||
? focusedJob.encode_stats
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
@@ -964,6 +1090,19 @@ export default function JobManager() {
|
||||
<div className="text-xs text-right font-mono text-helios-slate">
|
||||
{job.progress.toFixed(1)}%
|
||||
</div>
|
||||
{job.status === "encoding" && (() => {
|
||||
const eta = calcEta(job.id, job.progress);
|
||||
return eta ? (
|
||||
<p className="text-[10px] text-helios-slate mt-0.5 font-mono">
|
||||
{eta}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
{job.status === "encoding" && job.encoder && (
|
||||
<span className="text-[10px] font-mono text-helios-solar opacity-70">
|
||||
{job.encoder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
job.vmaf_score ? (
|
||||
@@ -1164,114 +1303,160 @@ export default function JobManager() {
|
||||
{detailLoading && (
|
||||
<p className="text-xs text-helios-slate" aria-live="polite">Loading job details...</p>
|
||||
)}
|
||||
{focusedJob.metadata ? (
|
||||
{focusedJob.metadata || completedEncodeStats ? (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Activity size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Video Codec</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink capitalize">
|
||||
{focusedJob.metadata?.codec_name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-helios-slate">
|
||||
{(focusedJob.metadata?.bit_depth ? `${focusedJob.metadata.bit_depth}-bit` : "Unknown bit depth")} • {focusedJob.metadata?.container.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Maximize2 size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Resolution</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{focusedJob.metadata ? `${focusedJob.metadata.width}x${focusedJob.metadata.height}` : "-"}
|
||||
</p>
|
||||
<p className="text-xs text-helios-slate">
|
||||
{focusedJob.metadata?.fps.toFixed(2)} FPS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Clock size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Duration</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{focusedJob.metadata ? formatDuration(focusedJob.metadata.duration_secs) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-helios-slate/70 flex items-center gap-2">
|
||||
<Database size={12} /> Input Details
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">File Size</span>
|
||||
<span className="text-helios-ink font-bold">{focusedJob.metadata ? formatBytes(focusedJob.metadata.size_bytes) : "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Video Bitrate</span>
|
||||
<span className="text-helios-ink font-bold">
|
||||
{focusedJob.metadata && (focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps)
|
||||
? `${(((focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps) as number) / 1000).toFixed(0)} kbps`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Audio</span>
|
||||
<span className="text-helios-ink font-bold capitalize">
|
||||
{focusedJob.metadata?.audio_codec || "N/A"} ({focusedJob.metadata?.audio_channels || 0}ch)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-helios-solar flex items-center gap-2">
|
||||
<Zap size={12} /> Output Details
|
||||
</h3>
|
||||
{focusedJob.encode_stats ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Result Size</span>
|
||||
<span className="text-helios-solar font-bold">{formatBytes(focusedJob.encode_stats.output_size_bytes)}</span>
|
||||
{focusedJob.metadata && (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Activity size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Video Codec</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Reduction</span>
|
||||
<span className="text-green-500 font-bold">
|
||||
{((1 - focusedJob.encode_stats.compression_ratio) * 100).toFixed(1)}% Saved
|
||||
</span>
|
||||
<p className="text-sm font-bold text-helios-ink capitalize">
|
||||
{focusedJob.metadata.codec_name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-helios-slate">
|
||||
{(focusedJob.metadata.bit_depth ? `${focusedJob.metadata.bit_depth}-bit` : "Unknown bit depth")} • {focusedJob.metadata.container.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Maximize2 size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Resolution</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">VMAF Score</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-16 bg-helios-line/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-helios-solar" style={{ width: `${focusedJob.encode_stats.vmaf_score || 0}%` }} />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{`${focusedJob.metadata.width}x${focusedJob.metadata.height}`}
|
||||
</p>
|
||||
<p className="text-xs text-helios-slate">
|
||||
{focusedJob.metadata.fps.toFixed(2)} FPS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Clock size={12} />
|
||||
<span className="text-xs font-medium text-helios-slate">Duration</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{formatDuration(focusedJob.metadata.duration_secs)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-helios-slate/70 flex items-center gap-2">
|
||||
<Database size={12} /> Input Details
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">File Size</span>
|
||||
<span className="text-helios-ink font-bold">{formatBytes(focusedJob.metadata.size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Video Bitrate</span>
|
||||
<span className="text-helios-ink font-bold">
|
||||
{focusedJob.encode_stats.vmaf_score?.toFixed(1) || "-"}
|
||||
{(focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps)
|
||||
? `${(((focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps) as number) / 1000).toFixed(0)} kbps`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Audio</span>
|
||||
<span className="text-helios-ink font-bold capitalize">
|
||||
{focusedJob.metadata.audio_codec || "N/A"} ({focusedJob.metadata.audio_channels || 0}ch)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[80px] flex items-center justify-center border border-dashed border-helios-line/20 rounded-lg text-xs text-helios-slate italic">
|
||||
{focusedJob.job.status === "encoding"
|
||||
? "Encoding in progress..."
|
||||
: focusedJob.job.status === "remuxing"
|
||||
? "Remuxing in progress..."
|
||||
: "No encode data available"}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-helios-solar flex items-center gap-2">
|
||||
<Zap size={12} /> Output Details
|
||||
</h3>
|
||||
{focusedJob.encode_stats ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Result Size</span>
|
||||
<span className="text-helios-solar font-bold">{formatBytes(focusedJob.encode_stats.output_size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Reduction</span>
|
||||
<span className="text-green-500 font-bold">
|
||||
{((1 - focusedJob.encode_stats.compression_ratio) * 100).toFixed(1)}% Saved
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">VMAF Score</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-16 bg-helios-line/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-helios-solar" style={{ width: `${focusedJob.encode_stats.vmaf_score || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-helios-ink font-bold">
|
||||
{focusedJob.encode_stats.vmaf_score?.toFixed(1) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[80px] flex items-center justify-center border border-dashed border-helios-line/20 rounded-lg text-xs text-helios-slate italic">
|
||||
{focusedJob.job.status === "encoding"
|
||||
? "Encoding in progress..."
|
||||
: focusedJob.job.status === "remuxing"
|
||||
? "Remuxing in progress..."
|
||||
: "No encode data available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{completedEncodeStats && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-helios-solar flex items-center gap-2">
|
||||
<Zap size={12} /> Encode Results
|
||||
</h3>
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Input size</span>
|
||||
<span className="text-helios-ink font-bold">{formatBytes(completedEncodeStats.input_size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Output size</span>
|
||||
<span className="text-helios-ink font-bold">{formatBytes(completedEncodeStats.output_size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Reduction</span>
|
||||
<span className="text-green-500 font-bold">
|
||||
{completedEncodeStats.input_size_bytes > 0
|
||||
? `${((1 - completedEncodeStats.output_size_bytes / completedEncodeStats.input_size_bytes) * 100).toFixed(1)}% saved`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Encode time</span>
|
||||
<span className="text-helios-ink font-bold">{formatDuration(completedEncodeStats.encode_time_seconds)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Speed</span>
|
||||
<span className="text-helios-ink font-bold">{`${completedEncodeStats.encode_speed.toFixed(2)}\u00d7 realtime`}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Avg bitrate</span>
|
||||
<span className="text-helios-ink font-bold">{`${completedEncodeStats.avg_bitrate_kbps} kbps`}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">VMAF</span>
|
||||
<span className="text-helios-ink font-bold">{completedEncodeStats.vmaf_score?.toFixed(1) ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-5">
|
||||
|
||||
Reference in New Issue
Block a user