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
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if "listSegmentEdits" in line:
|
||||||
|
print(line.strip())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
||||||
|
for i, line in enumerate(f):
|
||||||
|
if "core_ranges" in line:
|
||||||
|
print(f"{i}: {line.strip()}")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
for i, line in enumerate(f):
|
||||||
|
if "value={segmentDrafts" in line:
|
||||||
|
print(f"{i}: {line.strip()}")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
with open("backend/app/main.py", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if "set_progress" in line or "job_manager.log" in line:
|
||||||
|
print(line.strip())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if "const match = /^(" in line:
|
||||||
|
print(line.strip())
|
||||||
+137
-31
@@ -27,6 +27,7 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TextField,
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
Brightness4,
|
Brightness4,
|
||||||
Brightness7,
|
Brightness7,
|
||||||
CallSplit,
|
CallSplit,
|
||||||
|
ContentPaste,
|
||||||
ContentCopy,
|
ContentCopy,
|
||||||
Delete,
|
Delete,
|
||||||
Link,
|
Link,
|
||||||
@@ -55,6 +57,7 @@ import {
|
|||||||
getJob,
|
getJob,
|
||||||
getVideoMetadata,
|
getVideoMetadata,
|
||||||
listMarkers,
|
listMarkers,
|
||||||
|
listSegmentEdits,
|
||||||
listVideos,
|
listVideos,
|
||||||
replaceMarkers,
|
replaceMarkers,
|
||||||
replaceSegmentEdits,
|
replaceSegmentEdits,
|
||||||
@@ -528,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) {
|
||||||
@@ -655,6 +658,25 @@ export default function App() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -811,7 +833,7 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapped = snapToFrame(parsed);
|
const snapped = parsed;
|
||||||
|
|
||||||
if (!isLinked) {
|
if (!isLinked) {
|
||||||
setCustomSegmentRows((prev) => {
|
setCustomSegmentRows((prev) => {
|
||||||
@@ -864,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) => {
|
||||||
@@ -1390,22 +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>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={isLinked ? <LinkOff /> : <Link />}
|
|
||||||
onClick={() => {
|
|
||||||
if (isLinked) {
|
|
||||||
setCustomSegmentRows(derivedSegmentRows);
|
|
||||||
}
|
|
||||||
setIsLinked(!isLinked);
|
|
||||||
}}
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
{isLinked ? "Unlink timestamps" : "Link timestamps"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@@ -1600,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ export async function uploadVideos(
|
|||||||
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 }[]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
with open("frontend/src/api.ts", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
content = re.sub(r"return request\(.*?/segment-edits\);", "return request(`/api/videos/${videoId}/segment-edits`);", content)
|
||||||
|
|
||||||
|
with open("frontend/src/api.ts", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("api.ts fixed properly")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_add_marker = """ const handleAddMarker = () => {
|
||||||
|
if (!duration) return;
|
||||||
|
const snapped = snapToFrame(cursorTime);
|
||||||
|
setMarkers((prev) => normalizeMarkers([...prev, snapped]));
|
||||||
|
};"""
|
||||||
|
|
||||||
|
new_add_marker = """ 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);
|
||||||
|
}
|
||||||
|
};"""
|
||||||
|
|
||||||
|
content = content.replace(old_add_marker, new_add_marker)
|
||||||
|
|
||||||
|
# Remove "Save markers" button
|
||||||
|
old_button = """ <Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
||||||
|
Save markers
|
||||||
|
</Button>"""
|
||||||
|
|
||||||
|
content = content.replace(old_button, "")
|
||||||
|
|
||||||
|
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("App.tsx add marker autosave patched")
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_export_progress = """ {(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}`}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}"""
|
||||||
|
|
||||||
|
new_export_progress = """ {(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>
|
||||||
|
)}"""
|
||||||
|
|
||||||
|
content = content.replace(old_export_progress, new_export_progress)
|
||||||
|
|
||||||
|
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("App.tsx export UI patched")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Add listSegmentEdits to import
|
||||||
|
content = content.replace("listMarkers,", "listMarkers,\n listSegmentEdits,")
|
||||||
|
|
||||||
|
# Fetch segment_edits in handleSelectVideo
|
||||||
|
old_handle_select = """ try {
|
||||||
|
const response = await listMarkers(item.id);
|
||||||
|
setMarkers(normalizeMarkers(response.markers ?? []));
|
||||||
|
} catch (err) {"""
|
||||||
|
|
||||||
|
new_handle_select = """ 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) {"""
|
||||||
|
|
||||||
|
content = content.replace(old_handle_select, new_handle_select)
|
||||||
|
|
||||||
|
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("App.tsx fetch segment_edits patched")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_btn = """ <Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={isLinked ? <LinkOff /> : <Link />}
|
||||||
|
onClick={() => {
|
||||||
|
if (isLinked) {
|
||||||
|
setCustomSegmentRows(derivedSegmentRows);
|
||||||
|
}
|
||||||
|
setIsLinked(!isLinked);
|
||||||
|
}}
|
||||||
|
disabled={!duration}
|
||||||
|
>
|
||||||
|
{isLinked ? "Unlink timestamps" : "Link timestamps"}
|
||||||
|
</Button>"""
|
||||||
|
|
||||||
|
content = content.replace(old_btn, "")
|
||||||
|
|
||||||
|
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Removed global unlink button")
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
with open('backend/app/db.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
new_func = '''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'''
|
||||||
|
|
||||||
|
content = content.replace('def replace_segment_edits', new_func)
|
||||||
|
|
||||||
|
with open('backend/app/db.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('db.py patched')
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
with open('backend/app/main.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
new_func = '''@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"'''
|
||||||
|
|
||||||
|
content = content.replace('@app.put("/api/videos/{video_id}/segment-edits"', new_func)
|
||||||
|
|
||||||
|
with open('backend/app/main.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('main.py patched')
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
with open("backend/app/main.py", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_split_logic = """ def run_job() -> dict:
|
||||||
|
project_dir = _project_dir(project["id"])
|
||||||
|
output_dir = project_dir / "outputs" / video_id
|
||||||
|
temp_dir = JOBS_DIR / job_id
|
||||||
|
|
||||||
|
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
|
||||||
|
|
||||||
|
outputs = mkv.build_episodes(
|
||||||
|
video["file_path"],
|
||||||
|
video["duration_seconds"],
|
||||||
|
project["intro_seconds"],
|
||||||
|
project["outro_seconds"],
|
||||||
|
markers,
|
||||||
|
output_dir,
|
||||||
|
temp_dir,
|
||||||
|
project_dir,
|
||||||
|
output_prefix=output_prefix,
|
||||||
|
reencode=project["reencode_enabled"],
|
||||||
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
||||||
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||||
|
progress_cb=progress,
|
||||||
|
log_cb=lambda message: job_manager.log(job_id, message),
|
||||||
|
)"""
|
||||||
|
|
||||||
|
new_split_logic = """ 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
|
||||||
|
temp_dir = JOBS_DIR / job_id
|
||||||
|
|
||||||
|
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
|
||||||
|
|
||||||
|
outputs = mkv.build_episodes(
|
||||||
|
video["file_path"],
|
||||||
|
video["duration_seconds"],
|
||||||
|
project["intro_seconds"],
|
||||||
|
project["outro_seconds"],
|
||||||
|
markers,
|
||||||
|
output_dir,
|
||||||
|
temp_dir,
|
||||||
|
project_dir,
|
||||||
|
output_prefix=output_prefix,
|
||||||
|
reencode=project["reencode_enabled"],
|
||||||
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
||||||
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||||
|
progress_cb=progress,
|
||||||
|
log_cb=lambda message: job_manager.log(job_id, message),
|
||||||
|
custom_segments=custom_segments,
|
||||||
|
)"""
|
||||||
|
|
||||||
|
content = content.replace(old_split_logic, new_split_logic)
|
||||||
|
|
||||||
|
old_split_all_ready = """ ready: list[tuple[dict, list[float]]] = []
|
||||||
|
skipped: list[dict] = []
|
||||||
|
for video in videos:
|
||||||
|
try:
|
||||||
|
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
||||||
|
except HTTPException as exc:
|
||||||
|
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"})
|
||||||
|
continue
|
||||||
|
ready.append((video, markers))"""
|
||||||
|
|
||||||
|
new_split_all_ready = """ ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
|
||||||
|
skipped: list[dict] = []
|
||||||
|
for video in videos:
|
||||||
|
try:
|
||||||
|
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
||||||
|
except HTTPException as exc:
|
||||||
|
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
||||||
|
continue
|
||||||
|
markers = sorted(db.list_markers(video["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_")]
|
||||||
|
|
||||||
|
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, custom_segments))"""
|
||||||
|
|
||||||
|
content = content.replace(old_split_all_ready, new_split_all_ready)
|
||||||
|
|
||||||
|
old_split_all_loop = """ for video_index, (video, markers) in enumerate(ready, start=1):
|
||||||
|
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
|
||||||
|
output_dir = project_dir / "outputs" / video["id"]
|
||||||
|
temp_dir = JOBS_DIR / job_id / video["id"]
|
||||||
|
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
|
||||||
|
job_manager.log(job_id, f"Starting export for {video['filename']}")
|
||||||
|
outputs = mkv.build_episodes(
|
||||||
|
video["file_path"],
|
||||||
|
video["duration_seconds"],
|
||||||
|
project["intro_seconds"],
|
||||||
|
project["outro_seconds"],
|
||||||
|
markers,
|
||||||
|
output_dir,
|
||||||
|
temp_dir,
|
||||||
|
project_dir,
|
||||||
|
output_prefix=output_prefix,
|
||||||
|
reencode=project["reencode_enabled"],
|
||||||
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
||||||
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||||
|
progress_cb=progress,
|
||||||
|
log_cb=lambda message: job_manager.log(job_id, message),
|
||||||
|
)"""
|
||||||
|
|
||||||
|
new_split_all_loop = """ for video_index, (video, markers, custom_segments) in enumerate(ready, start=1):
|
||||||
|
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
|
||||||
|
output_dir = project_dir / "outputs" / video["id"]
|
||||||
|
temp_dir = JOBS_DIR / job_id / video["id"]
|
||||||
|
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
|
||||||
|
job_manager.log(job_id, f"Starting export for {video['filename']}")
|
||||||
|
outputs = mkv.build_episodes(
|
||||||
|
video["file_path"],
|
||||||
|
video["duration_seconds"],
|
||||||
|
project["intro_seconds"],
|
||||||
|
project["outro_seconds"],
|
||||||
|
markers,
|
||||||
|
output_dir,
|
||||||
|
temp_dir,
|
||||||
|
project_dir,
|
||||||
|
output_prefix=output_prefix,
|
||||||
|
reencode=project["reencode_enabled"],
|
||||||
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
||||||
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||||
|
progress_cb=progress,
|
||||||
|
log_cb=lambda message: job_manager.log(job_id, message),
|
||||||
|
custom_segments=custom_segments,
|
||||||
|
)"""
|
||||||
|
|
||||||
|
content = content.replace(old_split_all_loop, new_split_all_loop)
|
||||||
|
|
||||||
|
with open("backend/app/main.py", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("main.py export logic patched")
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
old_sig = """def build_episodes(
|
||||||
|
video_path: str,
|
||||||
|
total_duration: float,
|
||||||
|
intro_seconds: float,
|
||||||
|
outro_seconds: float,
|
||||||
|
cut_points: list[float],
|
||||||
|
output_dir: Path,
|
||||||
|
temp_dir: Path,
|
||||||
|
project_dir: Path,
|
||||||
|
output_prefix: str = "episode",
|
||||||
|
reencode: bool = False,
|
||||||
|
ffmpeg_pass1_template: str | None = None,
|
||||||
|
ffmpeg_pass2_template: str | None = None,
|
||||||
|
progress_cb: ProgressCallback | None = None,
|
||||||
|
log_cb: LogCallback | None = None,
|
||||||
|
) -> list[str]:"""
|
||||||
|
|
||||||
|
new_sig = """def build_episodes(
|
||||||
|
video_path: str,
|
||||||
|
total_duration: float,
|
||||||
|
intro_seconds: float,
|
||||||
|
outro_seconds: float,
|
||||||
|
cut_points: list[float],
|
||||||
|
output_dir: Path,
|
||||||
|
temp_dir: Path,
|
||||||
|
project_dir: Path,
|
||||||
|
output_prefix: str = "episode",
|
||||||
|
reencode: bool = False,
|
||||||
|
ffmpeg_pass1_template: str | None = None,
|
||||||
|
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]:"""
|
||||||
|
|
||||||
|
content = content.replace(old_sig, new_sig)
|
||||||
|
|
||||||
|
old_logic = """ 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"""
|
||||||
|
|
||||||
|
new_logic = """ outputs: list[str] = []
|
||||||
|
core_ranges: list[tuple[int, float, float]] = []
|
||||||
|
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"""
|
||||||
|
|
||||||
|
content = content.replace(old_logic, new_logic)
|
||||||
|
|
||||||
|
with open("backend/app/mkv.py", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("mkv.py patched")
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add ContentPaste to imports
|
||||||
|
content = content.replace("CallSplit,", "CallSplit,\n ContentPaste,")
|
||||||
|
content = content.replace("import { IconButton, Tooltip } from \"@mui/material\";", "import { IconButton, Tooltip, InputAdornment } from \"@mui/material\";")
|
||||||
|
if "InputAdornment" not in content:
|
||||||
|
content = content.replace("TextField,", "TextField,\n InputAdornment,")
|
||||||
|
|
||||||
|
old_start_input = """ <TextField
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={segmentDrafts[rowIndex]?.start ?? formatTime(row.start)}
|
||||||
|
onChange={(e) => handleSegmentDraftChange(rowIndex, "start", e.target.value)}
|
||||||
|
onBlur={() => commitSegmentDraft(rowIndex, "start")}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitSegmentDraft(rowIndex, "start");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!row.startEditable}
|
||||||
|
inputProps={{ style: { fontFamily: "monospace" } }}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
new_start_input = """ <TextField
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={segmentDrafts[rowIndex]?.start ?? formatTime(row.start)}
|
||||||
|
onChange={(e) => handleSegmentDraftChange(rowIndex, "start", e.target.value)}
|
||||||
|
onBlur={() => commitSegmentDraft(rowIndex, "start")}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitSegmentDraft(rowIndex, "start");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!row.startEditable}
|
||||||
|
inputProps={{ style: { fontFamily: "monospace" } }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: row.startEditable ? (
|
||||||
|
<InputAdornment position="end" sx={{ opacity: 0, transition: "opacity 0.2s", ".MuiInputBase-root:hover &": { opacity: 1 } }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onMouseDown={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
handleSegmentDraftChange(rowIndex, "start", text);
|
||||||
|
setTimeout(() => commitSegmentDraft(rowIndex, "start"), 50);
|
||||||
|
} catch (err) {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentPaste fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null
|
||||||
|
}}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(old_start_input, new_start_input)
|
||||||
|
|
||||||
|
old_end_input = """ <TextField
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={segmentDrafts[rowIndex]?.end ?? formatTime(row.end)}
|
||||||
|
onChange={(e) => handleSegmentDraftChange(rowIndex, "end", e.target.value)}
|
||||||
|
onBlur={() => commitSegmentDraft(rowIndex, "end")}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitSegmentDraft(rowIndex, "end");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!row.endEditable}
|
||||||
|
inputProps={{ style: { fontFamily: "monospace" } }}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
new_end_input = """ <TextField
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={segmentDrafts[rowIndex]?.end ?? formatTime(row.end)}
|
||||||
|
onChange={(e) => handleSegmentDraftChange(rowIndex, "end", e.target.value)}
|
||||||
|
onBlur={() => commitSegmentDraft(rowIndex, "end")}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitSegmentDraft(rowIndex, "end");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!row.endEditable}
|
||||||
|
inputProps={{ style: { fontFamily: "monospace" } }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: row.endEditable ? (
|
||||||
|
<InputAdornment position="end" sx={{ opacity: 0, transition: "opacity 0.2s", ".MuiInputBase-root:hover &": { opacity: 1 } }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onMouseDown={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
handleSegmentDraftChange(rowIndex, "end", text);
|
||||||
|
setTimeout(() => commitSegmentDraft(rowIndex, "end"), 50);
|
||||||
|
} catch (err) {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentPaste fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null
|
||||||
|
}}
|
||||||
|
/>"""
|
||||||
|
|
||||||
|
content = content.replace(old_end_input, new_end_input)
|
||||||
|
|
||||||
|
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print("App.tsx paste buttons patched")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
print("".join(lines[350:400]))
|
||||||
Reference in New Issue
Block a user