Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e941e6e6a | |||
| 4a7d1e6663 | |||
| da898ef6f8 |
@@ -320,6 +320,18 @@ def replace_markers(video_id: str, markers: list[float], source: str) -> list[fl
|
|||||||
return sorted(markers)
|
return sorted(markers)
|
||||||
|
|
||||||
|
|
||||||
|
def list_segment_edits(video_id: str) -> list[dict]:
|
||||||
|
with _DB_LOCK:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM segment_edits WHERE video_id = ? ORDER BY segment_key",
|
||||||
|
(video_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [_row_to_dict(row) for row in rows]
|
||||||
|
|
||||||
def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]:
|
def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]:
|
||||||
now = _now()
|
now = _now()
|
||||||
with _DB_LOCK:
|
with _DB_LOCK:
|
||||||
|
|||||||
+20
-4
@@ -236,6 +236,11 @@ def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
|
|||||||
return {"markers": db.replace_markers(video_id, markers, source="manual")}
|
return {"markers": db.replace_markers(video_id, markers, source="manual")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
||||||
|
def list_segment_edits(video_id: str) -> dict:
|
||||||
|
_get_video_or_404(video_id)
|
||||||
|
return {"segments": db.list_segment_edits(video_id)}
|
||||||
|
|
||||||
@app.put("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
@app.put("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
||||||
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
|
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
|
||||||
_get_video_or_404(video_id)
|
_get_video_or_404(video_id)
|
||||||
@@ -306,6 +311,11 @@ def split_video(
|
|||||||
job = job_manager.create_job("split")
|
job = job_manager.create_job("split")
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
|
|
||||||
|
segment_edits = db.list_segment_edits(video_id)
|
||||||
|
custom_segments = None
|
||||||
|
if segment_edits:
|
||||||
|
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
||||||
|
|
||||||
def run_job() -> dict:
|
def run_job() -> dict:
|
||||||
project_dir = _project_dir(project["id"])
|
project_dir = _project_dir(project["id"])
|
||||||
output_dir = project_dir / "outputs" / video_id
|
output_dir = project_dir / "outputs" / video_id
|
||||||
@@ -328,6 +338,7 @@ def split_video(
|
|||||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||||
progress_cb=progress,
|
progress_cb=progress,
|
||||||
log_cb=lambda message: job_manager.log(job_id, message),
|
log_cb=lambda message: job_manager.log(job_id, message),
|
||||||
|
custom_segments=custom_segments,
|
||||||
)
|
)
|
||||||
return {"outputs": outputs, "output_dir": str(output_dir)}
|
return {"outputs": outputs, "output_dir": str(output_dir)}
|
||||||
|
|
||||||
@@ -346,7 +357,7 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None
|
|||||||
if not videos:
|
if not videos:
|
||||||
raise HTTPException(status_code=400, detail="No videos available to export")
|
raise HTTPException(status_code=400, detail="No videos available to export")
|
||||||
|
|
||||||
ready: list[tuple[dict, list[float]]] = []
|
ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
|
||||||
skipped: list[dict] = []
|
skipped: list[dict] = []
|
||||||
for video in videos:
|
for video in videos:
|
||||||
try:
|
try:
|
||||||
@@ -355,10 +366,15 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None
|
|||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
||||||
continue
|
continue
|
||||||
markers = sorted(db.list_markers(video["id"]))
|
markers = sorted(db.list_markers(video["id"]))
|
||||||
if not markers:
|
segment_edits = db.list_segment_edits(video["id"])
|
||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"})
|
custom_segments = None
|
||||||
|
if segment_edits:
|
||||||
|
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
||||||
|
|
||||||
|
if not markers and not custom_segments:
|
||||||
|
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
|
||||||
continue
|
continue
|
||||||
ready.append((video, markers))
|
ready.append((video, markers, custom_segments))
|
||||||
|
|
||||||
if not ready:
|
if not ready:
|
||||||
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
|
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
|
||||||
|
|||||||
+29
-21
@@ -348,35 +348,43 @@ def build_episodes(
|
|||||||
ffmpeg_pass2_template: str | None = None,
|
ffmpeg_pass2_template: str | None = None,
|
||||||
progress_cb: ProgressCallback | None = None,
|
progress_cb: ProgressCallback | None = None,
|
||||||
log_cb: LogCallback | None = None,
|
log_cb: LogCallback | None = None,
|
||||||
|
custom_segments: list[tuple[float, float]] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
min_segment = 0.001
|
min_segment = 0.001
|
||||||
|
|
||||||
core_end = max(intro_seconds, total_duration - outro_seconds)
|
|
||||||
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
|
||||||
boundaries.append(core_end)
|
|
||||||
|
|
||||||
outputs: list[str] = []
|
outputs: list[str] = []
|
||||||
prev = intro_seconds
|
|
||||||
safe_boundaries: list[float] = []
|
|
||||||
|
|
||||||
for end in boundaries:
|
|
||||||
if end - prev <= min_segment:
|
|
||||||
continue
|
|
||||||
safe_boundaries.append(end)
|
|
||||||
prev = end
|
|
||||||
|
|
||||||
if not safe_boundaries:
|
|
||||||
raise RuntimeError("No valid segments after filtering short ranges")
|
|
||||||
|
|
||||||
prev = intro_seconds
|
|
||||||
total_segments = len(safe_boundaries)
|
|
||||||
core_ranges: list[tuple[int, float, float]] = []
|
core_ranges: list[tuple[int, float, float]] = []
|
||||||
for index, end in enumerate(safe_boundaries, start=1):
|
min_segment = 0.001
|
||||||
core_ranges.append((index, prev, end))
|
|
||||||
prev = end
|
if custom_segments and len(custom_segments) > 0:
|
||||||
|
total_segments = len(custom_segments)
|
||||||
|
for index, (start, end) in enumerate(custom_segments, start=1):
|
||||||
|
core_ranges.append((index, start, end))
|
||||||
|
else:
|
||||||
|
core_end = max(intro_seconds, total_duration - outro_seconds)
|
||||||
|
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
||||||
|
boundaries.append(core_end)
|
||||||
|
|
||||||
|
prev = intro_seconds
|
||||||
|
safe_boundaries: list[float] = []
|
||||||
|
|
||||||
|
for end in boundaries:
|
||||||
|
if end - prev <= min_segment:
|
||||||
|
continue
|
||||||
|
safe_boundaries.append(end)
|
||||||
|
prev = end
|
||||||
|
|
||||||
|
if not safe_boundaries:
|
||||||
|
raise RuntimeError("No valid segments after filtering short ranges")
|
||||||
|
|
||||||
|
prev = intro_seconds
|
||||||
|
total_segments = len(safe_boundaries)
|
||||||
|
for index, end in enumerate(safe_boundaries, start=1):
|
||||||
|
core_ranges.append((index, prev, end))
|
||||||
|
prev = end
|
||||||
|
|
||||||
intro_work = intro_seconds if intro_seconds > min_segment else 0.0
|
intro_work = intro_seconds if intro_seconds > min_segment else 0.0
|
||||||
outro_start = max(0.0, total_duration - outro_seconds)
|
outro_start = max(0.0, total_duration - outro_seconds)
|
||||||
|
|||||||
+187
-30
@@ -27,6 +27,7 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TextField,
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -36,8 +37,11 @@ import {
|
|||||||
Brightness4,
|
Brightness4,
|
||||||
Brightness7,
|
Brightness7,
|
||||||
CallSplit,
|
CallSplit,
|
||||||
|
ContentPaste,
|
||||||
ContentCopy,
|
ContentCopy,
|
||||||
Delete,
|
Delete,
|
||||||
|
Link,
|
||||||
|
LinkOff,
|
||||||
MyLocation,
|
MyLocation,
|
||||||
Settings,
|
Settings,
|
||||||
SkipNext,
|
SkipNext,
|
||||||
@@ -53,6 +57,7 @@ import {
|
|||||||
getJob,
|
getJob,
|
||||||
getVideoMetadata,
|
getVideoMetadata,
|
||||||
listMarkers,
|
listMarkers,
|
||||||
|
listSegmentEdits,
|
||||||
listVideos,
|
listVideos,
|
||||||
replaceMarkers,
|
replaceMarkers,
|
||||||
replaceSegmentEdits,
|
replaceSegmentEdits,
|
||||||
@@ -133,8 +138,22 @@ export default function App() {
|
|||||||
const [job, setJob] = useState<Job | null>(null);
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
const [outputs, setOutputs] = useState<string[]>([]);
|
const [outputs, setOutputs] = useState<string[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,16 +359,6 @@ export default function App() {
|
|||||||
return Array.from(markMap.values()).sort((a, b) => a.value - b.value);
|
return Array.from(markMap.values()).sort((a, b) => a.value - b.value);
|
||||||
}, [markers, project, duration]);
|
}, [markers, project, duration]);
|
||||||
|
|
||||||
type SegmentRow = {
|
|
||||||
kind: "intro" | "core" | "outro";
|
|
||||||
label: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
startEditable: boolean;
|
|
||||||
endEditable: boolean;
|
|
||||||
coreIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const introBoundary = useMemo(() => {
|
const introBoundary = useMemo(() => {
|
||||||
if (!project) return 0;
|
if (!project) return 0;
|
||||||
const maxDuration = duration || project.intro_seconds;
|
const maxDuration = duration || project.intro_seconds;
|
||||||
@@ -394,7 +403,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [duration, coreBoundaries, frameStep]);
|
}, [duration, coreBoundaries, frameStep]);
|
||||||
|
|
||||||
const segmentRows = useMemo(() => {
|
const derivedSegmentRows = useMemo(() => {
|
||||||
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];
|
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];
|
||||||
const introEnd = Math.max(0, introBoundary - frameStep);
|
const introEnd = Math.max(0, introBoundary - frameStep);
|
||||||
const outroStart = clamp(alignedOutroBoundary, 0, duration);
|
const outroStart = clamp(alignedOutroBoundary, 0, duration);
|
||||||
@@ -430,6 +439,8 @@ export default function App() {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
|
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
|
||||||
|
|
||||||
|
const segmentRows = isLinked ? derivedSegmentRows : customSegmentRows;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!segmentRows.length) {
|
if (!segmentRows.length) {
|
||||||
setSegmentDrafts([]);
|
setSegmentDrafts([]);
|
||||||
@@ -520,7 +531,7 @@ export default function App() {
|
|||||||
}, [segments, selectedFrameTime]);
|
}, [segments, selectedFrameTime]);
|
||||||
|
|
||||||
const frameHighlight = useMemo(() => {
|
const frameHighlight = useMemo(() => {
|
||||||
const tolerance = frameStep / 2;
|
const tolerance = Math.max(frameStep / 2, 0.005);
|
||||||
for (let i = 0; i < segments.length; i += 1) {
|
for (let i = 0; i < segments.length; i += 1) {
|
||||||
const segment = segments[i];
|
const segment = segments[i];
|
||||||
if (Math.abs(selectedFrameTime - segment.start) <= tolerance) {
|
if (Math.abs(selectedFrameTime - segment.start) <= tolerance) {
|
||||||
@@ -602,9 +613,12 @@ export default function App() {
|
|||||||
if (videoFiles.length === 0) return;
|
if (videoFiles.length === 0) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadedItems = await uploadVideos(videoFiles);
|
const uploadedItems = await uploadVideos(videoFiles, (index, total, progress) => {
|
||||||
|
setUploadProgress((index + progress) / total);
|
||||||
|
});
|
||||||
const uploaded = uploadedItems[0];
|
const uploaded = uploadedItems[0];
|
||||||
await refreshVideos();
|
await refreshVideos();
|
||||||
if (uploaded) {
|
if (uploaded) {
|
||||||
@@ -616,6 +630,7 @@ export default function App() {
|
|||||||
setOutputs([]);
|
setOutputs([]);
|
||||||
setJob(null);
|
setJob(null);
|
||||||
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
|
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
|
||||||
|
setIsLinked(true);
|
||||||
}
|
}
|
||||||
setVideoFiles([]);
|
setVideoFiles([]);
|
||||||
setPreviewUrl("");
|
setPreviewUrl("");
|
||||||
@@ -639,9 +654,29 @@ export default function App() {
|
|||||||
setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
|
setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
|
||||||
setVideoFiles([]);
|
setVideoFiles([]);
|
||||||
setPreviewUrl("");
|
setPreviewUrl("");
|
||||||
|
setIsLinked(true);
|
||||||
try {
|
try {
|
||||||
const response = await listMarkers(item.id);
|
const response = await listMarkers(item.id);
|
||||||
setMarkers(normalizeMarkers(response.markers ?? []));
|
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) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -798,7 +833,18 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapped = snapToFrame(parsed);
|
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 boundaryIndex = -1;
|
||||||
let boundaryValue = snapped;
|
let boundaryValue = snapped;
|
||||||
|
|
||||||
@@ -840,10 +886,17 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMarker = () => {
|
const handleAddMarker = async () => {
|
||||||
if (!duration) return;
|
if (!video || !duration) return;
|
||||||
const snapped = snapToFrame(cursorTime);
|
const snapped = snapToFrame(cursorTime);
|
||||||
setMarkers((prev) => normalizeMarkers([...prev, snapped]));
|
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 handleGoToTime = (value: number) => {
|
||||||
@@ -854,6 +907,12 @@ export default function App() {
|
|||||||
|
|
||||||
const handleDeleteSegment = async (row: SegmentRow) => {
|
const handleDeleteSegment = async (row: SegmentRow) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!isLinked) {
|
||||||
|
setCustomSegmentRows((prev) => prev.filter((r) => r !== row));
|
||||||
|
setPendingSegmentEditPersist(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.kind === "intro") {
|
if (row.kind === "intro") {
|
||||||
await saveProject({ intro_seconds: 0 });
|
await saveProject({ intro_seconds: 0 });
|
||||||
setPendingSegmentEditPersist(true);
|
setPendingSegmentEditPersist(true);
|
||||||
@@ -1014,6 +1073,14 @@ export default function App() {
|
|||||||
Project settings
|
Project settings
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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>
|
<Box>
|
||||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
<Typography variant="subtitle2">Recent uploads</Typography>
|
<Typography variant="subtitle2">Recent uploads</Typography>
|
||||||
@@ -1352,9 +1419,8 @@ export default function App() {
|
|||||||
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
|
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
|
||||||
Add marker at playhead
|
Add marker at playhead
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
|
||||||
Save markers
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@@ -1549,17 +1615,108 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
|
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
|
||||||
<Box>
|
<Box sx={{ p: 2, bgcolor: "background.paper", borderRadius: 4, border: "1px solid", borderColor: "divider" }}>
|
||||||
<LinearProgress variant="determinate" value={Math.round(job.progress * 100)} />
|
<Typography variant="h6" gutterBottom>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
{job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
|
||||||
{job.message || `Status: ${job.status}`}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
{job.details && (
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
{job.kind === "split_all" && job.details?.label && (() => {
|
||||||
<Chip size="small" label={`Stage: ${job.details.stage ?? "working"}`} />
|
const match = /^(\d+)\/(\d+) (.+)$/.exec(job.details.label);
|
||||||
<Chip size="small" label={`Stage ETA: ${job.details.stage_eta ?? "calculating"}`} />
|
if (match) {
|
||||||
<Chip size="small" label={`Total ETA: ${job.details.total_eta ?? "calculating"}`} />
|
const index = Number(match[1]);
|
||||||
</Stack>
|
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+51
-3
@@ -116,14 +116,62 @@ export async function replaceMarkers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadVideos(files: File[]): Promise<Video[]> {
|
export async function uploadVideoWithProgress(file: File, onProgress?: (progress: number) => void): Promise<Video> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", `${apiBase}/api/videos/upload`);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable && onProgress) {
|
||||||
|
onProgress(event.loaded / event.total);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(xhr.responseText));
|
||||||
|
} catch {
|
||||||
|
reject(new ApiError(xhr.status, "Invalid response"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let message = `Request failed (${xhr.status})`;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (data?.detail) message = data.detail;
|
||||||
|
else if (data?.message) message = data.message;
|
||||||
|
} catch {}
|
||||||
|
reject(new ApiError(xhr.status, message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new ApiError(0, "Network error"));
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadVideos(
|
||||||
|
files: File[],
|
||||||
|
onProgress?: (index: number, total: number, fileProgress: number) => void
|
||||||
|
): Promise<Video[]> {
|
||||||
const uploaded: Video[] = [];
|
const uploaded: Video[] = [];
|
||||||
for (const file of files) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
uploaded.push(await uploadVideo(file));
|
const file = files[i];
|
||||||
|
const video = await uploadVideoWithProgress(file, (progress) => {
|
||||||
|
if (onProgress) onProgress(i, files.length, progress);
|
||||||
|
});
|
||||||
|
uploaded.push(video);
|
||||||
}
|
}
|
||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listSegmentEdits(videoId: string): Promise<{ segments: SegmentEdit[] }> {
|
||||||
|
return request(`/api/videos/${videoId}/segment-edits`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function replaceSegmentEdits(
|
export async function replaceSegmentEdits(
|
||||||
videoId: string,
|
videoId: string,
|
||||||
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]
|
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]
|
||||||
|
|||||||
Reference in New Issue
Block a user