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:
parent
f0fc542260
commit
04c461837f
9 changed files with 585 additions and 12 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue