feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ExportFormat } from "@/lib/trimmer-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TrimmerExportSectionProps {
|
||||
exportFormat: ExportFormat;
|
||||
onExportFormatChange: (format: ExportFormat) => void;
|
||||
isProcessing: boolean;
|
||||
progress: number;
|
||||
ffmpegReady: boolean;
|
||||
hasVideo: boolean;
|
||||
outputUrl: string | null;
|
||||
onProcess: () => void;
|
||||
}
|
||||
|
||||
export function TrimmerExportSection({
|
||||
exportFormat,
|
||||
onExportFormatChange,
|
||||
isProcessing,
|
||||
progress,
|
||||
ffmpegReady,
|
||||
hasVideo,
|
||||
outputUrl,
|
||||
onProcess,
|
||||
}: TrimmerExportSectionProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold text-white">Export</h2>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["mp4", "webm"] as const).map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onExportFormatChange(format)}
|
||||
className={cn(
|
||||
"rounded-lg border px-4 py-2 text-sm font-medium uppercase transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50",
|
||||
exportFormat === format
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
|
||||
)}
|
||||
>
|
||||
{format}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
disabled={!hasVideo || isProcessing || !ffmpegReady}
|
||||
onClick={onProcess}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Processing…
|
||||
</>
|
||||
) : (
|
||||
"Trim & Crop"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!ffmpegReady && hasVideo ? (
|
||||
<p className="mt-2 text-center text-xs text-gray-400">
|
||||
Loading FFmpeg engine…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isProcessing ? (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 flex justify-between text-xs text-gray-400">
|
||||
<span>Progress</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-800">
|
||||
<div
|
||||
className="h-full w-full origin-left rounded-full bg-blue-600 transition-transform duration-200"
|
||||
style={{ transform: `scaleX(${progress / 100})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{outputUrl ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<video
|
||||
src={outputUrl}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full rounded-lg bg-black"
|
||||
/>
|
||||
<a
|
||||
href={outputUrl}
|
||||
download={`trimmed-${Date.now()}.${exportFormat}`}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
Download {exportFormat.toUpperCase()}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { useVideoThumbnails } from "@/hooks/useVideoThumbnails";
|
||||
import { formatTime } from "@/lib/trimmer-utils";
|
||||
|
||||
interface TrimmerStripProps {
|
||||
videoUrl: string | null;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
onTrimChange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
export function TrimmerStrip({
|
||||
videoUrl,
|
||||
duration,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
onTrimChange,
|
||||
}: TrimmerStripProps) {
|
||||
const thumbnails = useVideoThumbnails(videoUrl, duration);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const minGap = 0.5;
|
||||
|
||||
const startDrag = useCallback(
|
||||
(handle: "start" | "end", clientX: number) => {
|
||||
const track = trackRef.current;
|
||||
if (!track || duration <= 0) return;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const ratio = Math.min(
|
||||
1,
|
||||
Math.max(0, (event.clientX - rect.left) / rect.width)
|
||||
);
|
||||
const time = ratio * duration;
|
||||
|
||||
if (handle === "start") {
|
||||
onTrimChange(
|
||||
Math.min(time, trimEnd - minGap),
|
||||
trimEnd
|
||||
);
|
||||
} else {
|
||||
onTrimChange(
|
||||
trimStart,
|
||||
Math.max(time, trimStart + minGap)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
handleMove({ clientX } as MouseEvent);
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[duration, trimStart, trimEnd, onTrimChange]
|
||||
);
|
||||
|
||||
if (!videoUrl || duration <= 0) return null;
|
||||
|
||||
const startPct = (trimStart / duration) * 100;
|
||||
const endPct = (trimEnd / duration) * 100;
|
||||
const clipDuration = trimEnd - trimStart;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold text-white">Trim</h2>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-16 overflow-hidden rounded-lg bg-gray-900"
|
||||
>
|
||||
<div className="flex h-full w-full">
|
||||
{thumbnails.map((src, index) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={src}
|
||||
alt=""
|
||||
className="h-full flex-1 object-cover opacity-80"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-y-0 bg-black/55"
|
||||
style={{ left: 0, width: `${startPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 bg-black/55"
|
||||
style={{ left: `${endPct}%`, right: 0 }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 border-y-2 border-blue-500 bg-blue-500/10"
|
||||
style={{ left: `${startPct}%`, width: `${endPct - startPct}%` }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Trim start"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
startDrag("start", event.clientX);
|
||||
}}
|
||||
className="absolute top-0 z-10 h-full w-3 -translate-x-1/2 cursor-ew-resize rounded-sm bg-blue-600 hover:bg-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
style={{ left: `${startPct}%` }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Trim end"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
startDrag("end", event.clientX);
|
||||
}}
|
||||
className="absolute top-0 z-10 h-full w-3 -translate-x-1/2 cursor-ew-resize rounded-sm bg-blue-600 hover:bg-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
style={{ left: `${endPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-center text-sm tabular-nums text-gray-400">
|
||||
{formatTime(trimStart)} → {formatTime(trimEnd)} (
|
||||
<span className="font-medium text-white">
|
||||
{Math.round(clipDuration)}s
|
||||
</span>
|
||||
)
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Film, Upload } from "lucide-react";
|
||||
|
||||
import { formatFileSize } from "@/lib/trimmer-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TrimmerUploadZoneProps {
|
||||
uploadedFile: File | null;
|
||||
onFileSelect: (file: File) => void;
|
||||
}
|
||||
|
||||
export function TrimmerUploadZone({
|
||||
uploadedFile,
|
||||
onFileSelect,
|
||||
}: TrimmerUploadZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (file && file.type.startsWith("video/")) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
},
|
||||
[onFileSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFiles(event.target.files)}
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-12 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-950/40"
|
||||
: "border-gray-700 bg-gray-800 hover:border-blue-500/60 hover:bg-gray-800/80"
|
||||
)}
|
||||
>
|
||||
<Upload className="mb-3 h-10 w-10 text-blue-500" aria-hidden />
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Drag & drop a video, or click to browse
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">MP4, WebM, MOV and other video formats</p>
|
||||
</div>
|
||||
|
||||
{uploadedFile ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-800 px-4 py-3">
|
||||
<Film className="h-5 w-5 shrink-0 text-blue-500" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{uploadedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatFileSize(uploadedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
|
||||
import type { AspectRatioPreset, CropBox } from "@/lib/trimmer-types";
|
||||
import { getAspectRatioValue } from "@/lib/trimmer-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ASPECT_OPTIONS: { id: AspectRatioPreset; label: string }[] = [
|
||||
{ id: "free", label: "Free" },
|
||||
{ id: "16:9", label: "16:9" },
|
||||
{ id: "9:16", label: "9:16" },
|
||||
{ id: "1:1", label: "1:1" },
|
||||
{ id: "4:3", label: "4:3" },
|
||||
];
|
||||
|
||||
interface TrimmerVideoPreviewProps {
|
||||
videoUrl: string | null;
|
||||
aspectRatio: AspectRatioPreset;
|
||||
cropBox: CropBox;
|
||||
onCropChange: (crop: CropBox) => void;
|
||||
onAspectRatioChange: (preset: AspectRatioPreset) => void;
|
||||
onVideoMetadata: (duration: number, size: { width: number; height: number }) => void;
|
||||
onDisplaySize: (size: { width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
export function TrimmerVideoPreview({
|
||||
videoUrl,
|
||||
aspectRatio,
|
||||
cropBox,
|
||||
onCropChange,
|
||||
onAspectRatioChange,
|
||||
onVideoMetadata,
|
||||
onDisplaySize,
|
||||
}: TrimmerVideoPreviewProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const syncDisplaySize = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
onDisplaySize({
|
||||
width: video.clientWidth,
|
||||
height: video.clientHeight,
|
||||
});
|
||||
}, [onDisplaySize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoaded = () => {
|
||||
const w = video.clientWidth;
|
||||
const h = video.clientHeight;
|
||||
onVideoMetadata(video.duration, {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
});
|
||||
onCropChange({ x: 0, y: 0, w, h });
|
||||
onDisplaySize({ width: w, height: h });
|
||||
};
|
||||
|
||||
video.addEventListener("loadedmetadata", handleLoaded);
|
||||
window.addEventListener("resize", syncDisplaySize);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("loadedmetadata", handleLoaded);
|
||||
window.removeEventListener("resize", syncDisplaySize);
|
||||
};
|
||||
}, [videoUrl, onVideoMetadata, onCropChange, onDisplaySize, syncDisplaySize]);
|
||||
|
||||
if (!videoUrl) return null;
|
||||
|
||||
const lockRatio = getAspectRatioValue(aspectRatio);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold text-white">Preview & crop</h2>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{ASPECT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onAspectRatioChange(option.id)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
aspectRatio === option.id
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div ref={containerRef} className="relative mx-auto w-full max-w-3xl overflow-hidden rounded-lg bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
className="block h-auto w-full"
|
||||
onLoadedData={syncDisplaySize}
|
||||
/>
|
||||
<Rnd
|
||||
size={{ width: cropBox.w, height: cropBox.h }}
|
||||
position={{ x: cropBox.x, y: cropBox.y }}
|
||||
bounds="parent"
|
||||
lockAspectRatio={lockRatio}
|
||||
onDragStop={(_event, data) => {
|
||||
onCropChange({
|
||||
...cropBox,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
});
|
||||
}}
|
||||
onResizeStop={(_event, _dir, ref, _delta, position) => {
|
||||
onCropChange({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
w: ref.offsetWidth,
|
||||
h: ref.offsetHeight,
|
||||
});
|
||||
}}
|
||||
className="border-2 border-blue-500 bg-blue-500/20 shadow-lg"
|
||||
enableResizing={{
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: true,
|
||||
bottomRight: true,
|
||||
bottomLeft: true,
|
||||
topLeft: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user