This commit is contained in:
2026-06-04 17:42:38 +02:00
parent 5e941e6e6a
commit da07b55798
8 changed files with 1554 additions and 1480 deletions
+48 -7
View File
@@ -52,6 +52,11 @@ def init_db() -> None:
segments_count INTEGER NOT NULL, segments_count INTEGER NOT NULL,
intro_seconds REAL NOT NULL, intro_seconds REAL NOT NULL,
outro_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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -65,6 +70,7 @@ def init_db() -> None:
filename TEXT NOT NULL, filename TEXT NOT NULL,
file_path TEXT NOT NULL, file_path TEXT NOT NULL,
duration_seconds REAL NOT NULL, duration_seconds REAL NOT NULL,
is_exported INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS markers ( CREATE TABLE IF NOT EXISTS markers (
@@ -80,6 +86,7 @@ def init_db() -> None:
segment_key TEXT NOT NULL, segment_key TEXT NOT NULL,
start_seconds REAL NOT NULL, start_seconds REAL NOT NULL,
end_seconds REAL NOT NULL, end_seconds REAL NOT NULL,
color TEXT,
modified_at TEXT NOT NULL modified_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_markers_video_id ON markers(video_id); 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()} columns = {row[1] for row in cur.fetchall()}
if "reencode_enabled" not in columns: if "reencode_enabled" not in columns:
cur.execute("ALTER TABLE projects ADD COLUMN reencode_enabled INTEGER NOT NULL DEFAULT 0") 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: if "ffmpeg_pass1_template" not in columns:
cur.execute("ALTER TABLE projects ADD COLUMN ffmpeg_pass1_template TEXT") cur.execute("ALTER TABLE projects ADD COLUMN ffmpeg_pass1_template TEXT")
if "ffmpeg_pass2_template" not in columns: if "ffmpeg_pass2_template" not in columns:
@@ -99,6 +110,13 @@ def init_db() -> None:
if "modified_at" not in segment_edit_columns: if "modified_at" not in segment_edit_columns:
cur.execute("ALTER TABLE segment_edits ADD COLUMN modified_at TEXT") cur.execute("ALTER TABLE segment_edits ADD COLUMN modified_at TEXT")
cur.execute("UPDATE segment_edits SET modified_at = ?", (_now(),)) 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( cur.execute(
"UPDATE projects SET ffmpeg_pass1_template = ? WHERE ffmpeg_pass1_template IS NULL", "UPDATE projects SET ffmpeg_pass1_template = ? WHERE ffmpeg_pass1_template IS NULL",
(DEFAULT_FFMPEG_PASS1_TEMPLATE,), (DEFAULT_FFMPEG_PASS1_TEMPLATE,),
@@ -111,10 +129,19 @@ def init_db() -> None:
conn.close() 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: def _normalize_project(project: dict | None) -> dict | None:
if not project: if not project:
return None return None
project["reencode_enabled"] = bool(project.get("reencode_enabled", 0)) 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_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 project["ffmpeg_pass2_template"] = project.get("ffmpeg_pass2_template") or DEFAULT_FFMPEG_PASS2_TEMPLATE
return project return project
@@ -126,6 +153,8 @@ def create_project(
intro_seconds: float, intro_seconds: float,
outro_seconds: float, outro_seconds: float,
reencode_enabled: bool = False, reencode_enabled: bool = False,
encoding_passes: int = 1,
target_os: str = "windows",
ffmpeg_pass1_template: str | None = None, ffmpeg_pass1_template: str | None = None,
ffmpeg_pass2_template: str | None = None, ffmpeg_pass2_template: str | None = None,
) -> dict: ) -> dict:
@@ -138,10 +167,11 @@ def create_project(
""" """
INSERT INTO projects ( INSERT INTO projects (
id, name, segments_count, intro_seconds, outro_seconds, 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 created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
project_id, project_id,
@@ -150,6 +180,8 @@ def create_project(
intro_seconds, intro_seconds,
outro_seconds, outro_seconds,
1 if reencode_enabled else 0, 1 if reencode_enabled else 0,
encoding_passes,
target_os,
ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE, ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE,
ffmpeg_pass2_template or DEFAULT_FFMPEG_PASS2_TEMPLATE, ffmpeg_pass2_template or DEFAULT_FFMPEG_PASS2_TEMPLATE,
now, now,
@@ -199,6 +231,8 @@ def update_current_project(
intro_seconds: float | None = None, intro_seconds: float | None = None,
outro_seconds: float | None = None, outro_seconds: float | None = None,
reencode_enabled: bool | None = None, reencode_enabled: bool | None = None,
encoding_passes: int | None = None,
target_os: str | None = None,
ffmpeg_pass1_template: str | None = None, ffmpeg_pass1_template: str | None = None,
ffmpeg_pass2_template: str | None = None, ffmpeg_pass2_template: str | None = None,
) -> dict | None: ) -> dict | None:
@@ -222,6 +256,12 @@ def update_current_project(
if reencode_enabled is not None: if reencode_enabled is not None:
fields.append("reencode_enabled = ?") fields.append("reencode_enabled = ?")
params.append(1 if reencode_enabled else 0) 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: if ffmpeg_pass1_template is not None:
fields.append("ffmpeg_pass1_template = ?") fields.append("ffmpeg_pass1_template = ?")
params.append(ffmpeg_pass1_template or DEFAULT_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 = conn.cursor()
cur.execute( cur.execute(
""" """
INSERT INTO videos (id, project_id, filename, file_path, duration_seconds, created_at) INSERT INTO videos (id, project_id, filename, file_path, duration_seconds, is_exported, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 0, ?)
""", """,
(video_id, project_id, filename, file_path, duration_seconds, now), (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( cur.execute(
""" """
INSERT INTO segment_edits ( 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, uuid.uuid4().hex,
@@ -352,13 +392,14 @@ def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]:
segment["segment_key"], segment["segment_key"],
float(segment["start_seconds"]), float(segment["start_seconds"]),
float(segment["end_seconds"]), float(segment["end_seconds"]),
segment.get("color"),
now, now,
), ),
) )
conn.commit() conn.commit()
cur.execute( cur.execute(
""" """
SELECT segment_key, start_seconds, end_seconds, modified_at SELECT segment_key, start_seconds, end_seconds, color, modified_at
FROM segment_edits FROM segment_edits
WHERE video_id = ? WHERE video_id = ?
ORDER BY rowid ORDER BY rowid
+21 -4
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware 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 . import db, jobs, media, mkv, schemas
from .settings import APP_NAME, JOBS_DIR, PROJECTS_DIR, UPLOADS_DIR, BLACKDETECT_WINDOW 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), log_cb=lambda message: job_manager.log(job_id, message),
custom_segments=custom_segments, 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) job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id) 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"]) project_dir = _project_dir(project["id"])
all_outputs: list[str] = [] all_outputs: list[str] = []
total_videos = len(ready) 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}" label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
output_dir = project_dir / "outputs" / video["id"] output_dir = project_dir / "outputs" / video["id"]
temp_dir = JOBS_DIR / job_id / 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), span=1.0 / float(total_videos),
), ),
log_cb=lambda message: job_manager.log(job_id, message), 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} return {"outputs": all_outputs, "skipped": skipped}
@@ -456,3 +459,17 @@ def get_frame(
media_type="image/jpeg", media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"}, 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",
)
+8
View File
@@ -7,6 +7,8 @@ class ProjectCreate(BaseModel):
intro_seconds: float = Field(ge=0) intro_seconds: float = Field(ge=0)
outro_seconds: float = Field(ge=0) outro_seconds: float = Field(ge=0)
reencode_enabled: bool = False 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_pass1_template: str | None = None
ffmpeg_pass2_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) intro_seconds: float | None = Field(default=None, ge=0)
outro_seconds: float | None = Field(default=None, ge=0) outro_seconds: float | None = Field(default=None, ge=0)
reencode_enabled: bool | None = None 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_pass1_template: str | None = None
ffmpeg_pass2_template: str | None = None ffmpeg_pass2_template: str | None = None
@@ -28,6 +32,8 @@ class ProjectOut(BaseModel):
intro_seconds: float intro_seconds: float
outro_seconds: float outro_seconds: float
reencode_enabled: bool reencode_enabled: bool
encoding_passes: int
target_os: str
ffmpeg_pass1_template: str | None = None ffmpeg_pass1_template: str | None = None
ffmpeg_pass2_template: str | None = None ffmpeg_pass2_template: str | None = None
created_at: str created_at: str
@@ -40,6 +46,7 @@ class VideoOut(BaseModel):
filename: str filename: str
file_path: str file_path: str
duration_seconds: float duration_seconds: float
is_exported: bool = False
created_at: str created_at: str
@@ -51,6 +58,7 @@ class SegmentEdit(BaseModel):
segment_key: str segment_key: str
start_seconds: float = Field(ge=0) start_seconds: float = Field(ge=0)
end_seconds: float = Field(ge=0) end_seconds: float = Field(ge=0)
color: str | None = None
class SegmentEditsUpdate(BaseModel): class SegmentEditsUpdate(BaseModel):
+403 -1464
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -55,6 +55,8 @@ export async function createProject(payload: {
intro_seconds: number; intro_seconds: number;
outro_seconds: number; outro_seconds: number;
reencode_enabled?: boolean; reencode_enabled?: boolean;
encoding_passes?: number;
target_os?: string;
ffmpeg_pass1_template?: string | null; ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null; ffmpeg_pass2_template?: string | null;
}): Promise<Project> { }): Promise<Project> {
@@ -70,6 +72,8 @@ export async function updateProject(payload: {
intro_seconds?: number; intro_seconds?: number;
outro_seconds?: number; outro_seconds?: number;
reencode_enabled?: boolean; reencode_enabled?: boolean;
encoding_passes?: number;
target_os?: string;
ffmpeg_pass1_template?: string | null; ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null; ffmpeg_pass2_template?: string | null;
}): Promise<Project> { }): Promise<Project> {
@@ -174,7 +178,7 @@ export async function listSegmentEdits(videoId: string): Promise<{ segments: Seg
export async function replaceSegmentEdits( export async function replaceSegmentEdits(
videoId: string, videoId: string,
segments: { segment_key: string; start_seconds: number; end_seconds: number }[] segments: { segment_key: string; start_seconds: number; end_seconds: number; color?: string | null }[]
): Promise<{ segments: SegmentEdit[] }> { ): Promise<{ segments: SegmentEdit[] }> {
return request(`/api/videos/${videoId}/segment-edits`, { return request(`/api/videos/${videoId}/segment-edits`, {
method: "PUT", method: "PUT",
+37 -4
View File
@@ -1,5 +1,17 @@
import { createTheme } from "@mui/material/styles"; import { createTheme } from "@mui/material/styles";
declare module "@mui/material/Button" {
interface ButtonPropsVariantOverrides {
tonal: true;
}
}
declare module "@mui/material/Chip" {
interface ChipPropsVariantOverrides {
tonal: true;
}
}
export type ColorMode = "light" | "dark"; export type ColorMode = "light" | "dark";
export function buildTheme(mode: ColorMode) { export function buildTheme(mode: ColorMode) {
@@ -30,7 +42,7 @@ export function buildTheme(mode: ColorMode) {
}, },
}, },
shape: { shape: {
borderRadius: 16, // Softer, more fluid shapes borderRadius: 12, // More balanced Material Design 3 curve
}, },
typography: { typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@@ -49,6 +61,18 @@ export function buildTheme(mode: ColorMode) {
}, },
components: { components: {
MuiButton: { MuiButton: {
variants: [
{
props: { variant: "tonal" },
style: {
backgroundColor: mode === "dark" ? "#4A4458" : "#E8DEF8",
color: mode === "dark" ? "#E8DEF8" : "#1D192B",
"&:hover": {
backgroundColor: mode === "dark" ? "#625B71" : "#D0BCFF",
},
},
},
] as any,
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: 100, // Full pill shape typical of M3 Expressive borderRadius: 100, // Full pill shape typical of M3 Expressive
@@ -69,7 +93,7 @@ export function buildTheme(mode: ColorMode) {
MuiCard: { MuiCard: {
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: 24, // Playful, edge-hugging containers borderRadius: 16, // Refined container rounding
boxShadow: "none", // Flatter hierarchy using surface tones boxShadow: "none", // Flatter hierarchy using surface tones
backgroundColor: mode === "dark" ? "#2B2930" : "#F3EDF7", backgroundColor: mode === "dark" ? "#2B2930" : "#F3EDF7",
backgroundImage: "none", backgroundImage: "none",
@@ -92,7 +116,7 @@ export function buildTheme(mode: ColorMode) {
MuiPaper: { MuiPaper: {
styleOverrides: { styleOverrides: {
rounded: { rounded: {
borderRadius: 24, borderRadius: 16,
}, },
}, },
}, },
@@ -100,12 +124,21 @@ export function buildTheme(mode: ColorMode) {
styleOverrides: { styleOverrides: {
root: { root: {
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
borderRadius: 12, // Softer input fields borderRadius: 8, // More compact input fields
}, },
}, },
}, },
}, },
MuiChip: { MuiChip: {
variants: [
{
props: { variant: "tonal" },
style: {
backgroundColor: mode === "dark" ? "#4A4458" : "#E8DEF8",
color: mode === "dark" ? "#E8DEF8" : "#1D192B",
},
},
] as any,
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: 8, borderRadius: 8,
+4
View File
@@ -5,6 +5,8 @@ export type Project = {
intro_seconds: number; intro_seconds: number;
outro_seconds: number; outro_seconds: number;
reencode_enabled: boolean; reencode_enabled: boolean;
encoding_passes: number;
target_os: "windows" | "linux";
ffmpeg_pass1_template: string | null; ffmpeg_pass1_template: string | null;
ffmpeg_pass2_template: string | null; ffmpeg_pass2_template: string | null;
created_at: string; created_at: string;
@@ -17,6 +19,7 @@ export type Video = {
filename: string; filename: string;
file_path: string; file_path: string;
duration_seconds: number; duration_seconds: number;
is_exported: boolean;
created_at: string; created_at: string;
}; };
@@ -43,5 +46,6 @@ export type SegmentEdit = {
segment_key: string; segment_key: string;
start_seconds: number; start_seconds: number;
end_seconds: number; end_seconds: number;
color: string | null;
modified_at: string; modified_at: string;
}; };