4a7d1e6663
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
459 lines
16 KiB
Python
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"},
|
|
)
|