From da07b557987080ff8b15d1371a75e277f2db82b7 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Thu, 4 Jun 2026 17:42:38 +0200 Subject: [PATCH] update --- backend/app/db.py | 55 +- backend/app/main.py | 25 +- backend/app/schemas.py | 8 + frontend/src/App.tsx | 1867 ++++++++-------------------------- frontend/src/VideoEditor.tsx | 1028 +++++++++++++++++++ frontend/src/api.ts | 6 +- frontend/src/theme.ts | 41 +- frontend/src/types.ts | 4 + 8 files changed, 1554 insertions(+), 1480 deletions(-) create mode 100644 frontend/src/VideoEditor.tsx diff --git a/backend/app/db.py b/backend/app/db.py index 07db53a..6fad83e 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -52,6 +52,11 @@ def init_db() -> None: segments_count INTEGER NOT NULL, intro_seconds REAL NOT NULL, outro_seconds REAL NOT NULL, + reencode_enabled INTEGER NOT NULL DEFAULT 0, + encoding_passes INTEGER NOT NULL DEFAULT 1, + target_os TEXT NOT NULL DEFAULT 'windows', + ffmpeg_pass1_template TEXT, + ffmpeg_pass2_template TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -65,6 +70,7 @@ def init_db() -> None: filename TEXT NOT NULL, file_path TEXT NOT NULL, duration_seconds REAL NOT NULL, + is_exported INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS markers ( @@ -80,6 +86,7 @@ def init_db() -> None: segment_key TEXT NOT NULL, start_seconds REAL NOT NULL, end_seconds REAL NOT NULL, + color TEXT, modified_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_markers_video_id ON markers(video_id); @@ -90,6 +97,10 @@ def init_db() -> None: columns = {row[1] for row in cur.fetchall()} if "reencode_enabled" not in columns: cur.execute("ALTER TABLE projects ADD COLUMN reencode_enabled INTEGER NOT NULL DEFAULT 0") + if "encoding_passes" not in columns: + cur.execute("ALTER TABLE projects ADD COLUMN encoding_passes INTEGER NOT NULL DEFAULT 1") + if "target_os" not in columns: + cur.execute("ALTER TABLE projects ADD COLUMN target_os TEXT NOT NULL DEFAULT 'windows'") if "ffmpeg_pass1_template" not in columns: cur.execute("ALTER TABLE projects ADD COLUMN ffmpeg_pass1_template TEXT") if "ffmpeg_pass2_template" not in columns: @@ -99,6 +110,13 @@ def init_db() -> None: if "modified_at" not in segment_edit_columns: cur.execute("ALTER TABLE segment_edits ADD COLUMN modified_at TEXT") cur.execute("UPDATE segment_edits SET modified_at = ?", (_now(),)) + if "color" not in segment_edit_columns: + cur.execute("ALTER TABLE segment_edits ADD COLUMN color TEXT") + + cur.execute("PRAGMA table_info(videos)") + video_columns = {row[1] for row in cur.fetchall()} + if "is_exported" not in video_columns: + cur.execute("ALTER TABLE videos ADD COLUMN is_exported INTEGER NOT NULL DEFAULT 0") cur.execute( "UPDATE projects SET ffmpeg_pass1_template = ? WHERE ffmpeg_pass1_template IS NULL", (DEFAULT_FFMPEG_PASS1_TEMPLATE,), @@ -111,10 +129,19 @@ def init_db() -> None: conn.close() +def mark_video_exported(video_id: str) -> None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("UPDATE videos SET is_exported = 1 WHERE id = ?", (video_id,)) + conn.commit() + def _normalize_project(project: dict | None) -> dict | None: if not project: return None project["reencode_enabled"] = bool(project.get("reencode_enabled", 0)) + project["encoding_passes"] = int(project.get("encoding_passes", 1)) + project["target_os"] = project.get("target_os", "windows") project["ffmpeg_pass1_template"] = project.get("ffmpeg_pass1_template") or DEFAULT_FFMPEG_PASS1_TEMPLATE project["ffmpeg_pass2_template"] = project.get("ffmpeg_pass2_template") or DEFAULT_FFMPEG_PASS2_TEMPLATE return project @@ -126,6 +153,8 @@ def create_project( intro_seconds: float, outro_seconds: float, reencode_enabled: bool = False, + encoding_passes: int = 1, + target_os: str = "windows", ffmpeg_pass1_template: str | None = None, ffmpeg_pass2_template: str | None = None, ) -> dict: @@ -138,10 +167,11 @@ def create_project( """ INSERT INTO projects ( id, name, segments_count, intro_seconds, outro_seconds, - reencode_enabled, ffmpeg_pass1_template, ffmpeg_pass2_template, + reencode_enabled, encoding_passes, target_os, + ffmpeg_pass1_template, ffmpeg_pass2_template, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( project_id, @@ -150,6 +180,8 @@ def create_project( intro_seconds, outro_seconds, 1 if reencode_enabled else 0, + encoding_passes, + target_os, ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE, ffmpeg_pass2_template or DEFAULT_FFMPEG_PASS2_TEMPLATE, now, @@ -199,6 +231,8 @@ def update_current_project( intro_seconds: float | None = None, outro_seconds: float | None = None, reencode_enabled: bool | None = None, + encoding_passes: int | None = None, + target_os: str | None = None, ffmpeg_pass1_template: str | None = None, ffmpeg_pass2_template: str | None = None, ) -> dict | None: @@ -222,6 +256,12 @@ def update_current_project( if reencode_enabled is not None: fields.append("reencode_enabled = ?") params.append(1 if reencode_enabled else 0) + if encoding_passes is not None: + fields.append("encoding_passes = ?") + params.append(encoding_passes) + if target_os is not None: + fields.append("target_os = ?") + params.append(target_os) if ffmpeg_pass1_template is not None: fields.append("ffmpeg_pass1_template = ?") params.append(ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE) @@ -252,8 +292,8 @@ def create_video(project_id: str, filename: str, file_path: str, duration_second cur = conn.cursor() cur.execute( """ - INSERT INTO videos (id, project_id, filename, file_path, duration_seconds, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO videos (id, project_id, filename, file_path, duration_seconds, is_exported, created_at) + VALUES (?, ?, ?, ?, ?, 0, ?) """, (video_id, project_id, filename, file_path, duration_seconds, now), ) @@ -342,9 +382,9 @@ def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]: cur.execute( """ INSERT INTO segment_edits ( - id, video_id, segment_key, start_seconds, end_seconds, modified_at + id, video_id, segment_key, start_seconds, end_seconds, color, modified_at ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( uuid.uuid4().hex, @@ -352,13 +392,14 @@ def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]: segment["segment_key"], float(segment["start_seconds"]), float(segment["end_seconds"]), + segment.get("color"), now, ), ) conn.commit() cur.execute( """ - SELECT segment_key, start_seconds, end_seconds, modified_at + SELECT segment_key, start_seconds, end_seconds, color, modified_at FROM segment_edits WHERE video_id = ? ORDER BY rowid diff --git a/backend/app/main.py b/backend/app/main.py index 377af69..db2bbc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ 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 fastapi.responses import FileResponse, StreamingResponse from . import db, jobs, media, mkv, schemas from .settings import APP_NAME, JOBS_DIR, PROJECTS_DIR, UPLOADS_DIR, BLACKDETECT_WINDOW @@ -340,7 +340,8 @@ def split_video( log_cb=lambda message: job_manager.log(job_id, message), custom_segments=custom_segments, ) - return {"outputs": outputs, "output_dir": str(output_dir)} + db.mark_video_exported(video_id) + return {"outputs": [Path(o).name for o in outputs], "output_dir": str(output_dir)} job_manager.run_in_thread(job_id, run_job) return job_manager.get_job(job_id) @@ -386,7 +387,7 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None project_dir = _project_dir(project["id"]) all_outputs: list[str] = [] total_videos = len(ready) - for video_index, (video, markers) in enumerate(ready, start=1): + 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"] @@ -412,8 +413,10 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None span=1.0 / float(total_videos), ), log_cb=lambda message: job_manager.log(job_id, message), + custom_segments=custom_segments, ) - all_outputs.extend(outputs) + db.mark_video_exported(video["id"]) + all_outputs.extend([Path(o).name for o in outputs]) return {"outputs": all_outputs, "skipped": skipped} @@ -456,3 +459,17 @@ def get_frame( media_type="image/jpeg", headers={"Cache-Control": "public, max-age=86400"}, ) + + +@app.get("/api/videos/{video_id}/outputs/{filename}") +def download_output(video_id: str, filename: str): + project = _require_project() + project_dir = _project_dir(project["id"]) + file_path = project_dir / "outputs" / video_id / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Output file not found") + return FileResponse( + path=file_path, + filename=filename, + media_type="video/x-matroska", + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 07361db..8d0b988 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -7,6 +7,8 @@ class ProjectCreate(BaseModel): intro_seconds: float = Field(ge=0) outro_seconds: float = Field(ge=0) reencode_enabled: bool = False + encoding_passes: int = Field(default=1, ge=1, le=2) + target_os: str = Field(default="windows") ffmpeg_pass1_template: str | None = None ffmpeg_pass2_template: str | None = None @@ -17,6 +19,8 @@ class ProjectUpdate(BaseModel): intro_seconds: float | None = Field(default=None, ge=0) outro_seconds: float | None = Field(default=None, ge=0) reencode_enabled: bool | None = None + encoding_passes: int | None = Field(default=None, ge=1, le=2) + target_os: str | None = None ffmpeg_pass1_template: str | None = None ffmpeg_pass2_template: str | None = None @@ -28,6 +32,8 @@ class ProjectOut(BaseModel): intro_seconds: float outro_seconds: float reencode_enabled: bool + encoding_passes: int + target_os: str ffmpeg_pass1_template: str | None = None ffmpeg_pass2_template: str | None = None created_at: str @@ -40,6 +46,7 @@ class VideoOut(BaseModel): filename: str file_path: str duration_seconds: float + is_exported: bool = False created_at: str @@ -51,6 +58,7 @@ class SegmentEdit(BaseModel): segment_key: str start_seconds: float = Field(ge=0) end_seconds: float = Field(ge=0) + color: str | None = None class SegmentEditsUpdate(BaseModel): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a069d32..2fd2481 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,42 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState, useCallback } from "react"; import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, AppBar, Box, Button, + Checkbox, Chip, Container, CssBaseline, + Divider, Drawer, + FormControl, FormControlLabel, Grid, IconButton, + InputLabel, LinearProgress, + MenuItem, Paper, - Slider, + Select, Stack, Step, StepContent, StepLabel, Stepper, Switch, + Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Tabs, TextField, - InputAdornment, - Tooltip, Toolbar, Typography, } from "@mui/material"; @@ -37,37 +45,28 @@ import { Brightness4, Brightness7, CallSplit, - ContentPaste, ContentCopy, - Delete, - Link, - LinkOff, - MyLocation, + Download, + ExpandMore, Settings, - SkipNext, - SkipPrevious, + Terminal, UploadFile, } from "@mui/icons-material"; import { - autoCutVideo, createProject, deleteVideo, getCurrentProject, getJob, - getVideoMetadata, - listMarkers, listSegmentEdits, listVideos, - replaceMarkers, - replaceSegmentEdits, splitAllVideos, - splitVideo, updateProject, uploadVideos, } from "./api"; -import { Job, Project, Video } from "./types"; +import { Job, Project, SegmentEdit, Video } from "./types"; import { buildTheme, ColorMode } from "./theme"; +import VideoEditor from "./VideoEditor"; const formatTime = (value: number) => { const total = Math.max(0, value); @@ -78,25 +77,6 @@ const formatTime = (value: number) => { return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${padded}`; }; -const parseTimecode = (value: string) => { - const trimmed = value.trim(); - const match = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(trimmed); - if (!match) { - return null; - } - const hours = Number(match[1]); - const minutes = Number(match[2]); - const seconds = Number(match[3]); - const millisRaw = match[4] ?? "0"; - const millis = Number(millisRaw.padEnd(3, "0")); - if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds) || Number.isNaN(millis)) { - return null; - } - return hours * 3600 + minutes * 60 + seconds + millis / 1000; -}; - -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - const DEFAULT_PASS1_TEMPLATE = "ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} -map_metadata -1 -map_chapters -1 -an -sn -b:v 2M -x265-params no-slow-firstpass=1:pass=1:open-gop=0:keyint=60:min-keyint=60:scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ -tune animation -c:v libx265 -f null {null}"; const DEFAULT_PASS2_TEMPLATE = @@ -115,25 +95,19 @@ export default function App() { intro: 90, outro: 90, reencodeEnabled: false, + encodingPasses: 1, + targetOs: "windows" as "windows" | "linux", ffmpegPass1Template: DEFAULT_PASS1_TEMPLATE, ffmpegPass2Template: DEFAULT_PASS2_TEMPLATE, }); - const [video, setVideo] = useState