diff options
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
6 files changed, 434 insertions, 130 deletions
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 70eb69dd..21df9924 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -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... diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index fc4f677d..56437508 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -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> ); } diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx new file mode 100644 index 00000000..2cd33535 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/base-view.tsx @@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx new file mode 100644 index 00000000..4fe2cbd7 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 3bce1753..d33b4cce 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -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> diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index ca8e88c9..49eacbc7 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -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> ); } |
