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() { - - + + @@ -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 = """ """ + +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]))