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,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Scissors,
|
||||||
Settings2,
|
Settings2,
|
||||||
Share2,
|
Share2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -17,6 +18,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import DownloadDialog from "./DownloadDialog";
|
import DownloadDialog from "./DownloadDialog";
|
||||||
import ProfileEditor from "./ProfileEditor";
|
import ProfileEditor from "./ProfileEditor";
|
||||||
|
import VideoEditor from "./VideoEditor";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { api, type DownloadJob, type Me } from "../lib/api";
|
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).
|
// 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 = [
|
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 }) {
|
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">
|
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition">
|
||||||
<Thumb job={job} />
|
<Thumb job={job} />
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<div className="text-xs text-muted truncate mt-0.5">
|
||||||
{job.asset?.uploader || job.source_ref}
|
{job.asset?.uploader || job.source_ref}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -371,6 +381,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
|
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
|
||||||
const [shareJob, setShareJob] = 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 jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
|
||||||
const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
|
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) => (
|
{library.map((job) => (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
{saveBtn(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")}>
|
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
|
|
@ -526,6 +542,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||||
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
|
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
426
frontend/src/components/VideoEditor.tsx
Normal file
426
frontend/src/components/VideoEditor.tsx
Normal file
|
|
@ -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<HTMLVideoElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(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<Frac>({ 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 | "start" | "end" | "seek">(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<HTMLDivElement>(null);
|
||||||
|
const cropDrag = useRef<null | {
|
||||||
|
mode: "move" | "resize";
|
||||||
|
px: number;
|
||||||
|
py: number;
|
||||||
|
orig: Frac;
|
||||||
|
}>(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 (
|
||||||
|
<Modal title={t("editor.title", { name: defaultName })} onClose={onClose} maxWidth="max-w-3xl">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* video + crop overlay */}
|
||||||
|
<div className="relative bg-black rounded-lg overflow-hidden select-none">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={fileUrl}
|
||||||
|
onLoadedMetadata={onLoaded}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
className="w-full max-h-[46vh] mx-auto block"
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
{cropOn && (
|
||||||
|
<div ref={overlayRef} className="absolute inset-0">
|
||||||
|
{/* the box's 9999px boxShadow dims everything outside the crop rectangle */}
|
||||||
|
<div
|
||||||
|
className="absolute border-2 border-accent cursor-move bg-transparent"
|
||||||
|
style={{
|
||||||
|
left: `${crop.x * 100}%`,
|
||||||
|
top: `${crop.y * 100}%`,
|
||||||
|
width: `${crop.w * 100}%`,
|
||||||
|
height: `${crop.h * 100}%`,
|
||||||
|
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onCropDown(e, "move")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-sm bg-accent cursor-nwse-resize"
|
||||||
|
onPointerDown={(e) => onCropDown(e, "resize")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* transport */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={playPause} className="p-2 rounded-lg bg-surface hover:bg-card transition" title={t("editor.playPause")}>
|
||||||
|
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="text-xs text-muted tabular-nums">
|
||||||
|
{tc(current)} / {tc(duration)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={() => setStart(Math.min(current, end - 0.1))} className={clsx(btn, "bg-surface hover:bg-card")}>
|
||||||
|
{t("editor.setIn")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEnd(Math.max(current, start + 0.1))} className={clsx(btn, "bg-surface hover:bg-card")}>
|
||||||
|
{t("editor.setOut")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* timeline: filmstrip + selection + handles + playhead */}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="relative h-12 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
||||||
|
onPointerDown={(e) => onTrackDown(e, "seek")}
|
||||||
|
>
|
||||||
|
{strip && (
|
||||||
|
<div className="absolute inset-0 flex">
|
||||||
|
{Array.from({ length: strip.count ?? 0 }).map((_, i) => {
|
||||||
|
const cols = strip.cols ?? 12;
|
||||||
|
const rows = strip.rows ?? 1;
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full flex-1"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${api.storyboardImageUrl(job.id)})`,
|
||||||
|
backgroundSize: `${cols * 100}% ${rows * 100}%`,
|
||||||
|
backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* dim outside selection */}
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-black/60" style={{ width: pct(start) }} />
|
||||||
|
<div className="absolute inset-y-0 right-0 bg-black/60" style={{ left: pct(end) }} />
|
||||||
|
{/* selection border */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 border-x-2 border-accent pointer-events-none"
|
||||||
|
style={{ left: pct(start), right: `calc(100% - ${pct(end)})` }}
|
||||||
|
/>
|
||||||
|
{/* handles */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 -ml-1.5 w-3 cursor-ew-resize flex items-center justify-center"
|
||||||
|
style={{ left: pct(start) }}
|
||||||
|
onPointerDown={(e) => onTrackDown(e, "start")}
|
||||||
|
>
|
||||||
|
<div className="w-1 h-6 rounded bg-accent" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 -ml-1.5 w-3 cursor-ew-resize flex items-center justify-center"
|
||||||
|
style={{ left: pct(end) }}
|
||||||
|
onPointerDown={(e) => onTrackDown(e, "end")}
|
||||||
|
>
|
||||||
|
<div className="w-1 h-6 rounded bg-accent" />
|
||||||
|
</div>
|
||||||
|
{/* playhead */}
|
||||||
|
<div className="absolute inset-y-0 w-0.5 bg-white/80 pointer-events-none" style={{ left: pct(current) }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted">
|
||||||
|
<span>{t("editor.in")}: {tc(start)}</span>
|
||||||
|
<span className="text-fg font-medium">{t("editor.selected")}: {tc(selDur)}</span>
|
||||||
|
<span>{t("editor.out")}: {tc(end)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* options */}
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setCropOn((v) => !v)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
|
||||||
|
cropOn ? "border-accent text-accent bg-accent/10" : "border-border text-muted hover:text-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm">
|
||||||
|
<Scissors className="w-4 h-4 text-muted shrink-0" />
|
||||||
|
<span className="text-muted">{t("editor.split")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={split}
|
||||||
|
onChange={(e) => setSplit(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||||
|
className="w-16 rounded-md bg-surface border border-border px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* cut precision (only meaningful for a trim-only edit; a crop always re-encodes) */}
|
||||||
|
{!cropOn ? (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
{(["accurate", "fast"] as const).map((m) => {
|
||||||
|
const on = (m === "accurate") === accurate;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setAccurate(m === "accurate")}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 px-3 py-2 rounded-lg border text-left transition",
|
||||||
|
on ? "border-accent bg-accent/10" : "border-border hover:border-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={clsx("font-medium", on ? "text-accent" : "text-fg")}>{t(`editor.${m}.label`)}</div>
|
||||||
|
<div className="text-xs text-muted mt-0.5">{t(`editor.${m}.hint`)}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted">{t("editor.cropReencode")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={defaultName}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={onClose} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
disabled={!valid || create.isPending}
|
||||||
|
className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
|
||||||
|
>
|
||||||
|
{create.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{split > 1 ? t("editor.createN", { count: Math.round(split) }) : t("editor.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
"thumbnail": "Vorschaubild einbetten",
|
"thumbnail": "Vorschaubild einbetten",
|
||||||
"sponsorblock": "Sponsoren entfernen",
|
"sponsorblock": "Sponsoren entfernen",
|
||||||
"metadata": "Metadaten schreiben",
|
"metadata": "Metadaten schreiben",
|
||||||
"processing": "Verarbeitung"
|
"processing": "Verarbeitung",
|
||||||
|
"editing": "Bearbeitung"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
|
|
@ -55,7 +56,8 @@
|
||||||
"retry": "Erneut",
|
"retry": "Erneut",
|
||||||
"download": "Auf mein Gerät speichern",
|
"download": "Auf mein Gerät speichern",
|
||||||
"rename": "Umbenennen",
|
"rename": "Umbenennen",
|
||||||
"share": "Teilen"
|
"share": "Teilen",
|
||||||
|
"edit": "Bearbeiten / schneiden"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Die Warteschlange ist leer.",
|
"queue": "Die Warteschlange ist leer.",
|
||||||
|
|
@ -142,5 +144,6 @@
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"added": "Hinzugefügt",
|
"added": "Hinzugefügt",
|
||||||
"user": "Nutzer"
|
"user": "Nutzer"
|
||||||
}
|
},
|
||||||
|
"clipBadge": "Clip"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
frontend/src/i18n/locales/de/editor.json
Normal file
29
frontend/src/i18n/locales/de/editor.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"title": "Bearbeiten: „{{name}}“",
|
||||||
|
"playPause": "Wiedergabe / Pause",
|
||||||
|
"setIn": "Start hier",
|
||||||
|
"setOut": "Ende hier",
|
||||||
|
"in": "Start",
|
||||||
|
"out": "Ende",
|
||||||
|
"selected": "Auswahl",
|
||||||
|
"cropOff": "Zuschneiden",
|
||||||
|
"cropOn": "Zuschneiden aktiv",
|
||||||
|
"cropReencode": "Zuschneiden kodiert immer neu (bildgenau).",
|
||||||
|
"split": "Aufteilen in",
|
||||||
|
"accurate": {
|
||||||
|
"label": "Genauer Schnitt",
|
||||||
|
"hint": "Bildgenau. Kodiert den Clip neu."
|
||||||
|
},
|
||||||
|
"fast": {
|
||||||
|
"label": "Schneller Schnitt",
|
||||||
|
"hint": "Sofort, schneidet aber an Keyframes (±1–2 s)."
|
||||||
|
},
|
||||||
|
"nameLabel": "Clip-Name (optional)",
|
||||||
|
"willReencode": "Wird neu kodiert",
|
||||||
|
"willCopy": "Sofort (Stream-Kopie)",
|
||||||
|
"create": "Clip erstellen",
|
||||||
|
"createN": "{{count}} Clips erstellen",
|
||||||
|
"created_one": "{{count}} Clip zu deinen Downloads hinzugefügt",
|
||||||
|
"created_other": "{{count}} Clips zu deinen Downloads hinzugefügt",
|
||||||
|
"failed": "Clip konnte nicht erstellt werden."
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
"thumbnail": "Embedding thumbnail",
|
"thumbnail": "Embedding thumbnail",
|
||||||
"sponsorblock": "Removing sponsors",
|
"sponsorblock": "Removing sponsors",
|
||||||
"metadata": "Writing metadata",
|
"metadata": "Writing metadata",
|
||||||
"processing": "Processing"
|
"processing": "Processing",
|
||||||
|
"editing": "Editing"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
|
|
@ -55,7 +56,8 @@
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"download": "Save to my device",
|
"download": "Save to my device",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"share": "Share"
|
"share": "Share",
|
||||||
|
"edit": "Edit / trim"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "Nothing in the queue.",
|
"queue": "Nothing in the queue.",
|
||||||
|
|
@ -142,5 +144,6 @@
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"added": "Added",
|
"added": "Added",
|
||||||
"user": "User"
|
"user": "User"
|
||||||
}
|
},
|
||||||
|
"clipBadge": "Clip"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
frontend/src/i18n/locales/en/editor.json
Normal file
29
frontend/src/i18n/locales/en/editor.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"title": "Edit “{{name}}”",
|
||||||
|
"playPause": "Play / pause",
|
||||||
|
"setIn": "Set start",
|
||||||
|
"setOut": "Set end",
|
||||||
|
"in": "Start",
|
||||||
|
"out": "End",
|
||||||
|
"selected": "Selection",
|
||||||
|
"cropOff": "Add crop",
|
||||||
|
"cropOn": "Cropping",
|
||||||
|
"cropReencode": "Cropping always re-encodes (frame-accurate).",
|
||||||
|
"split": "Split into",
|
||||||
|
"accurate": {
|
||||||
|
"label": "Precise cut",
|
||||||
|
"hint": "Frame-accurate. Re-encodes the clip."
|
||||||
|
},
|
||||||
|
"fast": {
|
||||||
|
"label": "Fast cut",
|
||||||
|
"hint": "Instant, but cuts snap to keyframes (±1–2s)."
|
||||||
|
},
|
||||||
|
"nameLabel": "Clip name (optional)",
|
||||||
|
"willReencode": "Will re-encode",
|
||||||
|
"willCopy": "Instant (stream copy)",
|
||||||
|
"create": "Create clip",
|
||||||
|
"createN": "Create {{count}} clips",
|
||||||
|
"created_one": "{{count}} clip added to your downloads",
|
||||||
|
"created_other": "{{count}} clips added to your downloads",
|
||||||
|
"failed": "Couldn’t create the clip."
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,8 @@
|
||||||
"thumbnail": "Bélyegkép beágyazása",
|
"thumbnail": "Bélyegkép beágyazása",
|
||||||
"sponsorblock": "Szponzorok eltávolítása",
|
"sponsorblock": "Szponzorok eltávolítása",
|
||||||
"metadata": "Metaadat írása",
|
"metadata": "Metaadat írása",
|
||||||
"processing": "Feldolgozás"
|
"processing": "Feldolgozás",
|
||||||
|
"editing": "Szerkesztés"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"pause": "Szünet",
|
"pause": "Szünet",
|
||||||
|
|
@ -55,7 +56,8 @@
|
||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"download": "Mentés a gépemre",
|
"download": "Mentés a gépemre",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"share": "Megosztás"
|
"share": "Megosztás",
|
||||||
|
"edit": "Szerkesztés / vágás"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"queue": "A sor üres.",
|
"queue": "A sor üres.",
|
||||||
|
|
@ -142,5 +144,6 @@
|
||||||
"status": "Állapot",
|
"status": "Állapot",
|
||||||
"added": "Hozzáadva",
|
"added": "Hozzáadva",
|
||||||
"user": "Felhasználó"
|
"user": "Felhasználó"
|
||||||
}
|
},
|
||||||
|
"clipBadge": "Klip"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
frontend/src/i18n/locales/hu/editor.json
Normal file
29
frontend/src/i18n/locales/hu/editor.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"title": "Szerkesztés: „{{name}}”",
|
||||||
|
"playPause": "Lejátszás / szünet",
|
||||||
|
"setIn": "Kezdet itt",
|
||||||
|
"setOut": "Vég itt",
|
||||||
|
"in": "Kezdet",
|
||||||
|
"out": "Vég",
|
||||||
|
"selected": "Kijelölés",
|
||||||
|
"cropOff": "Vágókeret",
|
||||||
|
"cropOn": "Vágókeret bekapcsolva",
|
||||||
|
"cropReencode": "A vágókeret mindig újrakódol (frame-pontos).",
|
||||||
|
"split": "Feldarabolás",
|
||||||
|
"accurate": {
|
||||||
|
"label": "Pontos vágás",
|
||||||
|
"hint": "Frame-pontos. Újrakódolja a klipet."
|
||||||
|
},
|
||||||
|
"fast": {
|
||||||
|
"label": "Gyors vágás",
|
||||||
|
"hint": "Azonnali, de kulcskockára ugrik (±1–2 mp)."
|
||||||
|
},
|
||||||
|
"nameLabel": "Klip neve (opcionális)",
|
||||||
|
"willReencode": "Újrakódolás lesz",
|
||||||
|
"willCopy": "Azonnali (másolás)",
|
||||||
|
"create": "Klip létrehozása",
|
||||||
|
"createN": "{{count}} klip létrehozása",
|
||||||
|
"created_one": "{{count}} klip a letöltésekhez adva",
|
||||||
|
"created_other": "{{count}} klip a letöltésekhez adva",
|
||||||
|
"failed": "A klip létrehozása nem sikerült."
|
||||||
|
}
|
||||||
|
|
@ -667,6 +667,22 @@ export type DownloadStatus =
|
||||||
| "error"
|
| "error"
|
||||||
| "canceled";
|
| "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 {
|
export interface DownloadJob {
|
||||||
id: number;
|
id: number;
|
||||||
status: DownloadStatus;
|
status: DownloadStatus;
|
||||||
|
|
@ -682,6 +698,9 @@ export interface DownloadJob {
|
||||||
profile_id: number | null;
|
profile_id: number | null;
|
||||||
spec: DownloadSpec;
|
spec: DownloadSpec;
|
||||||
display_name: string | null;
|
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;
|
can_download: boolean;
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
asset: DownloadAsset | null;
|
asset: DownloadAsset | null;
|
||||||
|
|
@ -1027,6 +1046,14 @@ export const api = {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> =>
|
}): Promise<DownloadJob> =>
|
||||||
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
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"),
|
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
||||||
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
||||||
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
||||||
|
|
@ -1051,6 +1078,13 @@ export const api = {
|
||||||
const a = getActiveAccount();
|
const a = getActiveAccount();
|
||||||
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
|
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
|
// admin
|
||||||
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue