diff --git a/backend/app/db.py b/backend/app/db.py
index d3279c4..07db53a 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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:
diff --git a/backend/app/main.py b/backend/app/main.py
index f099673..377af69 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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")
diff --git a/backend/app/mkv.py b/backend/app/mkv.py
index 59a3e74..a5ed05f 100644
--- a/backend/app/mkv.py
+++ b/backend/app/mkv.py
@@ -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)
diff --git a/check.py b/check.py
new file mode 100644
index 0000000..b01266f
--- /dev/null
+++ b/check.py
@@ -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())
diff --git a/check_core.py b/check_core.py
new file mode 100644
index 0000000..bf91411
--- /dev/null
+++ b/check_core.py
@@ -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()}")
diff --git a/check_fields.py b/check_fields.py
new file mode 100644
index 0000000..7b9fc72
--- /dev/null
+++ b/check_fields.py
@@ -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()}")
diff --git a/check_prog.py b/check_prog.py
new file mode 100644
index 0000000..0a6050d
--- /dev/null
+++ b/check_prog.py
@@ -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())
diff --git a/check_regex.py b/check_regex.py
new file mode 100644
index 0000000..c378901
--- /dev/null
+++ b/check_regex.py
@@ -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())
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0b68d36..a069d32 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -27,6 +27,7 @@ import {
TableHead,
TableRow,
TextField,
+ InputAdornment,
Tooltip,
Toolbar,
Typography,
@@ -36,6 +37,7 @@ import {
Brightness4,
Brightness7,
CallSplit,
+ ContentPaste,
ContentCopy,
Delete,
Link,
@@ -55,6 +57,7 @@ import {
getJob,
getVideoMetadata,
listMarkers,
+ listSegmentEdits,
listVideos,
replaceMarkers,
replaceSegmentEdits,
@@ -528,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) {
@@ -655,6 +658,25 @@ export default function App() {
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);
}
@@ -811,7 +833,7 @@ export default function App() {
return;
}
- const snapped = snapToFrame(parsed);
+ const snapped = parsed;
if (!isLinked) {
setCustomSegmentRows((prev) => {
@@ -864,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) => {
@@ -1390,22 +1419,8 @@ export default function App() {
-
- : }
- onClick={() => {
- if (isLinked) {
- setCustomSegmentRows(derivedSegmentRows);
- }
- setIsLinked(!isLinked);
- }}
- disabled={!duration}
- >
- {isLinked ? "Unlink timestamps" : "Link timestamps"}
-
+
+
@@ -1600,17 +1615,108 @@ export default function App() {
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
-
-
-
- {job.message || `Status: ${job.status}`}
+
+
+ {job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
- {job.details && (
-
-
-
-
-
+
+ {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 (
+
+
+ Overall Bulk Progress ({index}/{total} videos)
+
+
+
+
+ Current Video: {filename}
+
+
+
+
+ Current Stage: {job.details.stage || "working"}
+
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {job.kind === "split" && (
+
+
+ Video Progress
+
+
+
+ Current Stage: {job.details?.stage || "working"}
+
+
+
+ )}
+
+
+ {job.details?.stage_eta && }
+ {job.details?.total_eta && }
+
+
+
+ {job.kind === "split_all" && (
+
+
+ Queue Queue
+
+
+ {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 (
+
+ {status === "completed" ? "✓ " : status === "running" ? "▶ " : "• "}
+ {v.filename}
+
+ );
+ })}
+
+
)}
)}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 6a0b43c..d686510 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -168,6 +168,10 @@ export async function uploadVideos(
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 }[]
diff --git a/patch_api.py b/patch_api.py
new file mode 100644
index 0000000..3f51f73
--- /dev/null
+++ b/patch_api.py
@@ -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")
diff --git a/patch_app.py b/patch_app.py
new file mode 100644
index 0000000..e6904c4
--- /dev/null
+++ b/patch_app.py
@@ -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 = """ """
+
+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")
diff --git a/patch_app_export.py b/patch_app_export.py
new file mode 100644
index 0000000..f3040dd
--- /dev/null
+++ b/patch_app_export.py
@@ -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" && (
+
+
+
+ {job.message || `Status: ${job.status}`}
+
+ {job.details && (
+
+
+
+
+
+ )}
+
+ )}"""
+
+new_export_progress = """ {(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
+
+
+ {job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
+
+
+ {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 (
+
+
+ Overall Bulk Progress ({index}/{total} videos)
+
+
+
+
+ Current Video: {filename}
+
+
+
+
+ Current Stage: {job.details.stage || "working"}
+
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {job.kind === "split" && (
+
+
+ Video Progress
+
+
+
+ Current Stage: {job.details?.stage || "working"}
+
+
+
+ )}
+
+
+ {job.details?.stage_eta && }
+ {job.details?.total_eta && }
+
+
+
+ {job.kind === "split_all" && (
+
+
+ Queue Queue
+
+
+ {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 (
+
+ {status === "completed" ? "✓ " : status === "running" ? "▶ " : "• "}
+ {v.filename}
+
+ );
+ })}
+
+
+ )}
+
+ )}"""
+
+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")
diff --git a/patch_app_fetch.py b/patch_app_fetch.py
new file mode 100644
index 0000000..7373687
--- /dev/null
+++ b/patch_app_fetch.py
@@ -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")
diff --git a/patch_app_unlink.py b/patch_app_unlink.py
new file mode 100644
index 0000000..384f25f
--- /dev/null
+++ b/patch_app_unlink.py
@@ -0,0 +1,22 @@
+with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
+ content = f.read()
+
+old_btn = """ : }
+ onClick={() => {
+ if (isLinked) {
+ setCustomSegmentRows(derivedSegmentRows);
+ }
+ setIsLinked(!isLinked);
+ }}
+ disabled={!duration}
+ >
+ {isLinked ? "Unlink timestamps" : "Link timestamps"}
+ """
+
+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")
diff --git a/patch_db.py b/patch_db.py
new file mode 100644
index 0000000..b68acd5
--- /dev/null
+++ b/patch_db.py
@@ -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')
diff --git a/patch_main.py b/patch_main.py
new file mode 100644
index 0000000..efd7724
--- /dev/null
+++ b/patch_main.py
@@ -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')
diff --git a/patch_main_export.py b/patch_main_export.py
new file mode 100644
index 0000000..2dc60a7
--- /dev/null
+++ b/patch_main_export.py
@@ -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")
diff --git a/patch_mkv.py b/patch_mkv.py
new file mode 100644
index 0000000..e706421
--- /dev/null
+++ b/patch_mkv.py
@@ -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")
diff --git a/patch_paste.py b/patch_paste.py
new file mode 100644
index 0000000..a5c07bc
--- /dev/null
+++ b/patch_paste.py
@@ -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 = """ 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 = """ 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 ? (
+
+ {
+ e.preventDefault();
+ try {
+ const text = await navigator.clipboard.readText();
+ handleSegmentDraftChange(rowIndex, "start", text);
+ setTimeout(() => commitSegmentDraft(rowIndex, "start"), 50);
+ } catch (err) {}
+ }}
+ >
+
+
+
+ ) : null
+ }}
+ />"""
+
+content = content.replace(old_start_input, new_start_input)
+
+old_end_input = """ 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 = """ 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 ? (
+
+ {
+ e.preventDefault();
+ try {
+ const text = await navigator.clipboard.readText();
+ handleSegmentDraftChange(rowIndex, "end", text);
+ setTimeout(() => commitSegmentDraft(rowIndex, "end"), 50);
+ } catch (err) {}
+ }}
+ >
+
+
+
+ ) : 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")
diff --git a/read_mkv.py b/read_mkv.py
new file mode 100644
index 0000000..8e8ff09
--- /dev/null
+++ b/read_mkv.py
@@ -0,0 +1,3 @@
+with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
+ lines = f.readlines()
+ print("".join(lines[350:400]))