summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel/views/captions.tsx
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/media-panel/views/captions.tsx
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/media-panel/views/captions.tsx')
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx303
1 files changed, 0 insertions, 303 deletions
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
deleted file mode 100644
index fbe5b418..00000000
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { PropertyGroup } from "../../properties-panel/property-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
-import { Language, LanguageSelect } from "@/components/language-select";
-import { useState, useRef, useEffect } from "react";
-import { extractTimelineAudio } from "@/lib/mediabunny-utils";
-import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-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, {
- ...DEFAULT_TEXT_ELEMENT,
- name: `Caption ${index + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65, // Larger for captions
- fontWeight: "bold", // Bold for captions
- } 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 h-full">
- <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>
- );
-}