Compare commits

..

3 Commits

Author SHA1 Message Date
PickleRick 5e941e6e6a Cleanup 2026-06-03 00:57:50 +02:00
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
PickleRick da898ef6f8 add an unlink buttton to unlink timestamps in the segments table
Add progress bar for uploading
2026-06-03 00:09:47 +02:00
5 changed files with 299 additions and 58 deletions
+12
View File
@@ -320,6 +320,18 @@ def replace_markers(video_id: str, markers: list[float], source: str) -> list[fl
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]:
now = _now()
with _DB_LOCK:
+20 -4
View File
@@ -236,6 +236,11 @@ def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
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]])
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
_get_video_or_404(video_id)
@@ -306,6 +311,11 @@ def split_video(
job = job_manager.create_job("split")
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:
project_dir = _project_dir(project["id"])
output_dir = project_dir / "outputs" / video_id
@@ -328,6 +338,7 @@ def split_video(
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
progress_cb=progress,
log_cb=lambda message: job_manager.log(job_id, message),
custom_segments=custom_segments,
)
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:
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] = []
for video in videos:
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})
continue
markers = sorted(db.list_markers(video["id"]))
if not markers:
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"})
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_")]
if not markers and not custom_segments:
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
continue
ready.append((video, markers))
ready.append((video, markers, custom_segments))
if not ready:
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
+29 -21
View File
@@ -348,35 +348,43 @@ def build_episodes(
ffmpeg_pass2_template: str | None = None,
progress_cb: ProgressCallback | None = None,
log_cb: LogCallback | None = None,
custom_segments: list[tuple[float, float]] | None = None,
) -> list[str]:
output_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
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] = []
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]] = []
for index, end in enumerate(safe_boundaries, start=1):
core_ranges.append((index, prev, end))
prev = end
min_segment = 0.001
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
outro_start = max(0.0, total_duration - outro_seconds)
+187 -30
View File
@@ -27,6 +27,7 @@ import {
TableHead,
TableRow,
TextField,
InputAdornment,
Tooltip,
Toolbar,
Typography,
@@ -36,8 +37,11 @@ import {
Brightness4,
Brightness7,
CallSplit,
ContentPaste,
ContentCopy,
Delete,
Link,
LinkOff,
MyLocation,
Settings,
SkipNext,
@@ -53,6 +57,7 @@ import {
getJob,
getVideoMetadata,
listMarkers,
listSegmentEdits,
listVideos,
replaceMarkers,
replaceSegmentEdits,
@@ -133,8 +138,22 @@ export default function App() {
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(() => {
@@ -340,16 +359,6 @@ export default function App() {
return Array.from(markMap.values()).sort((a, b) => a.value - b.value);
}, [markers, project, duration]);
type SegmentRow = {
kind: "intro" | "core" | "outro";
label: string;
start: number;
end: number;
startEditable: boolean;
endEditable: boolean;
coreIndex?: number;
};
const introBoundary = useMemo(() => {
if (!project) return 0;
const maxDuration = duration || project.intro_seconds;
@@ -394,7 +403,7 @@ export default function App() {
});
}, [duration, coreBoundaries, frameStep]);
const segmentRows = useMemo(() => {
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);
@@ -430,6 +439,8 @@ export default function App() {
return rows;
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
const segmentRows = isLinked ? derivedSegmentRows : customSegmentRows;
useEffect(() => {
if (!segmentRows.length) {
setSegmentDrafts([]);
@@ -520,7 +531,7 @@ export default function App() {
}, [segments, selectedFrameTime]);
const frameHighlight = useMemo(() => {
const tolerance = frameStep / 2;
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) {
@@ -602,9 +613,12 @@ export default function App() {
if (videoFiles.length === 0) return;
setError(null);
setIsUploading(true);
setUploadProgress(0);
try {
const uploadedItems = await uploadVideos(videoFiles);
const uploadedItems = await uploadVideos(videoFiles, (index, total, progress) => {
setUploadProgress((index + progress) / total);
});
const uploaded = uploadedItems[0];
await refreshVideos();
if (uploaded) {
@@ -616,6 +630,7 @@ export default function App() {
setOutputs([]);
setJob(null);
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
setIsLinked(true);
}
setVideoFiles([]);
setPreviewUrl("");
@@ -639,9 +654,29 @@ export default function App() {
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);
}
@@ -798,7 +833,18 @@ export default function App() {
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 boundaryValue = snapped;
@@ -840,10 +886,17 @@ export default function App() {
});
};
const handleAddMarker = () => {
if (!duration) return;
const handleAddMarker = async () => {
if (!video || !duration) return;
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) => {
@@ -854,6 +907,12 @@ export default function App() {
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);
@@ -1014,6 +1073,14 @@ export default function App() {
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>
@@ -1352,9 +1419,8 @@ export default function App() {
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
Add marker at playhead
</Button>
<Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
Save markers
</Button>
</Stack>
</Grid>
<Grid item xs={12}>
@@ -1549,17 +1615,108 @@ export default function App() {
</Button>
</Box>
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
<Box>
<LinearProgress variant="determinate" value={Math.round(job.progress * 100)} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{job.message || `Status: ${job.status}`}
<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.details && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
<Chip size="small" label={`Stage: ${job.details.stage ?? "working"}`} />
<Chip size="small" label={`Stage ETA: ${job.details.stage_eta ?? "calculating"}`} />
<Chip size="small" label={`Total ETA: ${job.details.total_eta ?? "calculating"}`} />
</Stack>
{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>
)}
+51 -3
View File
@@ -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[] = [];
for (const file of files) {
uploaded.push(await uploadVideo(file));
for (let i = 0; i < files.length; i++) {
const file = files[i];
const video = await uploadVideoWithProgress(file, (progress) => {
if (onProgress) onProgress(i, files.length, progress);
});
uploaded.push(video);
}
return uploaded;
}
export async function listSegmentEdits(videoId: string): Promise<{ segments: SegmentEdit[] }> {
return request(`/api/videos/${videoId}/segment-edits`);
}
export async function replaceSegmentEdits(
videoId: string,
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]