mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
feat(export): prepare edited videos for authenticated cloud sharing
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user