4a7d1e6663
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
1778 lines
67 KiB
TypeScript
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>
|
|
);
|
|
}
|