diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 8f6a441..5fe9fc2 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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 = { }; // 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({
-
{jobTitle(job)}
+
+ {job.job_kind === "edit" && ( + + {t("downloads.clipBadge")} + + )} + {jobTitle(job)} +
{job.asset?.uploader || job.source_ref}
@@ -371,6 +381,7 @@ export default function DownloadCenter({ me }: { me: Me }) { const [manage, setManage] = useState(false); const [renameJob, setRenameJob] = useState(null); const [shareJob, setShareJob] = useState(null); + const [editJob, setEditJob] = useState(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) => ( {saveBtn(job)} + {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} setRenameJob(job)} title={t("downloads.actions.rename")}> @@ -526,6 +542,7 @@ export default function DownloadCenter({ me }: { me: Me }) { {manage && setManage(false)} />} {renameJob && setRenameJob(null)} />} {shareJob && setShareJob(null)} />} + {editJob && setEditJob(null)} />}
); } diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx new file mode 100644 index 0000000..8a6f82d --- /dev/null +++ b/frontend/src/components/VideoEditor.tsx @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Crop, Pause, Play, Scissors, Loader2 } from "lucide-react"; +import clsx from "clsx"; +import Modal from "./Modal"; +import { notify } from "../lib/notifications"; +import { api, type DownloadJob, type EditSpec } from "../lib/api"; + +// mm:ss(.d) — a compact timecode for the trim readout. +function tc(s: number): string { + if (!isFinite(s) || s < 0) s = 0; + const m = Math.floor(s / 60); + const sec = s - m * 60; + return `${m}:${sec.toFixed(1).padStart(4, "0")}`; +} + +type Frac = { x: number; y: number; w: number; h: number }; // crop as 0..1 fractions of the frame + +const btn = + "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50"; +const inputCls = + "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"; + +export default function VideoEditor({ + job, + onClose, +}: { + job: DownloadJob; + onClose: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const videoRef = useRef(null); + const trackRef = useRef(null); + + const srcDur = job.asset?.duration_s ?? 0; + const [duration, setDuration] = useState(srcDur); + const [current, setCurrent] = useState(0); + const [playing, setPlaying] = useState(false); + + const [start, setStart] = useState(0); + const [end, setEnd] = useState(srcDur); + const [cropOn, setCropOn] = useState(false); + const [crop, setCrop] = useState({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 }); + const [accurate, setAccurate] = useState(true); + const [split, setSplit] = useState(1); + const [name, setName] = useState(""); + + const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]); + const sb = useQuery({ + queryKey: ["storyboard", job.id], + queryFn: () => api.downloadStoryboard(job.id), + staleTime: Infinity, + retry: false, + }); + + // Keep end pinned to the real duration until the user narrows it. + const onLoaded = () => { + const v = videoRef.current; + if (!v) return; + const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; + setDuration(d); + setEnd((e) => (e <= 0 || e > d ? d : e)); + }; + + // Pause at the out-point when previewing the selection. + const onTimeUpdate = () => { + const v = videoRef.current; + if (!v) return; + setCurrent(v.currentTime); + if (playing && v.currentTime >= end) { + v.pause(); + v.currentTime = end; + } + }; + + const seek = (s: number) => { + const v = videoRef.current; + if (v) v.currentTime = Math.max(0, Math.min(duration, s)); + }; + + const playPause = () => { + const v = videoRef.current; + if (!v) return; + if (v.paused) { + if (v.currentTime < start || v.currentTime >= end) v.currentTime = start; + v.play(); + } else { + v.pause(); + } + }; + + // --- timeline drag (in/out handles + scrub) --- + const timeAt = useCallback( + (clientX: number) => { + const el = trackRef.current; + if (!el) return 0; + const r = el.getBoundingClientRect(); + return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration; + }, + [duration] + ); + + const [drag, setDrag] = useState(null); + const onTrackDown = (e: React.PointerEvent, which: "start" | "end" | "seek") => { + e.preventDefault(); + e.stopPropagation(); + (e.target as Element).setPointerCapture?.(e.pointerId); + setDrag(which); + if (which === "seek") seek(timeAt(e.clientX)); + }; + useEffect(() => { + if (!drag) return; + const move = (e: PointerEvent) => { + const tt = timeAt(e.clientX); + if (drag === "start") setStart(Math.min(tt, end - 0.1)); + else if (drag === "end") setEnd(Math.max(tt, start + 0.1)); + else { + setCurrent(tt); + seek(tt); + } + }; + const up = () => setDrag(null); + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + return () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + }, [drag, timeAt, start, end]); + + // --- crop drag (move body + resize corner), all in 0..1 frame fractions --- + const overlayRef = useRef(null); + const cropDrag = useRef(null); + const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => { + e.preventDefault(); + e.stopPropagation(); + (e.target as Element).setPointerCapture?.(e.pointerId); + cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } }; + }; + useEffect(() => { + const move = (e: PointerEvent) => { + const d = cropDrag.current; + const el = overlayRef.current; + if (!d || !el) return; + const r = el.getBoundingClientRect(); + const dx = (e.clientX - d.px) / r.width; + const dy = (e.clientY - d.py) / r.height; + if (d.mode === "move") { + setCrop({ + ...d.orig, + x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)), + y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)), + }); + } else { + setCrop({ + ...d.orig, + w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)), + h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)), + }); + } + }; + const up = () => (cropDrag.current = null); + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + return () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + }, []); + + // --- create --- + const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`; + const selDur = Math.max(0, end - start); + const reencode = cropOn || accurate; + + const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { + const v = videoRef.current; + const vw = v?.videoWidth || job.asset?.width || 0; + const vh = v?.videoHeight || job.asset?.height || 0; + if (!cropOn || !vw || !vh) return undefined; + // Force even dimensions — libx264 requires them. + const even = (n: number) => Math.max(2, Math.round(n / 2) * 2); + return { + x: even(crop.x * vw), + y: even(crop.y * vh), + w: even(crop.w * vw), + h: even(crop.h * vh), + }; + }; + + const create = useMutation({ + mutationFn: async () => { + const n = Math.max(1, Math.min(20, Math.round(split))); + const cp = cropPx(); + const seg = selDur / n; + const base = name.trim(); + for (let i = 0; i < n; i++) { + const s = start + seg * i; + const e = i === n - 1 ? end : s + seg; + const spec: EditSpec = { trim: { start_s: +s.toFixed(3), end_s: +e.toFixed(3) } }; + if (cp) spec.crop = cp; + else spec.accurate = accurate; + const dn = n > 1 ? `${base || defaultName} (${i + 1}/${n})` : base || undefined; + await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn }); + } + return n; + }, + onSuccess: (n) => { + qc.invalidateQueries({ queryKey: ["downloads"] }); + qc.invalidateQueries({ queryKey: ["download-usage"] }); + notify({ level: "success", message: t("editor.created", { count: n }) }); + onClose(); + }, + onError: () => notify({ level: "error", message: t("editor.failed") }), + }); + + const defaultName = job.display_name || job.asset?.title || ""; + const valid = selDur >= 0.1 && (cropOn || start > 0 || end < duration - 0.01 || split > 1); + + // filmstrip cell backgrounds via percentage sprite positioning (no pixel math needed). + const strip = sb.data?.available ? sb.data : null; + + return ( + +
+ {/* video + crop overlay */} +
+