From bed9f8cf8dd24dae9c67463e63a41d8b567d759a Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:52:27 +1000 Subject: [PATCH] feat(export): prepare edited videos for authenticated cloud sharing --- .../video-editor/cloud/CloudShareButton.tsx | 261 ++++++++++ .../export/exportRunnerSupport.ts | 1 + .../export/useExportDialogActions.ts | 86 ++-- .../video-editor/export/useExportRunner.ts | 53 +- .../video-editor/exportDimensions.test.ts | 17 + .../video-editor/exportDimensions.ts | 20 + .../video-editor/layout/EditorExportMenu.tsx | 480 ++++++++++-------- 7 files changed, 660 insertions(+), 258 deletions(-) create mode 100644 src/components/video-editor/cloud/CloudShareButton.tsx diff --git a/src/components/video-editor/cloud/CloudShareButton.tsx b/src/components/video-editor/cloud/CloudShareButton.tsx new file mode 100644 index 00000000..605c3e2a --- /dev/null +++ b/src/components/video-editor/cloud/CloudShareButton.tsx @@ -0,0 +1,261 @@ +import { Check, CloudArrowUp, Copy, ShareNetwork } from "@phosphor-icons/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "@/components/ui/toast"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const DEFAULT_CLOUD_ENDPOINT = import.meta.env.DEV + ? "http://localhost:8787/api/upload" + : "https://videos.recordly.dev/api/upload"; + +type Props = { + filePath?: string; + projectTitle: string; + prepareFile?: () => Promise; + onCancelPrepare?: () => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + hideTrigger?: boolean; + authToken?: string; +}; + +export function CloudShareButton({ + filePath, + projectTitle, + prepareFile, + onCancelPrepare, + open: controlledOpen, + onOpenChange, + hideTrigger = false, + authToken, +}: Props) { + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = useCallback( + (nextOpen: boolean) => { + onOpenChange?.(nextOpen); + if (controlledOpen === undefined) setInternalOpen(nextOpen); + }, + [controlledOpen, onOpenChange], + ); + const [uploadId, setUploadId] = useState(); + const [progress, setProgress] = useState(0); + const [uploading, setUploading] = useState(false); + const [phase, setPhase] = useState<"idle" | "preparing" | "uploading">("idle"); + const [error, setError] = useState(); + const [shareUrl, setShareUrl] = useState(); + const [copied, setCopied] = useState(false); + const [notes, setNotes] = useState(""); + const preparedFileRef = useRef(undefined); + const cancelRequestedRef = useRef(false); + + const discardPreparedFile = useCallback(() => { + const preparedPath = preparedFileRef.current; + preparedFileRef.current = undefined; + if (preparedPath) void window.electronAPI.discardExportedTemp(preparedPath); + }, []); + + useEffect(() => discardPreparedFile, [discardPreparedFile]); + + useEffect(() => { + return window.electronAPI.onCloudShareProgress((next) => { + setUploadId(next.uploadId); + setProgress( + next.totalBytes > 0 + ? Math.min(100, Math.round((next.uploadedBytes / next.totalBytes) * 100)) + : 0, + ); + }); + }, []); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && uploading) return; + setOpen(nextOpen); + if (nextOpen) { + setError(undefined); + setCopied(false); + } else { + discardPreparedFile(); + } + }, + [discardPreparedFile, setOpen, uploading], + ); + + const handleUpload = useCallback(async () => { + setUploading(true); + setProgress(0); + setPhase("preparing"); + cancelRequestedRef.current = false; + setError(undefined); + setShareUrl(undefined); + try { + if (!authToken) throw new Error("Sign in to Recordly before creating a shared link."); + let resolvedFilePath = filePath ?? preparedFileRef.current; + if (!resolvedFilePath) { + resolvedFilePath = await prepareFile?.(); + if (cancelRequestedRef.current) { + if (resolvedFilePath) + void window.electronAPI.discardExportedTemp(resolvedFilePath); + return; + } + if (!resolvedFilePath) + throw new Error("Could not prepare the current edit for sharing."); + preparedFileRef.current = resolvedFilePath; + } + const nextUploadId = crypto.randomUUID(); + setUploadId(nextUploadId); + setPhase("uploading"); + const result = await window.electronAPI.cloudShareUpload({ + filePath: resolvedFilePath, + endpoint: DEFAULT_CLOUD_ENDPOINT, + token: authToken, + title: projectTitle, + notes: notes.trim() || undefined, + uploadId: nextUploadId, + }); + if (!result.success || !result.shareUrl) { + if (!result.canceled) setError(result.error || "Cloud upload failed."); + return; + } + setProgress(100); + setShareUrl(result.shareUrl); + toast.success("Share link created"); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setUploading(false); + setPhase("idle"); + setUploadId(undefined); + } + }, [authToken, filePath, notes, prepareFile, projectTitle]); + + const handleCancel = useCallback(async () => { + cancelRequestedRef.current = true; + if (phase === "preparing") onCancelPrepare?.(); + if (uploadId) await window.electronAPI.cloudShareCancel(uploadId); + }, [onCancelPrepare, phase, uploadId]); + + const copyShareUrl = useCallback(async () => { + if (!shareUrl) return; + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + toast.success("Link copied"); + }, [shareUrl]); + + return ( + <> + {hideTrigger ? null : ( + + )} + + + + Share to the cloud + + Publish the current edit to a Recordly viewing and feedback page. No + download is required. Shared videos are prepared at up to 1080p. + + + + {shareUrl ? ( +
+
+ + Your video is ready to share. +
+
+ + +
+ +
+ ) : ( +
+
+ +