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

@ -7,6 +7,7 @@ import {
Pause,
Play,
Plus,
Scissors,
Settings2,
Share2,
Pencil,
@ -17,6 +18,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
import Modal from "./Modal";
import DownloadDialog from "./DownloadDialog";
import ProfileEditor from "./ProfileEditor";
import VideoEditor from "./VideoEditor";
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api";
@ -37,9 +39,10 @@ const STATUS_CLS: Record<string, string> = {
};
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
const DOWNLOAD_PHASES = ["video", "audio"];
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
const PHASE_KEYS = [
"video", "audio", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
"video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
];
function StatusPill({ job }: { job: DownloadJob }) {
@ -108,7 +111,14 @@ function DownloadRow({
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition">
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1">{jobTitle(job)}</div>
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{job.job_kind === "edit" && (
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
<span className="truncate">{jobTitle(job)}</span>
</div>
<div className="text-xs text-muted truncate mt-0.5">
{job.asset?.uploader || job.source_ref}
</div>
@ -371,6 +381,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
const [manage, setManage] = useState(false);
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
@ -479,6 +490,11 @@ export default function DownloadCenter({ me }: { me: Me }) {
{library.map((job) => (
<DownloadRow key={job.id} job={job}>
{saveBtn(job)}
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}>
<Pencil className="w-4 h-4" />
</IconBtn>
@ -526,6 +542,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</div>
);
}