summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx7
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx122
-rw-r--r--apps/web/src/components/editor/media-panel/views/base-view.tsx67
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx313
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx50
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx5
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>
);
}