Compare commits

...

4 Commits

Author SHA1 Message Date
PickleRick da07b55798 update 2026-06-04 17:42:38 +02:00
PickleRick 5e941e6e6a Cleanup 2026-06-03 00:57:50 +02:00
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
PickleRick da898ef6f8 add an unlink buttton to unlink timestamps in the segments table
Add progress bar for uploading
2026-06-03 00:09:47 +02:00
9 changed files with 1676 additions and 1361 deletions
+60 -7
View File
@@ -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),
)
@@ -320,6 +360,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:
@@ -330,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,
@@ -340,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
+41 -8
View File
@@ -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
@@ -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,8 +338,10 @@ 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)}
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)
@@ -346,7 +358,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 +367,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")
@@ -370,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"]
@@ -396,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}
@@ -440,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",
)
+29 -21
View File
@@ -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)
+8
View File
@@ -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):
+413 -1317
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+56 -4
View File
@@ -55,6 +55,8 @@ export async function createProject(payload: {
intro_seconds: number;
outro_seconds: number;
reencode_enabled?: boolean;
encoding_passes?: number;
target_os?: string;
ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null;
}): Promise<Project> {
@@ -70,6 +72,8 @@ export async function updateProject(payload: {
intro_seconds?: number;
outro_seconds?: number;
reencode_enabled?: boolean;
encoding_passes?: number;
target_os?: string;
ffmpeg_pass1_template?: string | null;
ffmpeg_pass2_template?: string | null;
}): Promise<Project> {
@@ -116,17 +120,65 @@ export async function replaceMarkers(
});
}
export async function uploadVideos(files: File[]): Promise<Video[]> {
export async function uploadVideoWithProgress(file: File, onProgress?: (progress: number) => void): Promise<Video> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${apiBase}/api/videos/upload`);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
onProgress(event.loaded / event.total);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new ApiError(xhr.status, "Invalid response"));
}
} else {
let message = `Request failed (${xhr.status})`;
try {
const data = JSON.parse(xhr.responseText);
if (data?.detail) message = data.detail;
else if (data?.message) message = data.message;
} catch {}
reject(new ApiError(xhr.status, message));
}
};
xhr.onerror = () => reject(new ApiError(0, "Network error"));
const formData = new FormData();
formData.append("file", file);
xhr.send(formData);
});
}
export async function uploadVideos(
files: File[],
onProgress?: (index: number, total: number, fileProgress: number) => void
): Promise<Video[]> {
const uploaded: Video[] = [];
for (const file of files) {
uploaded.push(await uploadVideo(file));
for (let i = 0; i < files.length; i++) {
const file = files[i];
const video = await uploadVideoWithProgress(file, (progress) => {
if (onProgress) onProgress(i, files.length, progress);
});
uploaded.push(video);
}
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 }[]
segments: { segment_key: string; start_seconds: number; end_seconds: number; color?: string | null }[]
): Promise<{ segments: SegmentEdit[] }> {
return request(`/api/videos/${videoId}/segment-edits`, {
method: "PUT",
+37 -4
View File
@@ -1,5 +1,17 @@
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 function buildTheme(mode: ColorMode) {
@@ -30,7 +42,7 @@ export function buildTheme(mode: ColorMode) {
},
},
shape: {
borderRadius: 16, // Softer, more fluid shapes
borderRadius: 12, // More balanced Material Design 3 curve
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@@ -49,6 +61,18 @@ export function buildTheme(mode: ColorMode) {
},
components: {
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: {
root: {
borderRadius: 100, // Full pill shape typical of M3 Expressive
@@ -69,7 +93,7 @@ export function buildTheme(mode: ColorMode) {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 24, // Playful, edge-hugging containers
borderRadius: 16, // Refined container rounding
boxShadow: "none", // Flatter hierarchy using surface tones
backgroundColor: mode === "dark" ? "#2B2930" : "#F3EDF7",
backgroundImage: "none",
@@ -92,7 +116,7 @@ export function buildTheme(mode: ColorMode) {
MuiPaper: {
styleOverrides: {
rounded: {
borderRadius: 24,
borderRadius: 16,
},
},
},
@@ -100,12 +124,21 @@ export function buildTheme(mode: ColorMode) {
styleOverrides: {
root: {
"& .MuiOutlinedInput-root": {
borderRadius: 12, // Softer input fields
borderRadius: 8, // More compact input fields
},
},
},
},
MuiChip: {
variants: [
{
props: { variant: "tonal" },
style: {
backgroundColor: mode === "dark" ? "#4A4458" : "#E8DEF8",
color: mode === "dark" ? "#E8DEF8" : "#1D192B",
},
},
] as any,
styleOverrides: {
root: {
borderRadius: 8,
+4
View File
@@ -5,6 +5,8 @@ export type Project = {
intro_seconds: number;
outro_seconds: number;
reencode_enabled: boolean;
encoding_passes: number;
target_os: "windows" | "linux";
ffmpeg_pass1_template: string | null;
ffmpeg_pass2_template: string | null;
created_at: string;
@@ -17,6 +19,7 @@ export type Video = {
filename: string;
file_path: string;
duration_seconds: number;
is_exported: boolean;
created_at: string;
};
@@ -43,5 +46,6 @@ export type SegmentEdit = {
segment_key: string;
start_seconds: number;
end_seconds: number;
color: string | null;
modified_at: string;
};