Files
Video-Cutter/backend/app/main.py
T
PickleRick 4a7d1e6663 1. fix bulk edit and export Now I can only edit one video at the time overwise the timestamp get overwritten. Save timestamps for each file uploaded
2. in export bulk make clear wich files will get elaborated and whick is being elaborated also add multple progress bar one for bulk one for the actual video and one for actual process (use Material You expresssive and make them clear)
3. remove save marker button; autosave then save edit
4. add paste timestamp button on hover other timestamp input
5. make unlink button not global but add a link icon colored by couple to let understand which timestamps are linked and make the single link removed (link should work that if I edit a timestamp the next/previuos based on which are linked get automatically setted to the next/previous frame timestamp)
6. I'm unable to pit whichever timestamp I want in the timestamp table for example 00:07:23.109 (the one in the frame visualizer) get automatically changed to 00:07:23.110 making the colored border not working correctly sometimes fix both
2026-06-03 00:54:41 +02:00

459 lines
16 KiB
Python

import io
import json
import logging
import time
import shutil
import uuid
from pathlib import Path
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from . import db, jobs, media, mkv, schemas
from .settings import APP_NAME, JOBS_DIR, PROJECTS_DIR, UPLOADS_DIR, BLACKDETECT_WINDOW
app = FastAPI(title=APP_NAME)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
job_manager = jobs.JobManager()
def _ensure_dirs() -> None:
for directory in (UPLOADS_DIR, JOBS_DIR, PROJECTS_DIR):
directory.mkdir(parents=True, exist_ok=True)
def _require_project() -> dict:
project = db.get_current_project()
if not project:
raise HTTPException(status_code=400, detail="No current project. Create one first.")
return project
def _get_video_or_404(video_id: str) -> dict:
video = db.get_video(video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")
return video
def _sanitize_prefix(value: str) -> str:
cleaned = "".join(ch for ch in value if ch.isalnum() or ch in ("-", "_"))
return cleaned or "episode"
def _validate_durations(total_duration: float, intro_seconds: float, outro_seconds: float) -> None:
if intro_seconds + outro_seconds >= total_duration:
raise HTTPException(
status_code=400,
detail="Intro/outro durations exceed or match video length",
)
def _project_dir(project_id: str) -> Path:
path = PROJECTS_DIR / project_id
path.mkdir(parents=True, exist_ok=True)
return path
def _save_project_file(project: dict) -> None:
project_dir = _project_dir(project["id"])
payload = {
"id": project["id"],
"name": project["name"],
"segments_count": project["segments_count"],
"intro_seconds": project["intro_seconds"],
"outro_seconds": project["outro_seconds"],
"reencode_enabled": project["reencode_enabled"],
"ffmpeg_pass1_template": project["ffmpeg_pass1_template"],
"ffmpeg_pass2_template": project["ffmpeg_pass2_template"],
}
(project_dir / "project.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _format_eta(seconds: float | None) -> str | None:
if seconds is None or seconds < 0:
return None
seconds = int(round(seconds))
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def _make_progress_updater(job_id: str, job_label: str, offset: float = 0.0, span: float = 1.0):
started_at = time.monotonic()
stage_started_at = started_at
current_stage = None
def progress(done: float, total: float, message: str | None = None, details: dict | None = None) -> None:
nonlocal stage_started_at, current_stage
now = time.monotonic()
total_fraction = max(0.0, min(1.0, done / float(total))) if total else 0.0
stage = (details or {}).get("stage") or message or job_label
stage_progress = max(0.0, min(1.0, float((details or {}).get("stage_progress", total_fraction))))
if stage != current_stage:
current_stage = stage
stage_started_at = now
elapsed = max(0.001, now - started_at)
stage_elapsed = max(0.001, now - stage_started_at)
total_eta = elapsed * (1.0 - total_fraction) / total_fraction if total_fraction > 0.001 else None
stage_eta = stage_elapsed * (1.0 - stage_progress) / stage_progress if stage_progress > 0.001 else None
payload = {
**(details or {}),
"label": job_label,
"stage": stage,
"stage_progress": stage_progress,
"stage_percent": int(round(stage_progress * 100)),
"total_progress": total_fraction,
"total_percent": int(round(total_fraction * 100)),
"stage_eta_seconds": stage_eta,
"total_eta_seconds": total_eta,
"stage_eta": _format_eta(stage_eta),
"total_eta": _format_eta(total_eta),
"elapsed_seconds": elapsed,
}
full_progress = offset + span * total_fraction
label = message or f"{stage}: {payload['stage_percent']}%"
eta_text = f"ETA {payload['stage_eta']}" if payload["stage_eta"] else "ETA calculating"
job_manager.set_progress(
job_id,
full_progress,
message=f"{label} - {eta_text} - total {int(round(full_progress * 100))}%",
details=payload,
)
return progress
@app.on_event("startup")
def on_startup() -> None:
db.init_db()
_ensure_dirs()
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/api/projects", response_model=schemas.ProjectOut)
def create_project(payload: schemas.ProjectCreate) -> dict:
project = db.create_project(
payload.name,
payload.segments_count,
payload.intro_seconds,
payload.outro_seconds,
reencode_enabled=payload.reencode_enabled,
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
)
_save_project_file(project)
return project
@app.get("/api/projects/current", response_model=schemas.ProjectOut)
def get_current_project() -> dict:
project = db.get_current_project()
if not project:
raise HTTPException(status_code=404, detail="No current project")
return project
@app.put("/api/projects/current", response_model=schemas.ProjectOut)
def update_current_project(payload: schemas.ProjectUpdate) -> dict:
project = db.update_current_project(
name=payload.name,
segments_count=payload.segments_count,
intro_seconds=payload.intro_seconds,
outro_seconds=payload.outro_seconds,
reencode_enabled=payload.reencode_enabled,
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
)
if not project:
raise HTTPException(status_code=404, detail="No current project")
_save_project_file(project)
return project
@app.post("/api/videos/upload", response_model=schemas.VideoOut)
def upload_video(file: UploadFile = File(...)) -> dict:
project = _require_project()
if not file.filename or not file.filename.lower().endswith(".mkv"):
raise HTTPException(status_code=400, detail="Only .mkv files are supported")
video_id = uuid.uuid4().hex
safe_name = file.filename.replace(" ", "_")
dest_path = UPLOADS_DIR / f"{video_id}_{safe_name}"
with dest_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
duration = media.probe_duration(str(dest_path))
return db.create_video(project["id"], file.filename, str(dest_path), duration)
@app.get("/api/videos/{video_id}", response_model=schemas.VideoOut)
def get_video(video_id: str) -> dict:
return _get_video_or_404(video_id)
@app.get("/api/videos", response_model=list[schemas.VideoOut])
def list_videos() -> list[dict]:
project = _require_project()
return db.list_videos(project["id"])
@app.get("/api/videos/{video_id}/metadata")
def get_video_metadata(video_id: str) -> dict:
video = _get_video_or_404(video_id)
fps = media.probe_framerate(video["file_path"])
return {"fps": fps}
@app.get("/api/videos/{video_id}/markers")
def list_markers(video_id: str) -> dict:
_get_video_or_404(video_id)
return {"markers": db.list_markers(video_id)}
@app.put("/api/videos/{video_id}/markers")
def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
_get_video_or_404(video_id)
markers = sorted(payload.markers)
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)
segments = [
{
"segment_key": item.segment_key,
"start_seconds": item.start_seconds,
"end_seconds": item.end_seconds,
}
for item in payload.segments
]
return {"segments": db.replace_segment_edits(video_id, segments)}
@app.post("/api/videos/{video_id}/autocut", response_model=schemas.JobOut)
def autocut_video(
video_id: str,
payload: schemas.AutoCutRequest | None = Body(default=None),
) -> dict:
video = _get_video_or_404(video_id)
project = _require_project()
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
job = job_manager.create_job("autocut")
job_id = job["id"]
def run_job() -> dict:
window = payload.window_seconds if payload and payload.window_seconds else BLACKDETECT_WINDOW
def progress(done: int, total: int) -> None:
job_manager.set_progress(job_id, done / float(total), message=f"Analisi {done}/{total}")
markers = media.autodetect_cuts(
video["file_path"],
video["duration_seconds"],
project["intro_seconds"],
project["outro_seconds"],
project["segments_count"],
window_seconds=window,
progress_cb=progress,
)
db.replace_markers(video_id, markers, source="auto")
return {"markers": markers}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.post("/api/videos/{video_id}/split", response_model=schemas.JobOut)
def split_video(
video_id: str,
payload: schemas.SplitRequest | None = Body(default=None),
) -> dict:
video = _get_video_or_404(video_id)
project = _require_project()
_save_project_file(project)
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
markers = payload.markers if payload and payload.markers else db.list_markers(video_id)
markers = sorted(markers)
if not markers:
raise HTTPException(status_code=400, detail="No markers provided")
output_prefix = _sanitize_prefix(
payload.output_prefix if payload and payload.output_prefix else Path(video["filename"]).stem
)
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
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,
)
return {"outputs": outputs, "output_dir": str(output_dir)}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.post("/api/videos/split-all", response_model=schemas.JobOut)
def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None)) -> dict:
project = _require_project()
_save_project_file(project)
videos = db.list_videos(project["id"])
if payload and payload.video_ids:
selected = set(payload.video_ids)
videos = [item for item in videos if item["id"] in selected]
if not videos:
raise HTTPException(status_code=400, detail="No videos available to export")
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))
if not ready:
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
job = job_manager.create_job("split_all")
job_id = job["id"]
def run_job() -> dict:
project_dir = _project_dir(project["id"])
all_outputs: list[str] = []
total_videos = len(ready)
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=_make_progress_updater(
job_id,
label,
offset=(video_index - 1) / float(total_videos),
span=1.0 / float(total_videos),
),
log_cb=lambda message: job_manager.log(job_id, message),
)
all_outputs.extend(outputs)
return {"outputs": all_outputs, "skipped": skipped}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.delete("/api/videos/{video_id}", status_code=204)
def delete_video(video_id: str) -> None:
video = db.delete_video(video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")
file_path = Path(video["file_path"])
if file_path.exists():
file_path.unlink()
@app.get("/api/jobs/{job_id}", response_model=schemas.JobOut)
def get_job(job_id: str) -> dict:
try:
return job_manager.get_job(job_id)
except KeyError:
raise HTTPException(status_code=404, detail="Job not found")
@app.get("/api/videos/{video_id}/frame")
def get_frame(
video_id: str,
ts: float = Query(..., ge=0),
fast: bool = Query(default=False),
w: int | None = Query(default=None, ge=160, le=1920),
):
video = _get_video_or_404(video_id)
if ts > video["duration_seconds"]:
raise HTTPException(status_code=400, detail="Timestamp exceeds duration")
frame_bytes = media.extract_frame_bytes(video["file_path"], ts, fast=fast, scale_width=w)
return StreamingResponse(
io.BytesIO(frame_bytes),
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)