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, 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),
) )
@@ -320,6 +360,18 @@ def replace_markers(video_id: str, markers: list[float], source: str) -> list[fl
return sorted(markers) 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]: def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]:
now = _now() now = _now()
with _DB_LOCK: with _DB_LOCK:
@@ -330,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,
@@ -340,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
+41 -8
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
@@ -236,6 +236,11 @@ def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
return {"markers": db.replace_markers(video_id, markers, source="manual")} 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]]) @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: def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
_get_video_or_404(video_id) _get_video_or_404(video_id)
@@ -306,6 +311,11 @@ def split_video(
job = job_manager.create_job("split") job = job_manager.create_job("split")
job_id = job["id"] 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: def run_job() -> dict:
project_dir = _project_dir(project["id"]) project_dir = _project_dir(project["id"])
output_dir = project_dir / "outputs" / video_id output_dir = project_dir / "outputs" / video_id
@@ -328,8 +338,10 @@ def split_video(
ffmpeg_pass2_template=project["ffmpeg_pass2_template"], ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
progress_cb=progress, progress_cb=progress,
log_cb=lambda message: job_manager.log(job_id, message), 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) job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id) 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: if not videos:
raise HTTPException(status_code=400, detail="No videos available to export") 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] = [] skipped: list[dict] = []
for video in videos: for video in videos:
try: 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}) skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
continue continue
markers = sorted(db.list_markers(video["id"])) markers = sorted(db.list_markers(video["id"]))
if not markers: segment_edits = db.list_segment_edits(video["id"])
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"}) 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 continue
ready.append((video, markers)) ready.append((video, markers, custom_segments))
if not ready: if not ready:
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export") 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"]) 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"]
@@ -396,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}
@@ -440,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",
)
+29 -21
View File
@@ -348,35 +348,43 @@ def build_episodes(
ffmpeg_pass2_template: str | None = None, ffmpeg_pass2_template: str | None = None,
progress_cb: ProgressCallback | None = None, progress_cb: ProgressCallback | None = None,
log_cb: LogCallback | None = None, log_cb: LogCallback | None = None,
custom_segments: list[tuple[float, float]] | None = None,
) -> list[str]: ) -> list[str]:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
min_segment = 0.001 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] = [] 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]] = [] core_ranges: list[tuple[int, float, float]] = []
for index, end in enumerate(safe_boundaries, start=1): min_segment = 0.001
core_ranges.append((index, prev, end))
prev = end 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 intro_work = intro_seconds if intro_seconds > min_segment else 0.0
outro_start = max(0.0, total_duration - outro_seconds) 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) 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):
+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; 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> {
@@ -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[] = []; const uploaded: Video[] = [];
for (const file of files) { for (let i = 0; i < files.length; i++) {
uploaded.push(await uploadVideo(file)); const file = files[i];
const video = await uploadVideoWithProgress(file, (progress) => {
if (onProgress) onProgress(i, files.length, progress);
});
uploaded.push(video);
} }
return uploaded; return uploaded;
} }
export async function listSegmentEdits(videoId: string): Promise<{ segments: SegmentEdit[] }> {
return request(`/api/videos/${videoId}/segment-edits`);
}
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;
}; };