diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx
index 2fe4a89e..f0a1a8a4 100644
--- a/src/components/ComparisonPreview.tsx
+++ b/src/components/ComparisonPreview.tsx
@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
"use client";
-import { useEffect, useRef, useState, useCallback, RefObject } from "react";
+import { useEffect, useRef, useState, useCallback, useMemo, RefObject } from "react";
import { EditRecipe } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
@@ -19,6 +19,15 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef(null);
+ const aspectStyle = useMemo(() => {
+ if (!recipe) return undefined;
+ const preset = recipe.preset === "custom"
+ ? { width: recipe.customWidth, height: recipe.customHeight }
+ : getPresetById(recipe.preset);
+ if (!preset) return undefined;
+ return { aspectRatio: `${preset.width} / ${preset.height}` };
+ }, [recipe]);
+
// Calculate overlay for the right (reframed) side
const overlay = (() => {
if (!recipe) return null;
@@ -166,7 +175,8 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
return (
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx
index f9f2127e..c33c04c6 100644
--- a/src/components/FileUpload.tsx
+++ b/src/components/FileUpload.tsx
@@ -12,6 +12,7 @@ interface Props {
currentFile: File | null;
fileError: string;
duration: number;
+ isLoading?: boolean;
}
export default function FileUpload({
@@ -19,6 +20,7 @@ export default function FileUpload({
currentFile,
fileError,
duration,
+ isLoading = false,
}: Props) {
const inputRef = useRef(null);
@@ -259,6 +261,24 @@ export default function FileUpload({
/>
);
+
+ // ── Loading state ──
+ const LoadingState = () => (
+
+
+
+
+ Processing Video
+
+
+ Extracting dimensions, aspect ratio, and metadata...
+
+
+
+ );
return (
<>
@@ -305,7 +325,7 @@ export default function FileUpload({
{warning}
)}
- {currentFile ? : }
+ {isLoading ? : currentFile ? : }
>
);
diff --git a/src/components/SubtitleControls.tsx b/src/components/SubtitleControls.tsx
new file mode 100644
index 00000000..cec145c2
--- /dev/null
+++ b/src/components/SubtitleControls.tsx
@@ -0,0 +1,366 @@
+"use client";
+
+import { EditRecipe } from "@/lib/types";
+import { SubtitleItem } from "@/lib/subtitles";
+import { Upload, Trash2, Sliders, Type, Palette, Layout, Search, Play } from "lucide-react";
+import { useMemo, useState, useRef } from "react";
+import { getPresetById } from "@/lib/presets";
+
+interface SubtitleControlsProps {
+ recipe: EditRecipe;
+ onChange: (patch: Partial) => void;
+ subtitleFile: File | null;
+ parsedSubtitles: SubtitleItem[] | null;
+ onSubtitleSelect: (file: File) => void;
+ onClearSubtitles: () => void;
+ onSeek: (time: number) => void;
+}
+
+const FONTS = [
+ { value: "Inter", label: "Inter" },
+ { value: "Outfit", label: "Outfit" },
+ { value: "Roboto", label: "Roboto" },
+ { value: "Arial", label: "Arial (Standard)" },
+ { value: "Courier New", label: "Courier New" },
+ { value: "Times New Roman", label: "Times New Roman" },
+ { value: "system-ui", label: "System UI" },
+];
+
+const PRESET_COLORS = [
+ { hex: "#ffffff", label: "White" },
+ { hex: "#ffff00", label: "Yellow" },
+ { hex: "#00ffff", label: "Cyan" },
+ { hex: "#00ff00", label: "Green" },
+ { hex: "#ff00ff", label: "Magenta" },
+ { hex: "#000000", label: "Black" },
+];
+
+const STYLES = [
+ { value: "none", label: "Plain Text" },
+ { value: "outline", label: "Black Outline" },
+ { value: "box", label: "Semi-Transparent Box" },
+ { value: "shadow", label: "Soft Shadow" },
+];
+
+export default function SubtitleControls({
+ recipe,
+ onChange,
+ subtitleFile,
+ parsedSubtitles,
+ onSubtitleSelect,
+ onClearSubtitles,
+ onSeek,
+}: SubtitleControlsProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const fileInputRef = useRef(null);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ onSubtitleSelect(file);
+ }
+ };
+
+ const filteredSubtitles = useMemo(() => {
+ if (!parsedSubtitles) return [];
+ if (!searchQuery.trim()) return parsedSubtitles;
+ const query = searchQuery.toLowerCase();
+ return parsedSubtitles.filter((sub) => sub.text.toLowerCase().includes(query));
+ }, [parsedSubtitles, searchQuery]);
+
+ const formatTimestamp = (sec: number) => {
+ const min = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ const ms = Math.floor((sec % 1) * 1000);
+ return `${String(min).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
+ };
+
+ return (
+
+ {/* File Upload / Clear */}
+
+ {!subtitleFile ? (
+
+
+
+
+ ) : (
+
+
+
+ {subtitleFile.name}
+
+
+ {parsedSubtitles?.length ?? 0} subtitle segments loaded
+
+
+
+
+ )}
+
+
+ {subtitleFile && (
+
+ {/* Typography Settings */}
+
+
+
+ Typography
+
+
+ {/* Font Family */}
+
+
+
+
+
+ {/* Font Size */}
+
+
+
+
+ {recipe.subtitleSize}px
+
+
+
+
onChange({ subtitleSize: Number(e.target.value) })}
+ className="flex-1 accent-film-600 cursor-pointer"
+ />
+
+ {(["Small", "Medium", "Large"] as const).map((label, idx) => {
+ const size = idx === 0 ? 24 : idx === 1 ? 36 : 48;
+ const isActive = recipe.subtitleSize === size;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {/* Color Settings */}
+
+
+
+ Colors
+
+
+ {/* Text Color */}
+
+
+
+ onChange({ subtitleColor: e.target.value })}
+ className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent"
+ />
+ onChange({ subtitleColor: e.target.value })}
+ placeholder="#ffffff"
+ className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500"
+ aria-label="Subtitle Hex color input"
+ />
+
+
+ {/* Curated color buttons */}
+
+ {PRESET_COLORS.map((color) => (
+
+ ))}
+
+
+
+
+ {/* Style & Box Settings */}
+
+
+
+ Background & Readability
+
+
+ {/* Background type select */}
+
+
+
+ {STYLES.map((style) => {
+ const isActive = recipe.subtitleBgType === style.value;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Background / outline color */}
+ {recipe.subtitleBgType !== "none" && (
+
+ )}
+
+
+ {/* Subtitles Navigation list */}
+
+
+
+
+ Segments
+
+
+ {filteredSubtitles.length} of {parsedSubtitles?.length ?? 0}
+
+
+
+ {/* Search Input */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-8 pr-2.5 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] placeholder-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-film-500"
+ />
+
+
+ {/* Subtitle segments list */}
+ {filteredSubtitles.length > 0 ? (
+
+ {filteredSubtitles.map((sub) => (
+
+ ))}
+
+ ) : (
+
+ No matching captions found
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/TimelineEditor.tsx b/src/components/TimelineEditor.tsx
new file mode 100644
index 00000000..799bee60
--- /dev/null
+++ b/src/components/TimelineEditor.tsx
@@ -0,0 +1,704 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useRef, useState, useCallback, useMemo } from "react";
+import { EditRecipe } from "@/lib/types";
+import { cn, formatDuration } from "@/lib/utils";
+import { GripVertical } from "lucide-react";
+
+interface Thumbnail {
+ time: number;
+ dataUrl: string;
+}
+
+interface TimelineEditorProps {
+ videoSrc: string | null;
+ duration: number;
+ currentTime: number;
+ recipe: EditRecipe;
+ onChange: (patch: Partial) => void;
+ onSeek: (time: number) => void;
+}
+
+export default function TimelineEditor({
+ videoSrc,
+ duration,
+ currentTime,
+ recipe,
+ onChange,
+ onSeek,
+}: TimelineEditorProps) {
+ const [thumbnails, setThumbnails] = useState([]);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [progress, setProgress] = useState(0);
+
+ const trackRef = useRef(null);
+ const draggingRef = useRef<"start" | "end" | "clip" | "playhead" | null>(null);
+ const dragStartOffsetRef = useRef(0);
+ const dragStartClipRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
+
+ const lastRunIdRef = useRef(0);
+ const objectUrlsRef = useRef([]);
+ const offscreenVideoRef = useRef(null);
+
+ const effectiveTrimEnd = recipe.trimEnd ?? duration;
+ const clipLength = effectiveTrimEnd - recipe.trimStart;
+
+ // State for snapping guide
+ const [snapGuideTime, setSnapGuideTime] = useState(null);
+
+ const revokeAllObjectUrls = useCallback(() => {
+ objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
+ objectUrlsRef.current = [];
+ }, []);
+
+ const cancelThumbnailRun = useCallback(() => {
+ lastRunIdRef.current += 1;
+ }, []);
+
+ // Determine snap intervals
+ const snapStep = useMemo(() => {
+ if (duration <= 15) return 1;
+ if (duration <= 60) return 2;
+ if (duration <= 180) return 5;
+ return 10;
+ }, [duration]);
+
+ // ── Thumbnail Generation ──
+ const generateThumbnails = useCallback(async () => {
+ if (!videoSrc || duration <= 0) return;
+
+ const runId = ++lastRunIdRef.current;
+ setIsGenerating(true);
+ revokeAllObjectUrls();
+ setThumbnails([]);
+ setProgress(0);
+
+ const video = document.createElement("video");
+ offscreenVideoRef.current = video;
+
+ try {
+ video.src = videoSrc;
+ video.crossOrigin = "anonymous";
+ video.muted = true;
+ video.preload = "auto";
+
+ await new Promise((resolve, reject) => {
+ video.onloadedmetadata = () => resolve();
+ video.onerror = () => reject(new Error("Video load failed"));
+ video.load();
+ });
+
+ if (lastRunIdRef.current !== runId) return;
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const thumbW = 160;
+ const thumbH = 90;
+ canvas.width = thumbW;
+ canvas.height = thumbH;
+
+ // Calculate how many frames to extract to fit perfectly on screen
+ const times: number[] = [];
+ const interval = Math.max(0.5, duration / 12); // extract up to 12 frames
+ for (let t = 0; t <= duration; t += interval) {
+ times.push(Math.min(t, duration - 0.1));
+ }
+ if ((times[times.length - 1] ?? 0) < duration - 0.2) {
+ times.push(duration - 0.1);
+ }
+
+ const captured: Thumbnail[] = [];
+
+ for (let i = 0; i < times.length; i++) {
+ if (lastRunIdRef.current !== runId) break;
+
+ const time = times[i] ?? 0;
+ await new Promise((resolve) => {
+ const onSeeked = async () => {
+ video.removeEventListener("seeked", onSeeked);
+
+ if (lastRunIdRef.current !== runId) {
+ resolve();
+ return;
+ }
+
+ ctx.drawImage(video, 0, 0, thumbW, thumbH);
+
+ try {
+ const blob = await new Promise((blobResolve) => {
+ canvas.toBlob((b) => blobResolve(b), "image/jpeg", 0.6);
+ });
+ if (blob && lastRunIdRef.current === runId) {
+ const url = URL.createObjectURL(blob);
+ objectUrlsRef.current.push(url);
+ captured.push({ time, dataUrl: url });
+ setThumbnails([...captured]);
+ }
+ } catch (err) {
+ console.error("Failed to generate thumbnail blob", err);
+ }
+
+ setProgress(Math.round(((i + 1) / times.length) * 100));
+ resolve();
+ };
+ video.addEventListener("seeked", onSeeked);
+ video.currentTime = time;
+ });
+ }
+
+ if (lastRunIdRef.current === runId) {
+ setIsGenerating(false);
+ }
+ } finally {
+ video.src = "";
+ if (offscreenVideoRef.current === video) {
+ offscreenVideoRef.current = null;
+ }
+ }
+ }, [videoSrc, duration, revokeAllObjectUrls]);
+
+ useEffect(() => {
+ if (videoSrc && duration > 0) {
+ generateThumbnails();
+ }
+ return () => {
+ cancelThumbnailRun();
+ revokeAllObjectUrls();
+ };
+ }, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]);
+
+ // ── Drag & Drop Interactions ──
+ const xToSeconds = useCallback((clientX: number) => {
+ const track = trackRef.current;
+ if (!track || duration <= 0) return 0;
+ const { left, width } = track.getBoundingClientRect();
+ const ratio = Math.max(0, Math.min(1, (clientX - left) / width));
+ return ratio * duration;
+ }, [duration]);
+
+ // Snapping calculations
+ const calculateSnap = useCallback((seconds: number, threshold = 0.25) => {
+ // Candidates for snapping: start (0), end (duration), and multiples of snapStep
+ const candidates: number[] = [0, duration];
+ for (let t = snapStep; t < duration; t += snapStep) {
+ candidates.push(t);
+ }
+
+ let closestTime = seconds;
+ let minDistance = Infinity;
+
+ candidates.forEach((cand) => {
+ const dist = Math.abs(seconds - cand);
+ if (dist < minDistance && dist <= threshold) {
+ minDistance = dist;
+ closestTime = cand;
+ }
+ });
+
+ return {
+ time: closestTime,
+ snapped: minDistance !== Infinity && closestTime !== seconds,
+ };
+ }, [duration, snapStep]);
+
+ const handleDrag = useCallback((clientX: number) => {
+ if (!draggingRef.current || duration <= 0) return;
+
+ const currentSeconds = xToSeconds(clientX);
+
+ if (draggingRef.current === "playhead") {
+ const snap = calculateSnap(currentSeconds, 0.15);
+ onSeek(Math.max(0, Math.min(duration, snap.time)));
+ setSnapGuideTime(snap.snapped ? snap.time : null);
+ } else if (draggingRef.current === "start") {
+ const snap = calculateSnap(currentSeconds, 0.25);
+ const clamped = Math.max(0, Math.min(snap.time, effectiveTrimEnd - 0.1));
+ onChange({ trimStart: parseFloat(clamped.toFixed(2)) });
+ setSnapGuideTime(snap.snapped ? clamped : null);
+ } else if (draggingRef.current === "end") {
+ const snap = calculateSnap(currentSeconds, 0.25);
+ const clamped = Math.min(duration, Math.max(snap.time, recipe.trimStart + 0.1));
+ onChange({ trimEnd: parseFloat(clamped.toFixed(2)) });
+ setSnapGuideTime(snap.snapped ? clamped : null);
+ } else if (draggingRef.current === "clip") {
+ const deltaSeconds = currentSeconds - dragStartOffsetRef.current;
+ let newStart = dragStartClipRef.current.start + deltaSeconds;
+ let newEnd = dragStartClipRef.current.end + deltaSeconds;
+
+ // Handle boundaries
+ if (newStart < 0) {
+ newEnd -= newStart;
+ newStart = 0;
+ }
+ if (newEnd > duration) {
+ newStart -= (newEnd - duration);
+ newEnd = duration;
+ }
+
+ // Snap both sides together
+ const startSnap = calculateSnap(newStart, 0.25);
+ const endSnap = calculateSnap(newEnd, 0.25);
+
+ let activeSnapTime: number | null = null;
+ if (startSnap.snapped) {
+ const offset = startSnap.time - newStart;
+ newStart = startSnap.time;
+ newEnd = Math.min(duration, newEnd + offset);
+ activeSnapTime = startSnap.time;
+ } else if (endSnap.snapped) {
+ const offset = endSnap.time - newEnd;
+ newEnd = endSnap.time;
+ newStart = Math.max(0, newStart + offset);
+ activeSnapTime = endSnap.time;
+ }
+
+ onChange({
+ trimStart: parseFloat(newStart.toFixed(2)),
+ trimEnd: parseFloat(newEnd.toFixed(2)),
+ });
+ setSnapGuideTime(activeSnapTime);
+ }
+ }, [xToSeconds, duration, effectiveTrimEnd, recipe.trimStart, onChange, onSeek, calculateSnap]);
+
+ const handleMouseDown = (
+ e: React.MouseEvent,
+ type: "start" | "end" | "clip" | "playhead"
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+ draggingRef.current = type;
+
+ const clientX = e.clientX;
+ const currentSecs = xToSeconds(clientX);
+ dragStartOffsetRef.current = currentSecs;
+ dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd };
+
+ if (type === "playhead") {
+ handleDrag(clientX);
+ }
+ };
+
+ const handleTouchStart = (
+ e: React.TouchEvent,
+ type: "start" | "end" | "clip" | "playhead"
+ ) => {
+ const touch = e.touches[0];
+ if (!touch) return;
+ e.stopPropagation();
+ draggingRef.current = type;
+
+ const clientX = touch.clientX;
+ const currentSecs = xToSeconds(clientX);
+ dragStartOffsetRef.current = currentSecs;
+ dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd };
+
+ if (type === "playhead") {
+ handleDrag(clientX);
+ }
+ };
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent | TouchEvent) => {
+ if (!draggingRef.current) return;
+
+ let clientX: number;
+ if ("touches" in e) {
+ const touch = e.touches[0];
+ if (!touch) return;
+ clientX = touch.clientX;
+ } else {
+ clientX = e.clientX;
+ }
+
+ handleDrag(clientX);
+ };
+
+ const onUp = () => {
+ draggingRef.current = null;
+ setSnapGuideTime(null);
+ };
+
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ document.addEventListener("touchmove", onMove, { passive: true });
+ document.addEventListener("touchend", onUp);
+
+ return () => {
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ document.removeEventListener("touchmove", onMove);
+ document.removeEventListener("touchend", onUp);
+ };
+ }, [handleDrag]);
+
+ // Grid tick markers for the time ruler
+ const ticks = useMemo(() => {
+ const result: { time: number; label: string; xPct: number }[] = [];
+ const tickInterval = snapStep;
+ for (let t = 0; t <= duration; t += tickInterval) {
+ const minutes = Math.floor(t / 60);
+ const seconds = Math.floor(t % 60);
+ result.push({
+ time: t,
+ label: `${minutes}:${seconds.toString().padStart(2, "0")}`,
+ xPct: (t / duration) * 100,
+ });
+ }
+ return result;
+ }, [duration, snapStep]);
+
+ if (!videoSrc || duration <= 0) return null;
+
+ const playheadPct = (currentTime / duration) * 100;
+ const clipLeftPct = (recipe.trimStart / duration) * 100;
+ const clipRightPct = ((duration - effectiveTrimEnd) / duration) * 100;
+
+ return (
+
+ {/* ── Time Ruler ticks ── */}
+
handleMouseDown(e, "playhead")}
+ onTouchStart={(e) => handleTouchStart(e, "playhead")}
+ role="slider"
+ aria-label="Timeline time ruler"
+ aria-valuenow={currentTime}
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1));
+ if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1));
+ }}
+ >
+ {ticks.map((tick) => (
+
+ {tick.label}
+
+ ))}
+
+
+ {/* ── Visual Frame Track & Interactivity ── */}
+
+
+ {/* Continuous backdrop of generated thumbnails */}
+
+ {thumbnails.length === 0 && isGenerating && (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ )}
+ {thumbnails.length > 0 && (
+
+ {thumbnails.map((thumb) => (
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Dimmer overlays for regions out of trim bounds */}
+
+
+
+ {/* Draggable Active Clip Box */}
+
handleMouseDown(e, "clip")}
+ onTouchStart={(e) => handleTouchStart(e, "clip")}
+ role="button"
+ tabIndex={0}
+ aria-label="Active video clip range"
+ onKeyDown={(e) => {
+ if (e.key === "ArrowLeft") {
+ const newStart = Math.max(0, recipe.trimStart - 0.1);
+ const offset = recipe.trimStart - newStart;
+ onChange({ trimStart: parseFloat(newStart.toFixed(2)), trimEnd: parseFloat((effectiveTrimEnd - offset).toFixed(2)) });
+ }
+ if (e.key === "ArrowRight") {
+ const newEnd = Math.min(duration, effectiveTrimEnd + 0.1);
+ const offset = newEnd - effectiveTrimEnd;
+ onChange({ trimStart: parseFloat((recipe.trimStart + offset).toFixed(2)), trimEnd: parseFloat(newEnd.toFixed(2)) });
+ }
+ }}
+ >
+ {/* Trim Handles */}
+
handleMouseDown(e, "start")}
+ onTouchStart={(e) => handleTouchStart(e, "start")}
+ role="slider"
+ aria-label="Drag start trim handle"
+ aria-valuenow={recipe.trimStart}
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) });
+ if (e.key === "ArrowRight") onChange({ trimStart: Math.min(effectiveTrimEnd - 0.1, recipe.trimStart + 0.1) });
+ }}
+ >
+
+
+
+
handleMouseDown(e, "end")}
+ onTouchStart={(e) => handleTouchStart(e, "end")}
+ role="slider"
+ aria-label="Drag end trim handle"
+ aria-valuenow={effectiveTrimEnd}
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, effectiveTrimEnd - 0.1) });
+ if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, effectiveTrimEnd + 0.1) });
+ }}
+ >
+
+
+
+
+ {/* Snap Guide Line */}
+ {snapGuideTime !== null && (
+
+ )}
+
+ {/* Vertical Red Playhead Bar */}
+
handleMouseDown(e, "playhead")}
+ onTouchStart={(e) => handleTouchStart(e, "playhead")}
+ role="slider"
+ aria-label="Playhead scrubber"
+ aria-valuenow={currentTime}
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1));
+ if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1));
+ }}
+ >
+
+
+
+
+ {/* ── Metadata / Status Overlay ── */}
+
+
+ 🎬 Clip range: {recipe.trimStart.toFixed(1)}s – {effectiveTrimEnd.toFixed(1)}s
+
+
+ ⏰ Length: {formatDuration(clipLength)} of {formatDuration(duration)}
+
+
+
+
+
+ );
+}
diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx
index d48bdd69..27b48daa 100644
--- a/src/components/TrimControl.tsx
+++ b/src/components/TrimControl.tsx
@@ -177,64 +177,7 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
return (
- {duration > 0 && (
-
{
- if (dragging.current) return;
- const s = xToSeconds(e.clientX);
- onChange({ trimStart: s });
- }}
- onKeyDown={(e) => {
- if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) });
- if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) });
- }}
- >
-
-
-
{ dragging.current = "start"; }}
- onTouchStart={() => { dragging.current = "start"; }}
- onKeyDown={(e) => {
- if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) });
- if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) });
- }}
- />
-
{ dragging.current = "end"; }}
- onTouchStart={() => { dragging.current = "end"; }}
- onKeyDown={(e) => {
- if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, (recipe.trimEnd ?? duration) - 0.1) });
- if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, (recipe.trimEnd ?? duration) + 0.1) });
- }}
- />
-
- )}
+