From 4a7d1e66637d8725f153e6dc3845d6cafae6379f Mon Sep 17 00:00:00 2001 From: PickleRick Date: Wed, 3 Jun 2026 00:54:41 +0200 Subject: [PATCH] 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 --- backend/app/db.py | 12 ++++ backend/app/main.py | 24 +++++-- backend/app/mkv.py | 50 +++++++------ check.py | 4 ++ check_core.py | 4 ++ check_fields.py | 4 ++ check_prog.py | 4 ++ check_regex.py | 4 ++ frontend/src/App.tsx | 168 +++++++++++++++++++++++++++++++++++-------- frontend/src/api.ts | 4 ++ patch_api.py | 9 +++ patch_app.py | 34 +++++++++ patch_app_export.py | 131 +++++++++++++++++++++++++++++++++ patch_app_fetch.py | 43 +++++++++++ patch_app_unlink.py | 22 ++++++ patch_db.py | 22 ++++++ patch_main.py | 15 ++++ patch_main_export.py | 146 +++++++++++++++++++++++++++++++++++++ patch_mkv.py | 102 ++++++++++++++++++++++++++ patch_paste.py | 114 +++++++++++++++++++++++++++++ read_mkv.py | 3 + 21 files changed, 863 insertions(+), 56 deletions(-) create mode 100644 check.py create mode 100644 check_core.py create mode 100644 check_fields.py create mode 100644 check_prog.py create mode 100644 check_regex.py create mode 100644 patch_api.py create mode 100644 patch_app.py create mode 100644 patch_app_export.py create mode 100644 patch_app_fetch.py create mode 100644 patch_app_unlink.py create mode 100644 patch_db.py create mode 100644 patch_main.py create mode 100644 patch_main_export.py create mode 100644 patch_mkv.py create mode 100644 patch_paste.py create mode 100644 read_mkv.py 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]))