Files
Video-Cutter/frontend/src/App.tsx
T
PickleRick 4a7d1e6663 1. fix bulk edit and export Now I can only edit one video at the time overwise the timestamp get overwritten. Save timestamps for each file uploaded
2. in export bulk make clear wich files will get elaborated and whick is being elaborated also add multple progress bar one for bulk one for the actual video and one for actual process (use Material You expresssive and make them clear)
3. remove save marker button; autosave then save edit
4. add paste timestamp button on hover other timestamp input
5. make unlink button not global but add a link icon colored by couple to let understand which timestamps are linked and make the single link removed (link should work that if I edit a timestamp the next/previuos based on which are linked get automatically setted to the next/previous frame timestamp)
6. I'm unable to pit whichever timestamp I want in the timestamp table for example 00:07:23.109 (the one in the frame visualizer) get automatically changed to 00:07:23.110 making the colored border not working correctly sometimes fix both
2026-06-03 00:54:41 +02:00

1778 lines
67 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
AppBar,
Box,
Button,
Chip,
Container,
CssBaseline,
Drawer,
FormControlLabel,
Grid,
IconButton,
LinearProgress,
Paper,
Slider,
Stack,
Step,
StepContent,
StepLabel,
Stepper,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
InputAdornment,
Tooltip,
Toolbar,
Typography,
} from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import {
Brightness4,
Brightness7,
CallSplit,
ContentPaste,
ContentCopy,
Delete,
Link,
LinkOff,
MyLocation,
Settings,
SkipNext,
SkipPrevious,
UploadFile,
} from "@mui/icons-material";
import {
autoCutVideo,
createProject,
deleteVideo,
getCurrentProject,
getJob,
getVideoMetadata,
listMarkers,
listSegmentEdits,
listVideos,
replaceMarkers,
replaceSegmentEdits,
splitAllVideos,
splitVideo,
updateProject,
uploadVideos,
} from "./api";
import { Job, Project, Video } from "./types";
import { buildTheme, ColorMode } from "./theme";
const formatTime = (value: number) => {
const total = Math.max(0, value);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const padded = seconds.toFixed(3).padStart(6, "0");
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${padded}`;
};
const parseTimecode = (value: string) => {
const trimmed = value.trim();
const match = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(trimmed);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millisRaw = match[4] ?? "0";
const millis = Number(millisRaw.padEnd(3, "0"));
if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds) || Number.isNaN(millis)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const DEFAULT_PASS1_TEMPLATE =
"ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} -map_metadata -1 -map_chapters -1 -an -sn -b:v 2M -x265-params no-slow-firstpass=1:pass=1:open-gop=0:keyint=60:min-keyint=60:scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ -tune animation -c:v libx265 -f null {null}";
const DEFAULT_PASS2_TEMPLATE =
"ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} -map_metadata -1 -map_chapters -1 -map 0:v:0 -map 0:a? -disposition:v:0 default -b:v 2M -x265-params pass=2:open-gop=0:keyint=60:min-keyint=60:scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ -tune animation -b:a 128k -ac:a 2 -c:v libx265 -c:a libopus -c:s subrip {output}";
export default function App() {
const [mode, setMode] = useState<ColorMode>("dark");
const theme = useMemo(() => buildTheme(mode), [mode]);
const [activeStep, setActiveStep] = useState(0);
const [project, setProject] = useState<Project | null>(null);
const [projectDrawerOpen, setProjectDrawerOpen] = useState(false);
const [projectForm, setProjectForm] = useState({
name: "Season",
segments: 4,
intro: 90,
outro: 90,
reencodeEnabled: false,
ffmpegPass1Template: DEFAULT_PASS1_TEMPLATE,
ffmpegPass2Template: DEFAULT_PASS2_TEMPLATE,
});
const [video, setVideo] = useState<Video | null>(null);
const [videoFiles, setVideoFiles] = useState<File[]>([]);
const [videoList, setVideoList] = useState<Video[]>([]);
const [isLoadingVideos, setIsLoadingVideos] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
const [duration, setDuration] = useState(0);
const [cursorTime, setCursorTime] = useState(0);
const [selectedTime, setSelectedTime] = useState(0);
const [playheadText, setPlayheadText] = useState(formatTime(0));
const [frameRate, setFrameRate] = useState<number | null>(null);
const [markers, setMarkers] = useState<number[]>([]);
const [segmentDrafts, setSegmentDrafts] = useState<{ start: string; end: string }[]>([]);
const [pendingSegmentEditPersist, setPendingSegmentEditPersist] = useState(false);
const [autoDetectVideoId, setAutoDetectVideoId] = useState<string | null>(null);
const autoWindow = 15;
const [outputPrefix, setOutputPrefix] = useState("");
const [job, setJob] = useState<Job | null>(null);
const [outputs, setOutputs] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
type SegmentRow = {
kind: "intro" | "core" | "outro";
label: string;
start: number;
end: number;
startEditable: boolean;
endEditable: boolean;
coreIndex?: number;
};
const [isLinked, setIsLinked] = useState(true);
const [customSegmentRows, setCustomSegmentRows] = useState<SegmentRow[]>([]);
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
document.body.dataset.theme = mode;
}, [mode]);
useEffect(() => {
let active = true;
getCurrentProject()
.then((data) => {
if (!active) return;
setProject(data);
setProjectForm({
name: data.name,
segments: data.segments_count,
intro: data.intro_seconds,
outro: data.outro_seconds,
reencodeEnabled: data.reencode_enabled,
ffmpegPass1Template: data.ffmpeg_pass1_template ?? DEFAULT_PASS1_TEMPLATE,
ffmpegPass2Template: data.ffmpeg_pass2_template ?? DEFAULT_PASS2_TEMPLATE,
});
})
.catch((err) => {
if (!active) return;
if (err?.status !== 404) {
setError(err.message || "Failed to load project");
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (videoFiles.length !== 1) {
setPreviewUrl("");
return;
}
const url = URL.createObjectURL(videoFiles[0]);
setPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [videoFiles]);
useEffect(() => {
if (duration && cursorTime > duration) {
setCursorTime(duration);
}
}, [duration, cursorTime]);
useEffect(() => {
if (duration && selectedTime > duration) {
setSelectedTime(duration);
}
}, [duration, selectedTime]);
useEffect(() => {
setPlayheadText(formatTime(cursorTime));
}, [cursorTime]);
useEffect(() => {
if (!project) {
setVideoList([]);
}
}, [project]);
useEffect(() => {
if (!video) return;
setFrameRate(null);
getVideoMetadata(video.id)
.then((meta) => {
if (meta?.fps && Number.isFinite(meta.fps)) {
setFrameRate(Math.round(meta.fps * 1000) / 1000);
}
})
.catch(() => undefined);
}, [video]);
useEffect(() => {
if (!job || job.status === "completed" || job.status === "failed") {
return;
}
const interval = window.setInterval(async () => {
try {
const latest = await getJob(job.id);
setJob(latest);
if (latest.status === "completed") {
if (latest.kind === "autocut" && latest.result && Array.isArray(latest.result["markers"])) {
setMarkers(normalizeMarkers(latest.result["markers"] as number[]));
setPendingSegmentEditPersist(true);
}
if ((latest.kind === "split" || latest.kind === "split_all") && latest.result && Array.isArray(latest.result["outputs"])) {
setOutputs(latest.result["outputs"] as string[]);
}
}
} catch (err) {
setError((err as Error).message);
}
}, 1000);
return () => window.clearInterval(interval);
}, [job]);
const refreshVideos = async () => {
if (!project) return;
setIsLoadingVideos(true);
try {
const items = await listVideos();
setVideoList(items);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoadingVideos(false);
}
};
useEffect(() => {
if (!project) return;
refreshVideos();
}, [project]);
const effectiveFrameRate = frameRate ?? 24;
const frameStep = useMemo(() => 1 / Math.max(effectiveFrameRate, 1), [effectiveFrameRate]);
const snapToFrame = (value: number) => {
if (!Number.isFinite(effectiveFrameRate) || effectiveFrameRate <= 0) {
return value;
}
const snapped = Math.round(value * effectiveFrameRate) / effectiveFrameRate;
return duration ? clamp(snapped, 0, duration) : snapped;
};
const selectedFrameTime = snapToFrame(selectedTime);
const apiBase = import.meta.env.VITE_API_BASE ?? "";
const frameBase = useMemo(() => {
if (!video) return "";
const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase;
return `${base}/api/videos/${video.id}/frame`;
}, [apiBase, video]);
const frameUrl = useMemo(() => {
if (!frameBase) return "";
return `${frameBase}?ts=${selectedFrameTime.toFixed(3)}&fast=0&w=960`;
}, [frameBase, selectedFrameTime]);
const frameStrip = useMemo(() => {
if (!frameBase || !duration) return [];
const step = frameStep;
const frames = [] as { offset: number; ts: number; url: string }[];
for (let offset = -5; offset <= 5; offset += 1) {
if (offset === 0) continue;
const ts = clamp(selectedFrameTime + offset * step, 0, duration);
frames.push({
offset,
ts,
url: `${frameBase}?ts=${ts.toFixed(3)}&fast=1&w=240`,
});
}
return frames;
}, [frameBase, duration, frameStep, selectedFrameTime]);
const frameStripLeft = useMemo(
() => frameStrip.filter((frame) => frame.offset < 0).sort((a, b) => a.offset - b.offset),
[frameStrip]
);
const frameStripRight = useMemo(
() => frameStrip.filter((frame) => frame.offset > 0).sort((a, b) => a.offset - b.offset),
[frameStrip]
);
const normalizeMarkers = (values: number[]) => {
if (!duration) return values;
const rounded = values.map((value) => Math.round(clamp(value, 0, duration) * 1000) / 1000);
return Array.from(new Set(rounded)).sort((a, b) => a - b);
};
const playheadMarks = useMemo(() => {
const markMap = new Map<number, { value: number; label?: string }>();
markers.forEach((marker) => {
markMap.set(marker, { value: marker });
});
if (project?.intro_seconds && duration) {
const introValue = clamp(project.intro_seconds, 0, duration);
if (introValue > 0) {
markMap.set(introValue, { value: introValue, label: "Intro" });
}
}
if (project?.outro_seconds && duration) {
const outroValue = clamp(duration - project.outro_seconds, 0, duration);
if (outroValue > 0 && outroValue < duration) {
markMap.set(outroValue, { value: outroValue, label: "Outro" });
}
}
return Array.from(markMap.values()).sort((a, b) => a.value - b.value);
}, [markers, project, duration]);
const introBoundary = useMemo(() => {
if (!project) return 0;
const maxDuration = duration || project.intro_seconds;
return clamp(project.intro_seconds, 0, maxDuration);
}, [project, duration]);
const outroBoundary = useMemo(() => {
if (!project) return duration || 0;
if (!duration) return 0;
return clamp(duration - project.outro_seconds, 0, duration);
}, [project, duration]);
const lastFrameTime = useMemo(() => {
if (!duration) return 0;
if (!Number.isFinite(effectiveFrameRate) || effectiveFrameRate <= 0) return duration;
const last = Math.floor(duration * effectiveFrameRate) / effectiveFrameRate;
return clamp(last, 0, duration);
}, [duration, effectiveFrameRate]);
const alignedOutroBoundary = useMemo(() => {
if (!duration) return 0;
return Math.min(outroBoundary, lastFrameTime || duration);
}, [duration, outroBoundary, lastFrameTime]);
const coreBoundaries = useMemo(() => {
if (!duration) return [] as number[];
const filtered = markers.filter((marker) => marker > introBoundary && marker < alignedOutroBoundary);
const sorted = normalizeMarkers(filtered);
return [introBoundary, ...sorted, alignedOutroBoundary];
}, [duration, markers, introBoundary, alignedOutroBoundary]);
const segments = useMemo(() => {
if (!duration || coreBoundaries.length < 2) return [] as { index: number; start: number; end: number }[];
return coreBoundaries.slice(0, -1).map((start, idx) => {
const endBoundary = coreBoundaries[idx + 1];
const end = Math.max(start, endBoundary - frameStep);
return {
index: idx + 1,
start,
end,
};
});
}, [duration, coreBoundaries, frameStep]);
const derivedSegmentRows = useMemo(() => {
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];
const introEnd = Math.max(0, introBoundary - frameStep);
const outroStart = clamp(alignedOutroBoundary, 0, duration);
const rows: SegmentRow[] = [
{
kind: "intro",
label: "Intro",
start: 0,
end: introEnd,
startEditable: false,
endEditable: true,
},
];
segments.forEach((segment, index) => {
rows.push({
kind: "core",
label: `Segment ${segment.index}`,
start: segment.start,
end: segment.end,
startEditable: true,
endEditable: true,
coreIndex: index,
});
});
rows.push({
kind: "outro",
label: "Outro/Credits",
start: outroStart,
end: lastFrameTime,
startEditable: true,
endEditable: false,
});
return rows;
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
const segmentRows = isLinked ? derivedSegmentRows : customSegmentRows;
useEffect(() => {
if (!segmentRows.length) {
setSegmentDrafts([]);
return;
}
setSegmentDrafts(
segmentRows.map((row) => ({
start: formatTime(row.start),
end: formatTime(row.end),
}))
);
}, [segmentRows]);
const segmentKeyForRow = (row: SegmentRow) => {
if (row.kind === "core") {
return `segment_${String((row.coreIndex ?? 0) + 1).padStart(2, "0")}`;
}
return row.kind;
};
useEffect(() => {
if (!pendingSegmentEditPersist || !video || segmentRows.length === 0) return;
let cancelled = false;
const persist = async () => {
try {
await replaceSegmentEdits(
video.id,
segmentRows.map((row) => ({
segment_key: segmentKeyForRow(row),
start_seconds: row.start,
end_seconds: row.end,
}))
);
} catch (err) {
if (!cancelled) {
setError((err as Error).message);
}
} finally {
if (!cancelled) {
setPendingSegmentEditPersist(false);
}
}
};
void persist();
return () => {
cancelled = true;
};
}, [pendingSegmentEditPersist, video, segmentRows]);
const segmentColors = [
"info.main",
"warning.main",
"success.main",
"secondary.main",
"error.main",
"primary.main",
];
const segmentEndColors = [
"info.dark",
"warning.dark",
"success.dark",
"secondary.dark",
"error.dark",
"primary.dark",
];
const segmentColorForIndex = (index?: number) => {
if (index === undefined || index < 0) return "text.disabled";
return segmentColors[index % segmentColors.length] ?? "primary.main";
};
const segmentColorForTime = (timestamp: number) => {
const matchIndex = segments.findIndex(
(segment) => timestamp >= segment.start && timestamp <= segment.end
);
if (matchIndex < 0) return null;
return segmentColorForIndex(matchIndex);
};
const activeSegmentColor = useMemo(() => {
const matchIndex = segments.findIndex(
(segment) => selectedFrameTime >= segment.start && selectedFrameTime <= segment.end
);
if (matchIndex < 0) return null;
return segmentColorForIndex(matchIndex);
}, [segments, selectedFrameTime]);
const frameHighlight = useMemo(() => {
const tolerance = Math.max(frameStep / 2, 0.005);
for (let i = 0; i < segments.length; i += 1) {
const segment = segments[i];
if (Math.abs(selectedFrameTime - segment.start) <= tolerance) {
return { color: segmentColorForIndex(i), label: `Start S${segment.index}` };
}
if (Math.abs(selectedFrameTime - segment.end) <= tolerance) {
return { color: segmentEndColors[i % segmentEndColors.length] ?? "secondary.main", label: `End S${segment.index}` };
}
}
return null;
}, [segments, selectedFrameTime, frameStep, segmentEndColors]);
const outputPreview = useMemo(() => {
const count = markers.length ? markers.length + 1 : project?.segments_count ?? 0;
if (!count) return [];
const prefix = outputPrefix.trim() || "episode";
return Array.from({ length: count }, (_, index) =>
`${prefix}_${String(index + 1).padStart(2, "0")}.mkv`
);
}, [markers.length, project, outputPrefix]);
const isBusy = isUploading || (!!job && (job.status === "running" || job.status === "queued"));
const saveProject = async (
overrides: Partial<{
name: string;
segments_count: number;
intro_seconds: number;
outro_seconds: number;
reencode_enabled: boolean;
ffmpeg_pass1_template: string | null;
ffmpeg_pass2_template: string | null;
}> = {}
) => {
setError(null);
try {
const payload = {
name: projectForm.name.trim() || "Season",
segments_count: Number(projectForm.segments),
intro_seconds: Number(projectForm.intro),
outro_seconds: Number(projectForm.outro),
reencode_enabled: projectForm.reencodeEnabled,
ffmpeg_pass1_template: projectForm.ffmpegPass1Template.trim() || null,
ffmpeg_pass2_template: projectForm.ffmpegPass2Template.trim() || null,
...overrides,
};
const saved = project ? await updateProject(payload) : await createProject(payload);
setProject(saved);
setProjectForm({
name: saved.name,
segments: saved.segments_count,
intro: saved.intro_seconds,
outro: saved.outro_seconds,
reencodeEnabled: saved.reencode_enabled,
ffmpegPass1Template: saved.ffmpeg_pass1_template ?? DEFAULT_PASS1_TEMPLATE,
ffmpegPass2Template: saved.ffmpeg_pass2_template ?? DEFAULT_PASS2_TEMPLATE,
});
return saved;
} catch (err) {
setError((err as Error).message);
return null;
}
};
const handleProjectSave = async () => {
const saved = await saveProject();
if (saved) {
setProjectDrawerOpen(false);
}
};
const handleUpload = async () => {
if (!project) {
setError("Configure project settings before uploading.");
setProjectDrawerOpen(true);
return;
}
if (videoFiles.length === 0) return;
setError(null);
setIsUploading(true);
setUploadProgress(0);
try {
const uploadedItems = await uploadVideos(videoFiles, (index, total, progress) => {
setUploadProgress((index + progress) / total);
});
const uploaded = uploadedItems[0];
await refreshVideos();
if (uploaded) {
setVideo(uploaded);
setDuration(uploaded.duration_seconds);
setCursorTime(0);
setSelectedTime(0);
setMarkers([]);
setOutputs([]);
setJob(null);
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
setIsLinked(true);
}
setVideoFiles([]);
setPreviewUrl("");
setActiveStep(1);
} catch (err) {
setError((err as Error).message);
} finally {
setIsUploading(false);
}
};
const handleSelectVideo = async (item: Video) => {
setError(null);
setVideo(item);
setDuration(item.duration_seconds);
setCursorTime(0);
setSelectedTime(0);
setMarkers([]);
setOutputs([]);
setJob(null);
setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
setVideoFiles([]);
setPreviewUrl("");
setIsLinked(true);
try {
const response = await listMarkers(item.id);
setMarkers(normalizeMarkers(response.markers ?? []));
const editsResponse = await listSegmentEdits(item.id);
if (editsResponse.segments && editsResponse.segments.length > 0) {
setIsLinked(false);
setCustomSegmentRows(
editsResponse.segments.map((se) => ({
kind: se.segment_key.startsWith("segment_") ? "core" : (se.segment_key as any),
label: se.segment_key,
start: se.start_seconds,
end: se.end_seconds,
startEditable: true,
endEditable: true,
coreIndex: se.segment_key.startsWith("segment_") ? parseInt(se.segment_key.split("_")[1]) - 1 : undefined,
}))
);
} else {
setIsLinked(true);
setCustomSegmentRows([]);
}
} catch (err) {
setError((err as Error).message);
}
setActiveStep(1);
};
const handleDeleteVideo = async (videoId: string) => {
setError(null);
try {
await deleteVideo(videoId);
await refreshVideos();
if (video?.id === videoId) {
setVideo(null);
setDuration(0);
setCursorTime(0);
setSelectedTime(0);
setFrameRate(null);
setMarkers([]);
setOutputs([]);
setJob(null);
}
} catch (err) {
setError((err as Error).message);
}
};
const handleAutoCut = async () => {
if (!video) return;
setError(null);
setAutoDetectVideoId(video.id);
try {
const created = await autoCutVideo(video.id, autoWindow);
setJob(created);
} catch (err) {
setError((err as Error).message);
}
};
useEffect(() => {
if (activeStep !== 1 || !video || markers.length > 0 || autoDetectVideoId === video.id || isBusy) {
return;
}
void handleAutoCut();
}, [activeStep, video, markers.length, autoDetectVideoId, isBusy]);
const handleCopySelectedFrameTime = async () => {
const timestamp = formatTime(selectedFrameTime);
try {
await navigator.clipboard.writeText(timestamp);
setError(null);
} catch {
setError("Could not copy timestamp to clipboard.");
}
};
const handleSplit = async () => {
if (!video || markers.length === 0) return;
setError(null);
try {
const created = await splitVideo(video.id, markers, outputPrefix.trim() || undefined);
setJob(created);
setActiveStep(2);
} catch (err) {
setError((err as Error).message);
}
};
const handleSplitAll = async () => {
if (videoList.length === 0) return;
setError(null);
try {
const created = await splitAllVideos(videoList.map((item) => item.id));
setOutputs([]);
setJob(created);
setActiveStep(2);
} catch (err) {
setError((err as Error).message);
}
};
const handleSaveMarkers = async () => {
if (!video) return;
setError(null);
try {
const updated = await replaceMarkers(video.id, markers);
setMarkers(normalizeMarkers(updated.markers));
setPendingSegmentEditPersist(true);
} catch (err) {
setError((err as Error).message);
}
};
const commitPlayheadText = () => {
const parsed = parseTimecode(playheadText);
if (parsed === null) {
setError("Invalid timecode. Use hh:mm:ss.sss");
setPlayheadText(formatTime(cursorTime));
return;
}
const next = clamp(parsed, 0, duration || parsed);
const snapped = snapToFrame(next);
setCursorTime(snapped);
setSelectedTime(snapped);
};
const applyBoundaryUpdate = async (boundaryIndex: number, rawBoundary: number) => {
if (!duration || coreBoundaries.length < 2) return;
const minBoundary = boundaryIndex > 0 ? coreBoundaries[boundaryIndex - 1] + frameStep : 0;
const maxBoundary = boundaryIndex < coreBoundaries.length - 1
? coreBoundaries[boundaryIndex + 1] - frameStep
: lastFrameTime || duration;
const clamped = clamp(rawBoundary, minBoundary, maxBoundary);
const nextBoundaries = [...coreBoundaries];
nextBoundaries[boundaryIndex] = clamped;
const nextMarkers = normalizeMarkers(nextBoundaries.slice(1, -1));
setMarkers(nextMarkers);
setPendingSegmentEditPersist(true);
if (video) {
await replaceMarkers(video.id, nextMarkers);
}
if (!project) return;
if (boundaryIndex === 0) {
await saveProject({ intro_seconds: clamped });
} else if (boundaryIndex === coreBoundaries.length - 1) {
const nextOutroSeconds = clamp(duration - clamped, 0, duration);
await saveProject({ outro_seconds: nextOutroSeconds });
}
};
const handleSegmentDraftChange = (rowIndex: number, field: "start" | "end", value: string) => {
setSegmentDrafts((prev) =>
prev.map((entry, idx) => (idx === rowIndex ? { ...entry, [field]: value } : entry))
);
};
const commitSegmentDraft = async (rowIndex: number, field: "start" | "end") => {
const row = segmentRows[rowIndex];
if (!row) return;
if (field === "start" && !row.startEditable) return;
if (field === "end" && !row.endEditable) return;
const draft = segmentDrafts[rowIndex]?.[field] ?? formatTime(field === "start" ? row.start : row.end);
const parsed = parseTimecode(draft);
if (parsed === null) {
setError("Invalid timecode. Use hh:mm:ss.sss");
setSegmentDrafts((prev) =>
prev.map((entry, idx) =>
idx === rowIndex
? { ...entry, [field]: formatTime(field === "start" ? row.start : row.end) }
: entry
)
);
return;
}
const snapped = parsed;
if (!isLinked) {
setCustomSegmentRows((prev) => {
const next = [...prev];
next[rowIndex] = { ...next[rowIndex], [field]: snapped };
return next;
});
setPendingSegmentEditPersist(true);
return;
}
let boundaryIndex = -1;
let boundaryValue = snapped;
if (row.kind === "intro") {
if (field !== "end") return;
boundaryIndex = 0;
boundaryValue = snapped + frameStep;
} else if (row.kind === "outro") {
if (field !== "start") return;
boundaryIndex = coreBoundaries.length - 1;
boundaryValue = snapped;
} else {
const coreIndex = row.coreIndex ?? 0;
if (field === "start") {
boundaryIndex = coreIndex;
boundaryValue = snapped;
} else {
boundaryIndex = coreIndex + 1;
boundaryValue = snapped + frameStep;
}
}
if (boundaryIndex < 0) return;
await applyBoundaryUpdate(boundaryIndex, boundaryValue);
};
const handleCursorCommit = () => {
const snapped = snapToFrame(cursorTime);
setCursorTime(snapped);
setSelectedTime(snapped);
};
const handleFrameStep = (direction: number) => {
if (!duration) return;
setCursorTime((prev) => {
const next = clamp(prev + direction * frameStep, 0, duration);
setSelectedTime(next);
return next;
});
};
const handleAddMarker = async () => {
if (!video || !duration) return;
const snapped = snapToFrame(cursorTime);
const nextMarkers = normalizeMarkers([...markers, snapped]);
setMarkers(nextMarkers);
try {
await replaceMarkers(video.id, nextMarkers);
setPendingSegmentEditPersist(true);
} catch (err) {
setError((err as Error).message);
}
};
const handleGoToTime = (value: number) => {
const snapped = snapToFrame(value);
setCursorTime(snapped);
setSelectedTime(snapped);
};
const handleDeleteSegment = async (row: SegmentRow) => {
setError(null);
if (!isLinked) {
setCustomSegmentRows((prev) => prev.filter((r) => r !== row));
setPendingSegmentEditPersist(true);
return;
}
if (row.kind === "intro") {
await saveProject({ intro_seconds: 0 });
setPendingSegmentEditPersist(true);
return;
}
if (row.kind === "outro") {
await saveProject({ outro_seconds: 0 });
setPendingSegmentEditPersist(true);
return;
}
if (row.coreIndex === undefined || coreBoundaries.length <= 2) return;
const boundaryToRemove =
row.coreIndex < segments.length - 1 ? row.coreIndex + 1 : row.coreIndex;
const nextMarkers = coreBoundaries
.filter((_, index) => index !== boundaryToRemove)
.slice(1, -1);
const normalizedMarkers = normalizeMarkers(nextMarkers);
setMarkers(normalizedMarkers);
if (video) {
await replaceMarkers(video.id, normalizedMarkers);
}
setPendingSegmentEditPersist(true);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppBar position="static" color="inherit" elevation={1}>
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 600 }}>
Video Cutter Studio
</Typography>
{project && <Chip label={`Project: ${project.name}`} color="primary" sx={{ mr: 2 }} />}
{!project && <Chip label="No project" color="warning" sx={{ mr: 2 }} />}
<IconButton onClick={() => setProjectDrawerOpen(true)} aria-label="Project settings">
<Settings />
</IconButton>
<IconButton
onClick={() => setMode(mode === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
{mode === "dark" ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Toolbar>
</AppBar>
<Drawer anchor="right" open={projectDrawerOpen} onClose={() => setProjectDrawerOpen(false)}>
<Box sx={{ width: { xs: 340, sm: 560 }, p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Project settings
</Typography>
<Stack spacing={2}>
<TextField
fullWidth
label="Project name"
value={projectForm.name}
onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))}
/>
<TextField
fullWidth
type="number"
label="Episodes (core)"
value={projectForm.segments}
onChange={(event) =>
setProjectForm((prev) => ({ ...prev, segments: Number(event.target.value) }))
}
/>
<FormControlLabel
control={
<Switch
checked={projectForm.reencodeEnabled}
onChange={(event) =>
setProjectForm((prev) => ({ ...prev, reencodeEnabled: event.target.checked }))
}
/>
}
label="Reencode for frame-accurate cuts"
/>
<TextField
fullWidth
multiline
minRows={4}
label="FFmpeg pass 1 template"
value={projectForm.ffmpegPass1Template}
onChange={(event) =>
setProjectForm((prev) => ({ ...prev, ffmpegPass1Template: event.target.value }))
}
disabled={!projectForm.reencodeEnabled}
/>
<TextField
fullWidth
multiline
minRows={5}
label="FFmpeg pass 2 template"
value={projectForm.ffmpegPass2Template}
onChange={(event) =>
setProjectForm((prev) => ({ ...prev, ffmpegPass2Template: event.target.value }))
}
disabled={!projectForm.reencodeEnabled}
/>
<Button variant="contained" onClick={handleProjectSave} disabled={isBusy}>
{project ? "Save" : "Create project"}
</Button>
<Typography variant="caption" color="text.secondary">
Intro/outro timing is edited in the timeline table and saved for the next upload.
</Typography>
</Stack>
</Box>
</Drawer>
<Container maxWidth={false} sx={{ mt: 4, mb: 8, px: { xs: 2, md: 4 } }}>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Paper elevation={3} sx={{ p: { xs: 2, sm: 4 }, width: "100%" }}>
<Stepper activeStep={activeStep} orientation="vertical">
<Step>
<StepLabel>Upload Video</StepLabel>
<StepContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Button
variant="outlined"
component="label"
startIcon={<UploadFile />}
disabled={isBusy}
sx={{ width: "fit-content" }}
>
Select MKV files
<input
type="file"
accept=".mkv"
multiple
hidden
onChange={(event) => setVideoFiles(Array.from(event.target.files ?? []))}
/>
</Button>
{videoFiles.length > 0 && (
<Typography variant="body2">
{videoFiles.length === 1
? videoFiles[0].name
: `${videoFiles.length} files selected`}
</Typography>
)}
<Box>
<Button
variant="contained"
onClick={handleUpload}
disabled={videoFiles.length === 0 || isBusy || !project}
>
{isUploading ? "Uploading..." : "Upload to Backend"}
</Button>
<Button sx={{ ml: 1 }} onClick={() => setProjectDrawerOpen(true)}>
Project settings
</Button>
</Box>
{isUploading && (
<Box sx={{ mt: 1 }}>
<LinearProgress variant="determinate" value={uploadProgress * 100} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Uploading... {Math.round(uploadProgress * 100)}%
</Typography>
</Box>
)}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle2">Recent uploads</Typography>
<Button size="small" onClick={refreshVideos} disabled={isLoadingVideos || !project}>
Refresh
</Button>
</Stack>
{videoList.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No uploads yet
</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Filename</TableCell>
<TableCell>Duration</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{videoList.map((item) => (
<TableRow key={item.id} selected={video?.id === item.id}>
<TableCell>{item.filename}</TableCell>
<TableCell>{formatTime(item.duration_seconds)}</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button size="small" variant="outlined" onClick={() => handleSelectVideo(item)}>
Resume
</Button>
<Button
size="small"
color="error"
variant="outlined"
onClick={() => handleDeleteVideo(item.id)}
>
Delete
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{previewUrl && (
<Box
sx={{
mt: 2,
bgcolor: "background.paper",
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
overflow: "hidden",
}}
>
<video
ref={videoRef}
controls
src={previewUrl}
style={{ width: "100%", display: "block" }}
onLoadedMetadata={(event) => {
const current = event.currentTarget.duration;
if (Number.isFinite(current)) {
setDuration(current);
}
}}
/>
</Box>
)}
</Stack>
</StepContent>
</Step>
<Step>
<StepLabel>Timeline & Auto Cut</StepLabel>
<StepContent>
<Grid
container
spacing={3}
sx={{
mt: 0,
"--thumb-h": "64px",
"--thumb-gap": "8px",
}}
>
<Grid item xs={12} md={3}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Frames before
</Typography>
{frameStripLeft.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Load a video to see frames
</Typography>
) : (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridAutoRows: "var(--thumb-h)",
gap: "var(--thumb-gap)",
height: "calc(5 * var(--thumb-h) + 4 * var(--thumb-gap))",
}}
>
{frameStripLeft.map((frame) => {
const label = frame.offset > 0 ? `+${frame.offset}` : `${frame.offset}`;
const segmentColor = segmentColorForTime(frame.ts);
return (
<Box
key={`left-${frame.offset}-${frame.ts}`}
onClick={() => handleGoToTime(frame.ts)}
sx={{
borderRadius: 1,
overflow: "hidden",
border: "2px solid",
borderColor: segmentColor ?? "divider",
cursor: "pointer",
position: "relative",
backgroundColor: "background.default",
}}
>
<Box
component="img"
src={frame.url}
alt={`Frame ${frame.offset}`}
sx={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
<Box
sx={{
position: "absolute",
top: 4,
left: 4,
px: 0.5,
py: 0,
borderRadius: 0.5,
fontSize: 11,
backgroundColor: "rgba(0,0,0,0.6)",
color: "#fff",
}}
>
{label}
</Box>
</Box>
);
})}
</Box>
)}
</Grid>
<Grid item xs={12} md={6}>
<Box
sx={{
bgcolor: "background.paper",
borderRadius: 1,
border: "3px solid",
borderColor: frameHighlight?.color ?? activeSegmentColor ?? "divider",
height: "calc(5 * var(--thumb-h) + 4 * var(--thumb-gap))",
minHeight: "calc(5 * var(--thumb-h) + 4 * var(--thumb-gap))",
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: 2,
overflow: "hidden",
position: "relative",
}}
>
{frameUrl ? (
<Box
component="img"
src={frameUrl}
alt="Frame"
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : (
<Typography color="text.secondary">Load a video to preview frames</Typography>
)}
{frameUrl && (
<Box
sx={{
position: "absolute",
bottom: 12,
left: 12,
px: 1.25,
py: 0.5,
borderRadius: 1,
backgroundColor: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 12,
fontWeight: 600,
}}
>
{`Frame ${formatTime(selectedFrameTime)}`}
</Box>
)}
{frameHighlight && (
<Box
sx={{
position: "absolute",
top: 12,
left: 12,
px: 1.25,
py: 0.5,
borderRadius: 1,
backgroundColor: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 12,
fontWeight: 600,
}}
>
{frameHighlight.label}
</Box>
)}
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Frames after
</Typography>
{frameStripRight.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Load a video to see frames
</Typography>
) : (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridAutoRows: "var(--thumb-h)",
gap: "var(--thumb-gap)",
height: "calc(5 * var(--thumb-h) + 4 * var(--thumb-gap))",
}}
>
{frameStripRight.map((frame) => {
const label = frame.offset > 0 ? `+${frame.offset}` : `${frame.offset}`;
const segmentColor = segmentColorForTime(frame.ts);
return (
<Box
key={`right-${frame.offset}-${frame.ts}`}
onClick={() => handleGoToTime(frame.ts)}
sx={{
borderRadius: 1,
overflow: "hidden",
border: "2px solid",
borderColor: segmentColor ?? "divider",
cursor: "pointer",
position: "relative",
backgroundColor: "background.default",
}}
>
<Box
component="img"
src={frame.url}
alt={`Frame ${frame.offset}`}
sx={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
<Box
sx={{
position: "absolute",
top: 4,
left: 4,
px: 0.5,
py: 0,
borderRadius: 0.5,
fontSize: 11,
backgroundColor: "rgba(0,0,0,0.6)",
color: "#fff",
}}
>
{label}
</Box>
</Box>
);
})}
</Box>
)}
</Grid>
<Grid item xs={12}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
<Stack direction="row" spacing={1} alignItems="center">
<IconButton onClick={() => handleFrameStep(-1)} disabled={!duration}>
<SkipPrevious />
</IconButton>
<IconButton onClick={() => handleFrameStep(1)} disabled={!duration}>
<SkipNext />
</IconButton>
</Stack>
<TextField
size="small"
label="Playhead (hh:mm:ss.sss)"
placeholder="00:00:00.000"
value={playheadText}
onChange={(event) => setPlayheadText(event.target.value)}
onBlur={commitPlayheadText}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitPlayheadText();
}
}}
sx={{ width: 190 }}
/>
<Tooltip title="Copy central frame timestamp">
<span>
<IconButton
onClick={handleCopySelectedFrameTime}
disabled={!duration}
aria-label="Copy central frame timestamp"
>
<ContentCopy />
</IconButton>
</span>
</Tooltip>
<Chip
variant="outlined"
label={`FFmpeg FPS: ${frameRate === null ? "unknown" : frameRate.toFixed(3)}`}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Typography gutterBottom>Playhead: {formatTime(cursorTime)}</Typography>
<Slider
value={cursorTime}
min={0}
max={duration || 1}
step={frameStep}
onChange={(_, value) => setCursorTime(value as number)}
onChangeCommitted={handleCursorCommit}
marks={playheadMarks}
disabled={!duration}
/>
</Grid>
<Grid item xs={12}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
Add marker at playhead
</Button>
</Stack>
</Grid>
<Grid item xs={12}>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Segment</TableCell>
<TableCell align="center">Color</TableCell>
<TableCell>Start</TableCell>
<TableCell>End</TableCell>
<TableCell align="right">Delete</TableCell>
</TableRow>
</TableHead>
<TableBody>
{segmentRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary">
Load a video to see segments
</Typography>
</TableCell>
</TableRow>
) : (
segmentRows.map((row, rowIndex) => (
<TableRow key={`${row.kind}-${row.label}-${rowIndex}`}>
<TableCell>{row.label}</TableCell>
<TableCell align="center">
<Box
sx={{
width: 12,
height: 12,
borderRadius: "50%",
bgcolor: row.kind === "core" ? segmentColorForIndex(row.coreIndex) : "text.disabled",
mx: "auto",
}}
/>
</TableCell>
<TableCell sx={{ minWidth: 260 }}>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
fullWidth
value={segmentDrafts[rowIndex]?.start ?? formatTime(row.start)}
onChange={(event) =>
handleSegmentDraftChange(rowIndex, "start", event.target.value)
}
onBlur={() => commitSegmentDraft(rowIndex, "start")}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitSegmentDraft(rowIndex, "start");
}
}}
disabled={!row.startEditable}
/>
<Tooltip title="Go to start time">
<span>
<IconButton
size="small"
onClick={() => handleGoToTime(row.start)}
disabled={!duration}
aria-label={`Go to ${row.label} start`}
>
<MyLocation fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
<TableCell sx={{ minWidth: 260 }}>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
fullWidth
value={segmentDrafts[rowIndex]?.end ?? formatTime(row.end)}
onChange={(event) => handleSegmentDraftChange(rowIndex, "end", event.target.value)}
onBlur={() => commitSegmentDraft(rowIndex, "end")}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitSegmentDraft(rowIndex, "end");
}
}}
disabled={!row.endEditable}
/>
<Tooltip title="Go to end time">
<span>
<IconButton
size="small"
onClick={() => handleGoToTime(row.end)}
disabled={!duration}
aria-label={`Go to ${row.label} end`}
>
<MyLocation fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
<TableCell align="right">
<Tooltip title={`Delete ${row.label}`}>
<span>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteSegment(row)}
disabled={!duration || (row.kind === "core" && segments.length <= 1)}
aria-label={`Delete ${row.label}`}
>
<Delete fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Grid>
{job?.kind === "autocut" && job.status !== "completed" && (
<Grid item xs={12}>
<Box>
<LinearProgress variant="determinate" value={Math.round(job.progress * 100)} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{job.message || `Status: ${job.status}`}
</Typography>
</Box>
</Grid>
)}
</Grid>
<Box sx={{ mt: 4 }}>
<Button variant="contained" onClick={() => setActiveStep(2)} disabled={markers.length === 0}>
Proceed to Export
</Button>
<Button sx={{ ml: 1 }} onClick={() => setActiveStep(0)}>
Back
</Button>
</Box>
</StepContent>
</Step>
<Step>
<StepLabel>Export Episodes</StepLabel>
<StepContent>
<Typography color="text.secondary" gutterBottom>
Review markers and export your final files.
</Typography>
<Stack spacing={2} sx={{ mt: 2 }}>
<TextField
fullWidth
label="Output prefix"
value={outputPrefix}
onChange={(event) => setOutputPrefix(event.target.value)}
/>
{outputPreview.length > 0 && (
<Box>
<Typography variant="subtitle2">Filename preview</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{outputPreview.map((name) => (
<Typography key={name} variant="body2">
{name}
</Typography>
))}
</Stack>
</Box>
)}
<Box>
<Button
variant="contained"
color="success"
startIcon={<CallSplit />}
onClick={handleSplit}
disabled={isBusy || markers.length === 0}
>
Split Video
</Button>
<Button
sx={{ ml: 1 }}
variant="outlined"
color="success"
startIcon={<CallSplit />}
onClick={handleSplitAll}
disabled={isBusy || videoList.length === 0}
>
Export All
</Button>
<Button sx={{ ml: 1 }} onClick={() => setActiveStep(1)}>
Back
</Button>
</Box>
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
<Box sx={{ p: 2, bgcolor: "background.paper", borderRadius: 4, border: "1px solid", borderColor: "divider" }}>
<Typography variant="h6" gutterBottom>
{job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
</Typography>
{job.kind === "split_all" && job.details?.label && (() => {
const match = /^(\d+)\/(\d+) (.+)$/.exec(job.details.label);
if (match) {
const index = Number(match[1]);
const total = Number(match[2]);
const filename = match[3];
const videoProgress = Math.max(0, Math.min(100, (job.progress - (index - 1) / total) * total * 100));
return (
<Box sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Overall Bulk Progress ({index}/{total} videos)
</Typography>
<LinearProgress
variant="determinate"
value={Math.round(job.progress * 100)}
sx={{ height: 12, borderRadius: 6, mb: 2, bgcolor: "primary.light" }}
/>
<Typography variant="body2" color="text.primary" sx={{ fontWeight: "bold" }}>
Current Video: {filename}
</Typography>
<LinearProgress
variant="determinate"
value={Math.round(videoProgress)}
color="secondary"
sx={{ height: 8, borderRadius: 4, mb: 2 }}
/>
<Typography variant="body2" color="text.secondary">
Current Stage: {job.details.stage || "working"}
</Typography>
<LinearProgress
variant="determinate"
value={job.details.stage_percent || 0}
color="info"
sx={{ height: 4, borderRadius: 2 }}
/>
</Box>
);
}
return null;
})()}
{job.kind === "split" && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.primary" gutterBottom>
Video Progress
</Typography>
<LinearProgress
variant="determinate"
value={Math.round(job.progress * 100)}
color="secondary"
sx={{ height: 8, borderRadius: 4, mb: 2 }}
/>
<Typography variant="body2" color="text.secondary">
Current Stage: {job.details?.stage || "working"}
</Typography>
<LinearProgress
variant="determinate"
value={job.details?.stage_percent || 0}
color="info"
sx={{ height: 4, borderRadius: 2 }}
/>
</Box>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 2 }}>
{job.details?.stage_eta && <Chip size="small" label={`Stage ETA: ${job.details.stage_eta}`} />}
{job.details?.total_eta && <Chip size="small" label={`Total ETA: ${job.details.total_eta}`} />}
<Chip size="small" color={job.status === "running" ? "primary" : "default"} label={`Status: ${job.status}`} />
</Stack>
{job.kind === "split_all" && (
<Box sx={{ mt: 3, p: 2, bgcolor: "background.default", borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Queue Queue
</Typography>
<Stack spacing={0.5}>
{videoList.map((v, i) => {
const match = job.details?.label ? /^(\d+)\/(\d+) (.+)$/.exec(job.details.label) : null;
const currentIndex = match ? Number(match[1]) - 1 : -1;
const status = i < currentIndex ? "completed" : i === currentIndex ? "running" : "pending";
return (
<Typography
key={v.id}
variant="body2"
color={status === "completed" ? "success.main" : status === "running" ? "primary.main" : "text.disabled"}
sx={{ fontWeight: status === "running" ? "bold" : "normal" }}
>
{status === "completed" ? "✓ " : status === "running" ? "▶ " : "• "}
{v.filename}
</Typography>
);
})}
</Stack>
</Box>
)}
</Box>
)}
{job?.logs && job.logs.length > 0 && (
<Box>
<Typography variant="subtitle2">Backend log</Typography>
<Box
sx={{
mt: 1,
p: 1,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
maxHeight: 160,
overflow: "auto",
fontFamily: "monospace",
fontSize: 12,
}}
>
{job.logs.slice(-12).map((entry) => (
<Box key={`${entry.at}-${entry.message}`}>{`${entry.at} ${entry.message}`}</Box>
))}
</Box>
</Box>
)}
{outputs.length > 0 && (
<Box>
<Typography variant="subtitle2">Outputs</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{outputs.map((output) => (
<Typography key={output} variant="body2">
{output}
</Typography>
))}
</Stack>
</Box>
)}
{job?.result && Array.isArray(job.result["skipped"]) && (job.result["skipped"] as unknown[]).length > 0 && (
<Box>
<Typography variant="subtitle2">Skipped videos</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{(job.result["skipped"] as { filename?: string; reason?: string }[]).map((item, index) => (
<Typography key={`${item.filename ?? "video"}-${index}`} variant="body2" color="text.secondary">
{`${item.filename ?? "Video"}: ${item.reason ?? "Not ready"}`}
</Typography>
))}
</Stack>
</Box>
)}
</Stack>
</StepContent>
</Step>
</Stepper>
</Paper>
</Container>
</ThemeProvider>
);
}