mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging' into feat/timeline-return-to-start
This commit is contained in:
@@ -20,7 +20,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let ws = wavesurfer.current;
|
||||
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
|
||||
@@ -90,7 +90,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
const wsToDestroy = ws;
|
||||
// Detach from ref immediately
|
||||
wavesurfer.current = null;
|
||||
|
||||
|
||||
// Wait a tick to destroy so any pending operations can complete
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
@@ -111,13 +111,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
return () => {
|
||||
// Mark component as unmounted
|
||||
mounted = false;
|
||||
|
||||
|
||||
// Store reference to current wavesurfer instance
|
||||
const wsToDestroy = wavesurfer.current;
|
||||
|
||||
|
||||
// Immediately clear the ref to prevent accessing it after unmount
|
||||
wavesurfer.current = null;
|
||||
|
||||
|
||||
// If we have an instance to clean up, do it safely
|
||||
if (wsToDestroy) {
|
||||
// Delay destruction to avoid race conditions
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import Image from "next/image";
|
||||
|
||||
function TikTokGuide() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<Image
|
||||
src="/platform-guides/tiktok-blueprint.png"
|
||||
alt="TikTok layout guide"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGuideOverlay() {
|
||||
const { layoutGuide } = useEditorStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { TextView } from "./views/text";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { Captions } from "./views/captions";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
@@ -30,11 +31,7 @@ export function MediaPanel() {
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Captions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: <Captions />,
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Filters view coming soon...
|
||||
|
||||
@@ -2,122 +2,50 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||
const [isAtStart, setIsAtStart] = useState(true);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
});
|
||||
setIsAtEnd(true);
|
||||
setIsAtStart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStart = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
});
|
||||
setIsAtStart(true);
|
||||
setIsAtEnd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
scrollContainerRef.current;
|
||||
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
const isAtStartNow = scrollLeft <= 1;
|
||||
setIsAtEnd(isAtEndNow);
|
||||
setIsAtStart(isAtStartNow);
|
||||
}
|
||||
};
|
||||
|
||||
// We're using useEffect because we need to sync with external DOM scroll events
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ScrollButton
|
||||
direction="left"
|
||||
onClick={scrollToStart}
|
||||
isVisible={!isAtStart}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"
|
||||
>
|
||||
<div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75",
|
||||
activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground"
|
||||
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<tab.icon className="size-[1.1rem]!" />
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollButton
|
||||
direction="right"
|
||||
onClick={scrollToEnd}
|
||||
isVisible={!isAtEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
isVisible,
|
||||
}: {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-4! text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface BaseViewProps {
|
||||
children?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
tabs?: {
|
||||
value: string;
|
||||
label: string;
|
||||
content: React.ReactNode;
|
||||
}[];
|
||||
className?: string;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ViewContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className={`p-5 h-full ${className}`}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: BaseViewProps) {
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ViewContent>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { BaseView } from "./base-view";
|
||||
import { Language, LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/ffmpeg-utils";
|
||||
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
|
||||
export const languages: Language[] = [
|
||||
{ code: "US", name: "English" },
|
||||
{ code: "ES", name: "Spanish" },
|
||||
{ code: "IT", name: "Italian" },
|
||||
{ code: "FR", name: "French" },
|
||||
{ code: "DE", name: "German" },
|
||||
{ code: "PT", name: "Portuguese" },
|
||||
{ code: "RU", name: "Russian" },
|
||||
{ code: "JP", name: "Japanese" },
|
||||
{ code: "CN", name: "Chinese" },
|
||||
];
|
||||
|
||||
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedCountry, setSelectedCountry] = useState("auto");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
|
||||
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { insertTrackAt, addElementToTrack } = useTimelineStore();
|
||||
|
||||
// Check if user has already accepted privacy on mount
|
||||
useEffect(() => {
|
||||
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
|
||||
setHasAcceptedPrivacy(hasAccepted);
|
||||
}, []);
|
||||
|
||||
const handleGenerateTranscript = async () => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setProcessingStep("Extracting audio...");
|
||||
|
||||
const audioBlob = await extractTimelineAudio();
|
||||
|
||||
setProcessingStep("Encrypting audio...");
|
||||
|
||||
// Encrypt the audio with a random key (zero-knowledge)
|
||||
const audioBuffer = await audioBlob.arrayBuffer();
|
||||
const encryptionResult = await encryptWithRandomKey(audioBuffer);
|
||||
|
||||
// Convert encrypted data to blob for upload
|
||||
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
|
||||
|
||||
setProcessingStep("Uploading...");
|
||||
const uploadResponse = await fetch("/api/get-upload-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileExtension: "wav" }),
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const error = await uploadResponse.json();
|
||||
throw new Error(error.message || "Failed to get upload URL");
|
||||
}
|
||||
|
||||
const { uploadUrl, fileName } = await uploadResponse.json();
|
||||
|
||||
// Upload to R2
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: encryptedBlob,
|
||||
});
|
||||
|
||||
setProcessingStep("Transcribing...");
|
||||
|
||||
// Call Modal transcription API with encryption parameters
|
||||
const transcriptionResponse = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: fileName,
|
||||
language:
|
||||
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
|
||||
// Send the raw encryption key and IV (zero-knowledge)
|
||||
decryptionKey: arrayBufferToBase64(encryptionResult.key),
|
||||
iv: arrayBufferToBase64(encryptionResult.iv),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!transcriptionResponse.ok) {
|
||||
const error = await transcriptionResponse.json();
|
||||
throw new Error(error.message || "Transcription failed");
|
||||
}
|
||||
|
||||
const { text, segments } = await transcriptionResponse.json();
|
||||
|
||||
console.log("Transcription completed:", { text, segments });
|
||||
|
||||
const shortCaptions: Array<{
|
||||
text: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}> = [];
|
||||
|
||||
let globalEndTime = 0; // Track the end time of the last caption globally
|
||||
|
||||
segments.forEach((segment: any) => {
|
||||
const words = segment.text.trim().split(/\s+/);
|
||||
const segmentDuration = segment.end - segment.start;
|
||||
const wordsPerSecond = words.length / segmentDuration;
|
||||
|
||||
// Split into chunks of 2-4 words
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < words.length; i += 3) {
|
||||
chunks.push(words.slice(i, i + 3).join(" "));
|
||||
}
|
||||
|
||||
// Calculate timing for each chunk to place them sequentially
|
||||
let chunkStartTime = segment.start;
|
||||
chunks.forEach((chunk) => {
|
||||
const chunkWords = chunk.split(/\s+/).length;
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
|
||||
|
||||
let adjustedStartTime = chunkStartTime;
|
||||
|
||||
// Prevent overlapping: if this caption would start before the last one ends,
|
||||
// start it right after the last one ends
|
||||
if (adjustedStartTime < globalEndTime) {
|
||||
adjustedStartTime = globalEndTime;
|
||||
}
|
||||
|
||||
shortCaptions.push({
|
||||
text: chunk,
|
||||
startTime: adjustedStartTime,
|
||||
duration: chunkDuration,
|
||||
});
|
||||
|
||||
// Update global end time
|
||||
globalEndTime = adjustedStartTime + chunkDuration;
|
||||
|
||||
// Next chunk starts when this one ends (for within-segment timing)
|
||||
chunkStartTime += chunkDuration;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a single track for all captions
|
||||
const captionTrackId = insertTrackAt("text", 0);
|
||||
|
||||
// Add all caption elements to the same track
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
addElementToTrack(captionTrackId, {
|
||||
type: "text",
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: 65,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
textAlign: "center",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
} as TextElement);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ ${shortCaptions.length} short-form caption chunks added to timeline!`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Transcription failed:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProcessingStep("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView ref={containerRef} className="flex flex-col justify-between">
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
onSelect={setSelectedCountry}
|
||||
containerRef={containerRef}
|
||||
languages={languages}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (hasAcceptedPrivacy) {
|
||||
handleGenerateTranscript();
|
||||
} else {
|
||||
setShowPrivacyDialog(true);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Audio Processing Notice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
To generate captions, we need to process your timeline audio
|
||||
using speech-to-text technology.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Zero-knowledge encryption - we cannot decrypt your files
|
||||
even if we wanted to
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Encryption keys generated randomly in your browser, never
|
||||
stored anywhere
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Audio encrypted before upload - raw audio never leaves
|
||||
your device
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Everything permanently deleted within seconds after
|
||||
transcription
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>True zero-knowledge privacy:</strong> Encryption keys
|
||||
are generated randomly in your browser and never stored
|
||||
anywhere. It's cryptographically impossible for us, our cloud
|
||||
providers, or anyone else to decrypt your audio files.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPrivacyDialog(false)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
|
||||
setHasAcceptedPrivacy(true);
|
||||
setShowPrivacyDialog(false);
|
||||
handleGenerateTranscript();
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Continue & Generate Captions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</BaseView>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BaseView } from "./base-view";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
@@ -34,37 +32,37 @@ export function SettingsView() {
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="project-info" className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<ScrollArea className="flex-1">
|
||||
<TabsContent value="project-info" className="p-5 pt-0 mt-0">
|
||||
<ProjectInfoView />
|
||||
</TabsContent>
|
||||
<TabsContent value="background" className="p-4 pt-0">
|
||||
<BackgroundView />
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
<BaseView
|
||||
defaultTab="project-info"
|
||||
tabs={[
|
||||
{
|
||||
value: "project-info",
|
||||
label: "Project info",
|
||||
content: <ProjectInfoView />,
|
||||
},
|
||||
{
|
||||
value: "background",
|
||||
label: "Background",
|
||||
content: <BackgroundView />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const { activeProject, updateProjectFps } = useProjectStore();
|
||||
const { canvasPresets, setCanvasSize } = useEditorStore();
|
||||
const { activeProject, updateProjectFps, updateCanvasSize } =
|
||||
useProjectStore();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const { getDisplayName } = useAspectRatio();
|
||||
|
||||
const handleAspectRatioChange = (value: string) => {
|
||||
const preset = canvasPresets.find((p) => p.name === value);
|
||||
if (preset) {
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
updateCanvasSize(
|
||||
{ width: preset.width, height: preset.height },
|
||||
"preset"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,7 +257,7 @@ function BackgroundView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { BaseView } from "./base-view";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { type TextElement } from "@/types/timeline";
|
||||
@@ -28,7 +29,7 @@ const textData: TextElement = {
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<BaseView>
|
||||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
@@ -48,6 +49,6 @@ export function TextView() {
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</BaseView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,18 @@ import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
|
||||
import { TextElementDragState } from "@/types/editor";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { LayoutGuideOverlay } from "./layout-guide-overlay";
|
||||
import { Label } from "../ui/label";
|
||||
import { SocialsIcon } from "../icons";
|
||||
import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
|
||||
|
||||
interface ActiveElement {
|
||||
element: TimelineElement;
|
||||
@@ -26,8 +36,8 @@ interface ActiveElement {
|
||||
export function PreviewPanel() {
|
||||
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore();
|
||||
const { canvasSize } = useEditorStore();
|
||||
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewDimensions, setPreviewDimensions] = useState({
|
||||
@@ -35,7 +45,8 @@ export function PreviewPanel() {
|
||||
height: 0,
|
||||
});
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
|
||||
const [dragState, setDragState] = useState<TextElementDragState>({
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
@@ -223,7 +234,8 @@ export function PreviewPanel() {
|
||||
const getActiveElements = (): ActiveElement[] => {
|
||||
const activeElements: ActiveElement[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
// Iterate tracks from bottom to top so topmost track renders last (on top)
|
||||
[...tracks].reverse().forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
if (element.hidden) return;
|
||||
const elementStart = element.startTime;
|
||||
@@ -300,6 +312,7 @@ export function PreviewPanel() {
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
className="w-full h-full object-cover"
|
||||
trackMuted={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -343,7 +356,7 @@ export function PreviewPanel() {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute flex items-center justify-center cursor-grab"
|
||||
className="absolute cursor-grab"
|
||||
onMouseDown={(e) =>
|
||||
handleTextMouseDown(e, element, elementData.track.id)
|
||||
}
|
||||
@@ -364,7 +377,7 @@ export function PreviewPanel() {
|
||||
canvasSize.height) *
|
||||
100
|
||||
}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
|
||||
transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
|
||||
opacity: element.opacity,
|
||||
zIndex: 100 + index, // Text elements on top
|
||||
}}
|
||||
@@ -372,16 +385,16 @@ export function PreviewPanel() {
|
||||
<div
|
||||
className={fontClassName}
|
||||
style={{
|
||||
fontSize: `${element.fontSize}px`,
|
||||
fontSize: `${element.fontSize * scaleRatio}px`,
|
||||
color: element.color,
|
||||
backgroundColor: element.backgroundColor,
|
||||
textAlign: element.textAlign,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textDecoration: element.textDecoration,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "2px",
|
||||
whiteSpace: "pre-wrap",
|
||||
padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`,
|
||||
borderRadius: `${2 * scaleRatio}px`,
|
||||
whiteSpace: "nowrap",
|
||||
// Fallback for system fonts that don't have classes
|
||||
...(fontClassName === "" && { fontFamily: element.fontFamily }),
|
||||
}}
|
||||
@@ -423,6 +436,7 @@ export function PreviewPanel() {
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
trackMuted={element.muted || elementData.track.muted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -448,14 +462,18 @@ export function PreviewPanel() {
|
||||
// Audio elements (no visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div key={element.id} className="absolute inset-0">
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<AudioPlayer
|
||||
src={mediaItem.url!}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
trackMuted={elementData.track.muted}
|
||||
trackMuted={element.muted || elementData.track.muted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -496,13 +514,7 @@ export function PreviewPanel() {
|
||||
renderElement(elementData, index)
|
||||
)
|
||||
)}
|
||||
{activeProject?.backgroundType === "blur" &&
|
||||
blurBackgroundElements.length === 0 &&
|
||||
activeElements.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
|
||||
Add a video or image to use blur background
|
||||
</div>
|
||||
)}
|
||||
<LayoutGuideOverlay />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -758,13 +770,7 @@ function FullscreenPreview({
|
||||
renderElement(elementData, index)
|
||||
)
|
||||
)}
|
||||
{activeProject?.backgroundType === "blur" &&
|
||||
blurBackgroundElements.length === 0 &&
|
||||
activeElements.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
|
||||
Add a video or image to use blur background
|
||||
</div>
|
||||
)}
|
||||
<LayoutGuideOverlay />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-background">
|
||||
@@ -799,6 +805,7 @@ function PreviewToolbar({
|
||||
getTotalDuration: () => number;
|
||||
}) {
|
||||
const { isPlaying } = usePlaybackStore();
|
||||
const { layoutGuide, toggleLayoutGuide } = useEditorStore();
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
@@ -818,7 +825,7 @@ function PreviewToolbar({
|
||||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white"
|
||||
className="flex justify-end gap-2 h-auto pb-5 pr-5 pt-4 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -834,6 +841,54 @@ function PreviewToolbar({
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="h-auto p-0 mr-1"
|
||||
title="Toggle layout guide"
|
||||
>
|
||||
<SocialsIcon className="!size-6" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">Layout guide</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show platform-specific layout guides to help align your
|
||||
content with interface elements like profile pictures,
|
||||
usernames, and interaction buttons.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="none"
|
||||
checked={layoutGuide.platform === null}
|
||||
onCheckedChange={() =>
|
||||
toggleLayoutGuide(layoutGuide.platform || "tiktok")
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="none">None</Label>
|
||||
</div>
|
||||
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
|
||||
<div key={platform} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={platform}
|
||||
checked={layoutGuide.platform === platform}
|
||||
onCheckedChange={() =>
|
||||
toggleLayoutGuide(platform as PlatformLayout)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={platform}>{label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
|
||||
@@ -28,9 +28,7 @@ export function SnapIndicator({
|
||||
|
||||
// Track scroll position to lock snap indicator to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ import { SelectionBox } from "../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "../snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import type { DragData, TimelineTrack } from "@/types/timeline";
|
||||
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
@@ -169,11 +169,32 @@ export function Timeline() {
|
||||
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Only track mouse down on timeline background areas (not elements)
|
||||
const target = e.target as HTMLElement;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_mousedown: "START",
|
||||
target_class: target.className,
|
||||
target_parent_class: target.parentElement?.className,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
timeStamp: e.timeStamp,
|
||||
})
|
||||
);
|
||||
|
||||
const isTimelineBackground =
|
||||
!target.closest(".timeline-element") &&
|
||||
!playheadRef.current?.contains(target) &&
|
||||
!target.closest("[data-track-labels]");
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_mousedown: "CHECK",
|
||||
isTimelineBackground,
|
||||
hasTimelineElement: !!target.closest(".timeline-element"),
|
||||
hasPlayhead: !!playheadRef.current?.contains(target),
|
||||
hasTrackLabels: !!target.closest("[data-track-labels]"),
|
||||
})
|
||||
);
|
||||
|
||||
if (isTimelineBackground) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
@@ -181,12 +202,38 @@ export function Timeline() {
|
||||
downY: e.clientY,
|
||||
downTime: e.timeStamp,
|
||||
};
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_mousedown: "TRACKED",
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_mousedown: "IGNORED - not timeline background",
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Timeline content click to seek handler
|
||||
const handleTimelineContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "START",
|
||||
target: (e.target as HTMLElement).className,
|
||||
target_parent: (e.target as HTMLElement).parentElement?.className,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clickX: e.clientX,
|
||||
clickY: e.clientY,
|
||||
timeStamp: e.timeStamp,
|
||||
})
|
||||
);
|
||||
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
|
||||
// Reset mouse tracking
|
||||
@@ -201,8 +248,8 @@ export function Timeline() {
|
||||
if (!isMouseDown) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ignoredClickWithoutMouseDown: true,
|
||||
timeStamp: e.timeStamp,
|
||||
debug_click: "REJECTED - no mousedown",
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -216,11 +263,10 @@ export function Timeline() {
|
||||
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ignoredDragNotClick: true,
|
||||
debug_click: "REJECTED - movement too large",
|
||||
deltaX,
|
||||
deltaY,
|
||||
deltaTime,
|
||||
timeStamp: e.timeStamp,
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -228,27 +274,54 @@ export function Timeline() {
|
||||
|
||||
// Don't seek if this was a selection box operation
|
||||
if (isSelecting || justFinishedSelecting) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "REJECTED - selection operation",
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't seek if clicking on timeline elements, but still deselect
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "REJECTED - clicked timeline element",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't seek if clicking on playhead
|
||||
if (playheadRef.current?.contains(e.target as Node)) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "REJECTED - clicked playhead",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't seek if clicking on track labels
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "REJECTED - clicked track labels",
|
||||
})
|
||||
);
|
||||
clearSelectedElements();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear selected elements when clicking empty timeline area
|
||||
console.log(JSON.stringify({ clearingSelectedElements: true }));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "PROCEEDING - clearing elements",
|
||||
clearingSelectedElements: true,
|
||||
})
|
||||
);
|
||||
clearSelectedElements();
|
||||
|
||||
// Determine if we're clicking in ruler or tracks area
|
||||
@@ -256,24 +329,39 @@ export function Timeline() {
|
||||
"[data-ruler-area]"
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "CALCULATING POSITION",
|
||||
isRulerClick,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
target_element: (e.target as HTMLElement).tagName,
|
||||
target_class: (e.target as HTMLElement).className,
|
||||
})
|
||||
);
|
||||
|
||||
let mouseX: number;
|
||||
let scrollLeft = 0;
|
||||
|
||||
if (isRulerClick) {
|
||||
// Calculate based on ruler position
|
||||
const rulerContent = rulerScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
if (!rulerContent) return;
|
||||
const rulerContent = rulerScrollRef.current;
|
||||
if (!rulerContent) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
debug_click: "ERROR - no ruler container found",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rect = rulerContent.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
scrollLeft = rulerContent.scrollLeft;
|
||||
} else {
|
||||
// Calculate based on tracks content position
|
||||
const tracksContent = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
if (!tracksContent) return;
|
||||
const tracksContent = tracksScrollRef.current;
|
||||
if (!tracksContent) {
|
||||
return;
|
||||
}
|
||||
const rect = tracksContent.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
scrollLeft = tracksContent.scrollLeft;
|
||||
@@ -291,7 +379,6 @@ export function Timeline() {
|
||||
// Use frame snapping for timeline clicking
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
@@ -405,7 +492,21 @@ export function Timeline() {
|
||||
item.name === processedItem.name && item.url === processedItem.url
|
||||
);
|
||||
if (addedItem) {
|
||||
useTimelineStore.getState().addMediaToNewTrack(addedItem);
|
||||
const trackType: TrackType =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = useTimelineStore
|
||||
.getState()
|
||||
.insertTrackAt(trackType, 0);
|
||||
|
||||
useTimelineStore.getState().addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -428,15 +529,9 @@ export function Timeline() {
|
||||
|
||||
// --- Scroll synchronization effect ---
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const trackLabelsViewport = trackLabelsScrollRef.current;
|
||||
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export function TimelineElement({
|
||||
replaceElementMedia,
|
||||
rippleEditingEnabled,
|
||||
toggleElementHidden,
|
||||
toggleElementMuted,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
@@ -57,7 +58,7 @@ export function TimelineElement({
|
||||
element.type === "media"
|
||||
? mediaItems.find((item) => item.id === element.mediaId)
|
||||
: null;
|
||||
const isAudio = mediaItem?.type === "audio";
|
||||
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||
|
||||
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||
useTimelineElementResize({
|
||||
@@ -124,8 +125,12 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleElementHidden = (e: React.MouseEvent) => {
|
||||
const handleToggleElementContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (hasAudio && element.type === "media") {
|
||||
toggleElementMuted(track.id, element.id);
|
||||
return;
|
||||
}
|
||||
toggleElementHidden(track.id, element.id);
|
||||
};
|
||||
|
||||
@@ -246,6 +251,8 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const isMuted = element.type === "media" ? element.muted === true : false;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -279,9 +286,9 @@ export function TimelineElement({
|
||||
{renderElementContent()}
|
||||
</div>
|
||||
|
||||
{element.hidden && (
|
||||
{(hasAudio ? isMuted : element.hidden) && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
|
||||
{isAudio ? (
|
||||
{hasAudio ? (
|
||||
<VolumeX className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="h-6 w-6 text-white" />
|
||||
@@ -309,9 +316,9 @@ export function TimelineElement({
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleToggleElementHidden}>
|
||||
{isAudio ? (
|
||||
element.hidden ? (
|
||||
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||
{hasAudio ? (
|
||||
isMuted ? (
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
@@ -322,8 +329,8 @@ export function TimelineElement({
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
<span>
|
||||
{isAudio
|
||||
? element.hidden
|
||||
{hasAudio
|
||||
? isMuted
|
||||
? "Unmute"
|
||||
: "Mute"
|
||||
: element.hidden
|
||||
|
||||
@@ -51,9 +51,7 @@ export function TimelinePlayhead({
|
||||
|
||||
// Track scroll position to lock playhead to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
@@ -86,9 +84,7 @@ export function TimelinePlayhead({
|
||||
// Get the timeline content width and viewport width for right boundary
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const viewportWidth = tracksViewport?.clientWidth || 1000;
|
||||
|
||||
// Constrain playhead to never appear outside the timeline area
|
||||
@@ -126,7 +122,7 @@ export function TimelinePlayhead({
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-150"
|
||||
className="absolute pointer-events-auto z-40"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { TimelineElement } from "./timeline-element";
|
||||
import {
|
||||
TimelineTrack,
|
||||
sortTracksByOrder,
|
||||
ensureMainTrack,
|
||||
getMainTrack,
|
||||
canElementGoOnTrack,
|
||||
} from "@/types/timeline";
|
||||
@@ -16,6 +15,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
TrackType,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
snapTimeToFrame,
|
||||
@@ -689,8 +689,9 @@ export function TimelineTrackContent({
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container"
|
||||
@@ -1050,6 +1051,50 @@ export function TimelineTrackContent({
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
// External file drops
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
const { addMediaItem } = useMediaStore.getState();
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and add files to new timeline tracks at playhead position
|
||||
processMediaFiles(e.dataTransfer.files)
|
||||
.then(async (processedItems) => {
|
||||
for (const processedItem of processedItems) {
|
||||
await addMediaItem(activeProject.id, processedItem);
|
||||
const currentMediaItems = useMediaStore.getState().mediaItems;
|
||||
const addedItem = currentMediaItems.find(
|
||||
(item) =>
|
||||
item.name === processedItem.name &&
|
||||
item.url === processedItem.url
|
||||
);
|
||||
|
||||
if (addedItem) {
|
||||
const trackType: TrackType =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = insertTrackAt(trackType, 0);
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error processing external files:", error);
|
||||
toast.error("Failed to process dropped files");
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
|
||||
Reference in New Issue
Block a user