feat(export): prepare edited videos for authenticated cloud sharing

This commit is contained in:
webadderall
2026-09-20 18:52:27 +10:00
parent 6f3f635b44
commit bed9f8cf8d
7 changed files with 660 additions and 258 deletions
@@ -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<string | undefined>;
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<string>();
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [phase, setPhase] = useState<"idle" | "preparing" | "uploading">("idle");
const [error, setError] = useState<string>();
const [shareUrl, setShareUrl] = useState<string>();
const [copied, setCopied] = useState(false);
const [notes, setNotes] = useState("");
const preparedFileRef = useRef<string | undefined>(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 : (
<Button
type="button"
variant="outline"
onClick={() => setOpen(true)}
disabled={!filePath && !prepareFile}
className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-lg border-foreground/10 bg-foreground/5 px-3 text-foreground hover:bg-foreground/10 disabled:opacity-40"
title="Create a shareable link"
>
<ShareNetwork className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">Create link</span>
</Button>
)}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md border-foreground/10 bg-editor-dialog text-foreground">
<DialogHeader>
<DialogTitle>Share to the cloud</DialogTitle>
<DialogDescription>
Publish the current edit to a Recordly viewing and feedback page. No
download is required. Shared videos are prepared at up to 1080p.
</DialogDescription>
</DialogHeader>
{shareUrl ? (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-400">
<Check className="h-5 w-5 shrink-0" />
Your video is ready to share.
</div>
<div className="flex gap-2">
<Input value={shareUrl} readOnly className="min-w-0" />
<Button type="button" onClick={copyShareUrl} className="shrink-0">
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<Button
type="button"
variant="outline"
onClick={() => void window.electronAPI.openExternalUrl(shareUrl)}
className="w-full"
>
Open share page
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cloud-share-notes">Notes</Label>
<textarea
id="cloud-share-notes"
placeholder="Add context, instructions, or a short summary for viewers…"
value={notes}
onChange={(event) =>
setNotes(event.target.value.slice(0, 2000))
}
disabled={uploading}
className="min-h-24 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-right text-[11px] text-muted-foreground">
{notes.length}/2000
</p>
</div>
{uploading ? (
<div className="space-y-2">
<div className="h-2 overflow-hidden rounded-full bg-foreground/10">
<div
className="h-full bg-[#2563EB] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{phase === "preparing"
? "Preparing the current edit…"
: `Uploading… ${progress}%`}
</p>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex justify-end gap-2">
{uploading ? (
<Button
type="button"
variant="outline"
onClick={() => void handleCancel()}
>
Cancel
</Button>
) : (
<Button type="button" onClick={() => void handleUpload()}>
<CloudArrowUp className="h-4 w-4" />
Publish and create link
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -29,6 +29,7 @@ export type ExportRunnerInput = {
effectiveShowCursor: boolean;
ensureSupportedMp4SourceDimensions: (
frameRate: ReturnType<typeof useExportSettings>["mp4FrameRate"],
options?: { capTo1080p?: boolean },
) => Promise<SupportedMp4Dimensions>;
captionSidecarPayload?: PendingExportSave["captionSidecar"];
experimentalNvidiaCudaExport: boolean;
@@ -1,6 +1,6 @@
import { type RefObject, useCallback } from "react";
import { toast } from "@/components/ui/toast";
import type { ExportSettings } from "@/lib/exporter";
import type { ExportFormat, ExportSettings } from "@/lib/exporter";
import { resolveExportStartSettings } from "../exportStartSettings";
import type { VideoPlaybackRef } from "../VideoPlayback";
import type { useExportSession } from "./useExportSession";
@@ -15,7 +15,10 @@ type UseExportDialogActionsInput = {
hasCaptionsForSidecar: boolean;
settings: ExportSettingsState;
session: ExportSession;
handleExport: (settings: ExportSettings) => void;
handleExport: (
settings: ExportSettings,
options?: { destination?: "download" | "share" },
) => Promise<string | undefined>;
showExportSuccessToast: (filePath: string) => void;
};
@@ -46,41 +49,56 @@ export function useExportDialogActions({
session.setExportError(null);
}, [videoPath, session]);
const resolveCurrentSettings = useCallback(
(exportFormat: ExportFormat = settings.exportFormat) => {
const video = videoPlaybackRef.current?.video;
if (!videoPath) {
toast.error("No video loaded");
return null;
}
if (!video) {
toast.error("Video not ready");
return null;
}
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
toast.error("Video metadata is still loading");
return null;
}
return resolveExportStartSettings({
sourceWidth: video.videoWidth,
sourceHeight: video.videoHeight,
exportFormat,
includeCaptionSidecar: hasCaptionsForSidecar && settings.includeCaptionSidecar,
exportEncodingMode: settings.exportEncodingMode,
exportQuality: settings.exportQuality,
mp4FrameRate: settings.mp4FrameRate,
exportBackendPreference: settings.exportBackendPreference,
exportPipelineModel: settings.exportPipelineModel,
gifFrameRate: settings.gifFrameRate,
gifLoop: settings.gifLoop,
gifSizePreset: settings.gifSizePreset,
});
},
[videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings],
);
const handleStartExportFromDropdown = useCallback(() => {
const video = videoPlaybackRef.current?.video;
if (!videoPath) {
toast.error("No video loaded");
return;
}
if (!video) {
toast.error("Video not ready");
return;
}
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
toast.error("Video metadata is still loading");
return;
}
const resolvedSettings = resolveExportStartSettings({
sourceWidth: video.videoWidth,
sourceHeight: video.videoHeight,
exportFormat: settings.exportFormat,
includeCaptionSidecar: hasCaptionsForSidecar && settings.includeCaptionSidecar,
exportEncodingMode: settings.exportEncodingMode,
exportQuality: settings.exportQuality,
mp4FrameRate: settings.mp4FrameRate,
exportBackendPreference: settings.exportBackendPreference,
exportPipelineModel: settings.exportPipelineModel,
gifFrameRate: settings.gifFrameRate,
gifLoop: settings.gifLoop,
gifSizePreset: settings.gifSizePreset,
});
const resolvedSettings = resolveCurrentSettings();
if (!resolvedSettings) return;
session.setExportError(null);
session.setExportedFilePath(undefined);
session.setShowExportDropdown(true);
handleExport(resolvedSettings);
}, [videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings, session, handleExport]);
void handleExport(resolvedSettings, { destination: "download" });
}, [resolveCurrentSettings, session, handleExport]);
const prepareExportForShare = useCallback(async () => {
const resolvedSettings = resolveCurrentSettings("mp4");
if (!resolvedSettings) return undefined;
session.setExportError(null);
session.setShowExportDropdown(false);
return handleExport(resolvedSettings, { destination: "share" });
}, [resolveCurrentSettings, session, handleExport]);
const handleCancelExport = useCallback(() => {
if (!session.isExporting) return;
@@ -102,7 +120,6 @@ export function useExportDialogActions({
session.setShowExportDropdown(false);
session.setExportProgress(null);
session.setExportError(null);
session.setExportedFilePath(undefined);
}, [session]);
const handleRetrySaveExport = useCallback(async () => {
@@ -161,6 +178,7 @@ export function useExportDialogActions({
return {
handleOpenExportDropdown,
handleStartExportFromDropdown,
prepareExportForShare,
handleCancelExport,
handleExportDropdownClose,
handleRetrySaveExport,
@@ -3,7 +3,7 @@ import { toast } from "@/components/ui/toast";
import { getMp4ExportBitrate } from "@/lib/exporter/exportBitrate";
import { DEFAULT_MP4_CODEC } from "@/lib/exporter/mp4Support";
import type { ExportSettings } from "@/lib/exporter/types";
import { calculateMp4ExportDimensions } from "../exportDimensions";
import { calculateMp4ExportDimensions, capMp4ShareDimensions } from "../exportDimensions";
import { resolveMp4ExportRouting } from "../mp4ExportRouting";
import { resolveMp4ExportSettings } from "../mp4ExportSettings";
import { createSmokeExportProgressSampler } from "../smokeExportProgress";
@@ -11,6 +11,7 @@ import { buildExportRenderOptions } from "./buildExportRenderOptions";
import {
type PendingExportSave,
saveExportBlob,
streamExportBlobToTempFile,
writeSmokeExportReport,
} from "./exportPersistence";
import {
@@ -25,7 +26,10 @@ export function useExportRunner(input: ExportRunnerInput) {
const showExportSuccessToast = useExportSuccessToast();
const handleExport = useCallback(
async (settings: ExportSettings) => {
async (
settings: ExportSettings,
options?: { destination?: "download" | "share" },
): Promise<string | undefined> => {
const {
videoPath,
videoPlaybackRef,
@@ -163,6 +167,12 @@ export function useExportRunner(input: ExportRunnerInput) {
if (result.success && result.blob) {
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
if (options?.destination === "share") {
const tempPath = await streamExportBlobToTempFile(result.blob, "gif");
if (!tempPath)
throw new Error("Could not prepare the GIF for sharing.");
return tempPath;
}
markExportAsSaving();
const { saveResult, pendingSave } = await saveExportBlob(
@@ -244,15 +254,25 @@ export function useExportRunner(input: ExportRunnerInput) {
experimentalNvidiaCudaExport,
nvidiaCudaExportAvailable,
});
const supportedSourceDimensions =
await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate);
const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(
selectedMp4FrameRate,
{
capTo1080p: options?.destination === "share",
},
);
if (exportWasCancelled()) return;
const requestedDimensions = calculateMp4ExportDimensions(
supportedSourceDimensions.width,
supportedSourceDimensions.height,
quality,
);
const { width: exportWidth, height: exportHeight } =
calculateMp4ExportDimensions(
supportedSourceDimensions.width,
supportedSourceDimensions.height,
quality,
);
options?.destination === "share"
? capMp4ShareDimensions(
requestedDimensions.width,
requestedDimensions.height,
)
: requestedDimensions;
const bitrate = getMp4ExportBitrate({
width: exportWidth,
height: exportHeight,
@@ -327,6 +347,17 @@ export function useExportRunner(input: ExportRunnerInput) {
if (result.success && (result.blob || result.tempFilePath)) {
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
if (options?.destination === "share") {
if (result.tempFilePath) return result.tempFilePath;
if (result.blob) {
const tempPath = await streamExportBlobToTempFile(
result.blob,
"mp4",
);
if (tempPath) return tempPath;
}
throw new Error("Could not prepare the video for sharing.");
}
const sidecarForThisExport =
settings.includeCaptionSidecar && captionSidecarPayload
? captionSidecarPayload
@@ -488,7 +519,7 @@ export function useExportRunner(input: ExportRunnerInput) {
}
setExportError(result.error || "Export failed");
showExportErrorToast(result.error || "Export failed");
keepExportDialogOpen = true;
keepExportDialogOpen = options?.destination !== "share";
if (smokeExportConfig.enabled) {
window.close();
return;
@@ -519,7 +550,7 @@ export function useExportRunner(input: ExportRunnerInput) {
}
setExportError(errorMessage);
showExportErrorToast(`Export failed: ${errorMessage}`);
keepExportDialogOpen = true;
keepExportDialogOpen = options?.destination !== "share";
if (smokeExportConfig.enabled) {
window.close();
}
@@ -2,9 +2,26 @@ import { describe, expect, it } from "vitest";
import {
calculateMp4ExportDimensions,
calculateMp4SourceDimensions,
capMp4ShareDimensions,
shouldDebounceMp4SupportProbe,
} from "./exportDimensions";
describe("capMp4ShareDimensions", () => {
it("caps landscape shares at 1080p without upscaling", () => {
expect(capMp4ShareDimensions(3840, 2160)).toEqual({ width: 1920, height: 1080 });
expect(capMp4ShareDimensions(1280, 720)).toEqual({ width: 1280, height: 720 });
});
it("caps portrait and square shares within equivalent 1080p bounds", () => {
expect(capMp4ShareDimensions(2160, 3840)).toEqual({ width: 1080, height: 1920 });
expect(capMp4ShareDimensions(2160, 2160)).toEqual({ width: 1080, height: 1080 });
});
it("keeps unusual aspect ratios and even dimensions", () => {
expect(capMp4ShareDimensions(3441, 1441)).toEqual({ width: 1920, height: 802 });
});
});
describe("calculateMp4SourceDimensions", () => {
it("keeps native exports at the source dimensions", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "native")).toEqual({
@@ -102,3 +102,23 @@ export function calculateMp4ExportDimensions(
height: normalizeEvenDimension(baseHeight * qualityScale),
};
}
const SHARED_VIDEO_LONG_SIDE_MAX = 1920;
const SHARED_VIDEO_SHORT_SIDE_MAX = 1080;
export function capMp4ShareDimensions(
width: number,
height: number,
): { width: number; height: number } {
const normalizedWidth = normalizeEvenDimension(width);
const normalizedHeight = normalizeEvenDimension(height);
const landscape = normalizedWidth >= normalizedHeight;
const maxWidth = landscape ? SHARED_VIDEO_LONG_SIDE_MAX : SHARED_VIDEO_SHORT_SIDE_MAX;
const maxHeight = landscape ? SHARED_VIDEO_SHORT_SIDE_MAX : SHARED_VIDEO_LONG_SIDE_MAX;
const scale = Math.min(1, maxWidth / normalizedWidth, maxHeight / normalizedHeight);
return {
width: normalizeEvenDimension(normalizedWidth * scale),
height: normalizeEvenDimension(normalizedHeight * scale),
};
}
@@ -1,3 +1,6 @@
import { useEffect, useState } from "react";
import { CloudArrowUp } from "@phosphor-icons/react";
import { CloudShareButton } from "../cloud/CloudShareButton";
import { Card } from "@heroui/react";
import { ProgressBar } from "@heroui/react";
import { DownloadSimple as Download } from "@phosphor-icons/react";
@@ -28,9 +31,18 @@ type Props = {
handleStartExportFromDropdown: () => void;
revealExportedFile: () => void;
exportMessage: string | null;
projectTitle: string;
prepareExportForShare: () => Promise<string | undefined>;
onRequestShareSignIn: () => void;
shareRequestNonce: number;
authToken?: string;
};
export function EditorExportMenu(props: Props) {
const [shareOpen, setShareOpen] = useState(false);
useEffect(() => {
if (props.shareRequestNonce > 0) setShareOpen(true);
}, [props.shareRequestNonce]);
const {
t,
exportSettings,
@@ -93,227 +105,269 @@ export function EditorExportMenu(props: Props) {
} = exportStatus;
return (
<Popover
open={showExportDropdown}
onOpenChange={(open) => {
if (open) handleOpenExportDropdown();
else setShowExportDropdown(false);
}}
modal={true}
>
<PopoverTrigger asChild>
<Button
type="button"
className="inline-flex h-9 min-w-[104px] items-center justify-center gap-2 px-4.5"
>
<Download className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
{t("common.actions.export", "Export")}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
aria-label="Export"
align="end"
sideOffset={10}
className="w-[360px] p-0"
<>
<Popover
open={showExportDropdown}
onOpenChange={(open) => {
if (open) handleOpenExportDropdown();
else setShowExportDropdown(false);
}}
modal={true}
>
{isExporting ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.exporting", "Exporting")}
</p>
<p className="text-xs text-muted-foreground">
{t("editor.exportStatus.renderingFile", "Rendering your file.")}
</p>
{isLightningExportInProgress && exportMessage ? (
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground/70">
{exportMessage}
<PopoverTrigger asChild>
<Button
type="button"
className="inline-flex h-9 min-w-[104px] items-center justify-center gap-2 px-4.5"
>
<Download className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
{t("common.actions.export", "Export")}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
aria-label="Export"
align="end"
sideOffset={10}
className="w-[360px] p-0"
>
{isExporting ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.exporting", "Exporting")}
</p>
) : null}
{isLegacyExportInProgress ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Export too slow? Cancel and try Lightning export!
<p className="text-xs text-muted-foreground">
{t(
"editor.exportStatus.renderingFile",
"Rendering your file.",
)}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"
onClick={handleCancelExport}
className="h-8 px-3 text-xs"
>
{t("common.actions.cancel")}
</Button>
</div>
<ProgressBar
aria-label={t("editor.exportStatus.exporting", "Exporting")}
isIndeterminate={
isExportPreparing ||
isExportSaving ||
isExportFinalSaveIndeterminate
}
value={Math.min(
isRenderingAudio
? (exportProgress?.audioProgress ?? 0) * 100
: (exportFinalizingProgress ?? exportProgress?.percentage ?? 8),
100,
)}
>
<ProgressBar.Track>
<ProgressBar.Fill />
</ProgressBar.Track>
</ProgressBar>
<p className="mt-2 text-xs text-muted-foreground">{exportPercentLabel}</p>
{isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t(
"editor.export.processingAudioEdits",
"Processing audio with speed/overlay edits",
)}
</p>
) : exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{exportRenderSpeedLabel}
</p>
) : null}
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</p>
) : null}
{exportNativeSkipLabel ? (
<p className="mt-1 text-[11px] text-amber-500/80">
{exportNativeSkipLabel}
</p>
) : null}
</Card>
) : exportError ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.issue", "Export issue")}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</p>
) : null}
<p className="mt-1 select-text whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
{exportError}
</p>
<div className="mt-4 flex gap-2">
<Button
type="button"
variant="outline"
className="h-8 text-xs"
onClick={async () => {
try {
await navigator.clipboard.writeText(exportError);
toast.success(
t("editor.exportStatus.errorCopied", "Error copied"),
);
} catch {
toast.error(
t(
"editor.exportStatus.errorCopyFailed",
"Couldn't copy. Select the error text and copy it manually.",
),
);
}
}}
>
{t("editor.exportStatus.copyError", "Copy error")}
</Button>
{hasPendingExportSave ? (
{isLightningExportInProgress && exportMessage ? (
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground/70">
{exportMessage}
</p>
) : null}
{isLegacyExportInProgress ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Export too slow? Cancel and try Lightning export!
</p>
) : null}
</div>
<Button
type="button"
onClick={handleRetrySaveExport}
variant="outline"
onClick={handleCancelExport}
className="h-8 px-3 text-xs"
>
{t("common.actions.cancel")}
</Button>
</div>
<ProgressBar
aria-label={t("editor.exportStatus.exporting", "Exporting")}
isIndeterminate={
isExportPreparing ||
isExportSaving ||
isExportFinalSaveIndeterminate
}
value={Math.min(
isRenderingAudio
? (exportProgress?.audioProgress ?? 0) * 100
: (exportFinalizingProgress ??
exportProgress?.percentage ??
8),
100,
)}
>
<ProgressBar.Track>
<ProgressBar.Fill />
</ProgressBar.Track>
</ProgressBar>
<p className="mt-2 text-xs text-muted-foreground">
{exportPercentLabel}
</p>
{isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{t(
"editor.export.processingAudioEdits",
"Processing audio with speed/overlay edits",
)}
</p>
) : exportRenderSpeedLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
{exportRenderSpeedLabel}
</p>
) : null}
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</p>
) : null}
{exportNativeSkipLabel ? (
<p className="mt-1 text-[11px] text-amber-500/80">
{exportNativeSkipLabel}
</p>
) : null}
</Card>
) : exportError ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.issue", "Export issue")}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</p>
) : null}
<p className="mt-1 select-text whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
{exportError}
</p>
<div className="mt-4 flex gap-2">
<Button
type="button"
variant="outline"
className="h-8 text-xs"
onClick={async () => {
try {
await navigator.clipboard.writeText(exportError);
toast.success(
t(
"editor.exportStatus.errorCopied",
"Error copied",
),
);
} catch {
toast.error(
t(
"editor.exportStatus.errorCopyFailed",
"Couldn't copy. Select the error text and copy it manually.",
),
);
}
}}
>
{t("editor.exportStatus.copyError", "Copy error")}
</Button>
{hasPendingExportSave ? (
<Button
type="button"
onClick={handleRetrySaveExport}
className="h-8 flex-1 text-xs"
>
{t("editor.actions.saveAgain", "Save Again")}
</Button>
) : null}
<Button
type="button"
variant="outline"
onClick={handleExportDropdownClose}
className="h-8 flex-1 text-xs"
>
{t("editor.actions.saveAgain", "Save Again")}
{t("common.actions.close", "Close")}
</Button>
) : null}
<Button
type="button"
variant="outline"
onClick={handleExportDropdownClose}
className="h-8 flex-1 text-xs"
>
{t("common.actions.close", "Close")}
</Button>
</div>
</Card>
) : exportedFilePath ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.complete", "Export complete")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"editor.exportStatus.savedSuccessfully",
"Your file was saved successfully.",
)}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</div>
</Card>
) : exportedFilePath ? (
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.complete", "Export complete")}
</p>
) : null}
<p className="mt-3 truncate text-xs text-muted-foreground/70">
{exportedFilePath.split(/[\\/]/).pop()}
</p>
<div className="mt-4 flex gap-2">
<Button
type="button"
onClick={revealExportedFile}
className="h-8 flex-1 text-xs"
>
{t("editor.actions.showInFolder", "Show In Folder")}
</Button>
<Button
type="button"
variant="outline"
onClick={handleExportDropdownClose}
className="h-8 flex-1 text-xs"
>
Done
</Button>
</div>
</Card>
) : (
<ExportSettingsMenu
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
exportEncodingMode={exportEncodingMode}
onExportEncodingModeChange={setExportEncodingMode}
mp4FrameRate={mp4FrameRate}
onMp4FrameRateChange={setMp4FrameRate}
exportPipelineModel={exportPipelineModel}
experimentalNvidiaCudaExport={
experimentalNvidiaCudaExport && nvidiaCudaExportAvailable
}
onExperimentalNvidiaCudaExportChange={setExperimentalNvidiaCudaExport}
nvidiaCudaExportAvailable={nvidiaCudaExportAvailable}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"}
includeCaptionSidecar={includeCaptionSidecar}
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
mp4OutputDimensions={mp4OutputDimensions}
gifOutputDimensions={gifOutputDimensions}
onExport={handleStartExportFromDropdown}
className="rounded-none bg-transparent p-5 shadow-none"
/>
)}
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"editor.exportStatus.savedSuccessfully",
"Your file was saved successfully.",
)}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Path: {exportRuntimeLabel}
</p>
) : null}
<p className="mt-3 truncate text-xs text-muted-foreground/70">
{exportedFilePath.split(/[\\/]/).pop()}
</p>
<div className="mt-4 flex gap-2">
<Button
type="button"
onClick={revealExportedFile}
className="h-8 flex-1 text-xs"
>
{t("editor.actions.showInFolder", "Show In Folder")}
</Button>
<Button
type="button"
variant="outline"
onClick={handleExportDropdownClose}
className="h-8 flex-1 text-xs"
>
Done
</Button>
</div>
</Card>
) : (
<>
<ExportSettingsMenu
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
exportEncodingMode={exportEncodingMode}
onExportEncodingModeChange={setExportEncodingMode}
mp4FrameRate={mp4FrameRate}
onMp4FrameRateChange={setMp4FrameRate}
exportPipelineModel={exportPipelineModel}
experimentalNvidiaCudaExport={
experimentalNvidiaCudaExport && nvidiaCudaExportAvailable
}
onExperimentalNvidiaCudaExportChange={
setExperimentalNvidiaCudaExport
}
nvidiaCudaExportAvailable={nvidiaCudaExportAvailable}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
showCaptionSidecarOption={
hasCaptionsForSidecar && exportFormat === "mp4"
}
includeCaptionSidecar={includeCaptionSidecar}
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
mp4OutputDimensions={mp4OutputDimensions}
gifOutputDimensions={gifOutputDimensions}
onExport={handleStartExportFromDropdown}
className="rounded-none bg-transparent p-5 shadow-none"
/>
<div className="px-5 pb-5">
<Button
variant="secondary"
className="w-full"
onClick={() => {
setShowExportDropdown(false);
props.onRequestShareSignIn();
}}
>
<CloudArrowUp className="size-4" />
Create share link
</Button>
</div>
</>
)}
</PopoverContent>
</Popover>
{shareOpen && (
<CloudShareButton
hideTrigger
open={shareOpen}
onOpenChange={setShareOpen}
projectTitle={props.projectTitle}
prepareFile={props.prepareExportForShare}
onCancelPrepare={handleCancelExport}
authToken={props.authToken}
/>
)}
</>
);
}