feat(downloads): editor UI — trim/split/crop clips (phase 2)

VideoEditor modal on a finished Library download: HTML5 <video> scrubber, filmstrip timeline
(lazy server storyboard sprite) with draggable in/out handles, draggable/resizable crop overlay,
per-edit Precise (re-encode) vs Fast (stream-copy) cut toggle, split-into-N fan-out (N trim jobs),
optional clip name. Edited clips show a 'Clip' badge; 'editing' phase gets a % bar. New api
enqueueEdit/downloadStoryboard/storyboardImageUrl + EditSpec type; editor i18n (en/hu/de).
This commit is contained in:
npeter83 2026-07-04 01:10:42 +02:00
parent f0fc542260
commit 04c461837f
9 changed files with 585 additions and 12 deletions

View file

@ -667,6 +667,22 @@ export type DownloadStatus =
| "error"
| "canceled";
// Phase-2 editor recipe. `accurate` only matters for a trim-only cut (a crop always re-encodes).
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
crop?: { x: number; y: number; w: number; h: number };
accurate?: boolean;
}
export interface StoryboardMeta {
available: boolean;
cols?: number;
rows?: number;
count?: number;
interval_s?: number;
url?: string;
}
export interface DownloadJob {
id: number;
status: DownloadStatus;
@ -682,6 +698,9 @@ export interface DownloadJob {
profile_id: number | null;
spec: DownloadSpec;
display_name: string | null;
job_kind?: string; // "download" (default) | "edit"
source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null;
can_download: boolean;
expired: boolean;
asset: DownloadAsset | null;
@ -1027,6 +1046,14 @@ export const api = {
display_name?: string;
}): Promise<DownloadJob> =>
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
display_name?: string;
}): Promise<DownloadJob> =>
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
@ -1051,6 +1078,13 @@ export const api = {
const a = getActiveAccount();
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
},
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
: `/api/downloads/${id}/storyboard.jpg`;
},
// admin
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),