diff --git a/.gitignore b/.gitignore index d538b67ae..ae63d5913 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ pnpm-lock.yaml native-deps* apps/storybook/storybook-static tauri.*.conf.json + +*.tsbuildinfo diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index ac0d9894c..90b2c1e0f 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -103,7 +103,9 @@ window {}: RequestedFormatType::AbsoluteHighestFrameRate, ); - let mut camera = Camera::new(camera_info.index().clone(), format).unwrap(); + let Ok(mut camera) = Camera::new(camera_info.index().clone(), format) else { + continue; + }; info.push(json!({ "index": camera_info.index().to_string(), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 868beb830..27a6be2b0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1157,7 +1157,6 @@ fn open_main_window(app: AppHandle) { #[derive(Serialize, Type, tauri_specta::Event, Debug, Clone)] pub struct UploadProgress { - stage: String, progress: f64, message: String, } @@ -1233,7 +1232,6 @@ async fn upload_exported_video( // Start upload progress UploadProgress { - stage: "uploading".to_string(), progress: 0.0, message: "Starting upload...".to_string(), } @@ -1265,7 +1263,6 @@ async fn upload_exported_video( Ok(uploaded_video) => { // Emit upload complete UploadProgress { - stage: "uploading".to_string(), progress: 1.0, message: "Upload complete!".to_string(), } diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 2a77eddd5..acd67f002 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -229,7 +229,6 @@ pub async fn upload_video( // Emit progress every chunk UploadProgress { - stage: "uploading".to_string(), progress: current / total_size, message: format!("{:.0}%", (current / total_size * 100.0)), } @@ -289,7 +288,6 @@ pub async fn upload_video( if response.status().is_success() { // Final progress update UploadProgress { - stage: "uploading".to_string(), progress: 1.0, message: "100%".to_string(), } diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 25ec6551e..7e3799593 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -1,9 +1,8 @@ import { Button } from "@cap/ui-solid"; import { cx } from "cva"; import { - Match, + Setter, Show, - Switch, batch, createEffect, createResource, @@ -15,14 +14,13 @@ import { type as ostype } from "@tauri-apps/plugin-os"; import { Tooltip } from "@kobalte/core"; import { Select as KSelect } from "@kobalte/core/select"; import { createMutation } from "@tanstack/solid-query"; -import { getRequestEvent } from "solid-js/web"; import { save } from "@tauri-apps/plugin-dialog"; -import { Channel } from "@tauri-apps/api/core"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; +import { createStore, produce } from "solid-js/store"; -import { type RenderProgress, commands, events } from "~/utils/tauri"; -import { FPS, useEditorContext } from "./context"; +import { commands, events, RenderProgress } from "~/utils/tauri"; +import { useEditorContext } from "./context"; import { authStore } from "~/store"; import { Dialog, @@ -32,14 +30,9 @@ import { PopperContent, topLeftAnimateClasses, } from "./ui"; -import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; -import { - type ProgressState, - progressState, - setProgressState, -} from "~/store/progress"; import Titlebar from "~/components/titlebar/Titlebar"; import { initializeTitlebar, setTitlebar } from "~/utils/titlebar-state"; +import { Channel } from "@tauri-apps/api/core"; type ResolutionOption = { label: string; @@ -67,9 +60,7 @@ export interface ExportEstimates { export function Header() { const currentWindow = getCurrentWindow(); - const { videoId, project, prettyName } = useEditorContext(); - const [showExportOptions, setShowExportOptions] = createSignal(false); const [selectedFps, setSelectedFps] = createSignal( Number(localStorage.getItem("cap-export-fps")) || 30 ); @@ -80,142 +71,17 @@ export function Header() { ) || RESOLUTION_OPTIONS[0] ); - const [exportEstimates] = createResource( - () => ({ - videoId, - resolution: { - x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width, - y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height, - }, - fps: selectedFps(), - }), - async (params) => { - return commands.getExportEstimates( - params.videoId, - params.resolution, - params.fps - ); - } - ); - - let unlistenTitlebar: UnlistenFn | undefined; - onMount(async () => { - unlistenTitlebar = await initializeTitlebar(); - }); - onCleanup(() => unlistenTitlebar?.()); - // Save settings when they change createEffect(() => { localStorage.setItem("cap-export-fps", selectedFps().toString()); localStorage.setItem("cap-export-resolution", selectedResolution().value); }); - createEffect(() => { - const state = progressState; - if (state === undefined || state.type === "idle") { - currentWindow.setProgressBar({ status: ProgressBarStatus.None }); - return; - } - - let percentage: number | undefined; - if (state.type === "saving") { - percentage = - state.stage === "rendering" - ? Math.min( - ((state.renderProgress || 0) / (state.totalFrames || 1)) * 100, - 100 - ) - : Math.min(state.progress || 0, 100); - } - - if (percentage) - currentWindow.setProgressBar({ progress: Math.round(percentage) }); + let unlistenTitlebar: UnlistenFn | undefined; + onMount(async () => { + unlistenTitlebar = await initializeTitlebar(); }); - - const exportWithSettings = async () => { - setShowExportOptions(false); - - const path = await save({ - filters: [{ name: "mp4 filter", extensions: ["mp4"] }], - defaultPath: `~/Desktop/${prettyName()}.mp4`, - }); - if (!path) return; - - setProgressState({ - type: "saving", - progress: 0, - renderProgress: 0, - totalFrames: 0, - message: "Preparing to render...", - mediaPath: path, - stage: "rendering", - }); - - const progress = new Channel(); - progress.onmessage = (p) => { - if (p.type === "FrameRendered" && progressState.type === "saving") { - const percentComplete = Math.min( - Math.round( - (p.current_frame / (progressState.totalFrames || 1)) * 100 - ), - 100 - ); - - setProgressState({ - ...progressState, - renderProgress: p.current_frame, - message: `Rendering video - ${percentComplete}%`, - }); - - // If rendering is complete, update to finalizing state - if (percentComplete === 100) { - setProgressState({ - ...progressState, - message: "Finalizing export...", - }); - } - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "saving" - ) { - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - message: "Starting render...", - }); - } - }; - - try { - const videoPath = await commands.exportVideo( - videoId, - project, - progress, - true, - selectedFps(), - { - x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width, - y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height, - } - ); - await commands.copyFileToPath(videoPath, path); - - setProgressState({ - type: "saving", - progress: 100, - message: "Saved successfully!", - mediaPath: path, - }); - - setTimeout(() => { - setProgressState({ type: "idle" }); - }, 1500); - } catch (error) { - setProgressState({ type: "idle" }); - throw error; - } - }; + onCleanup(() => unlistenTitlebar?.()); batch(() => { setTitlebar("border", false); @@ -236,355 +102,347 @@ export function Header() { selectedResolution={selectedResolution} selectedFps={selectedFps} /> -
+ +
+ + ); + }); + + return ; +} + +function ExportButton(props: { + selectedFps: number; + selectedResolution: ResolutionOption; + setSelectedResolution: Setter; + setSelectedFps: Setter; +}) { + const { videoId, project, prettyName } = useEditorContext(); + const [showExportOptions, setShowExportOptions] = createSignal(false); + + const [exportEstimates] = createResource( + () => ({ + videoId, + resolution: { + x: props.selectedResolution.width, + y: props.selectedResolution.height, + }, + fps: props.selectedFps, + }), + (params) => + commands.getExportEstimates(params.videoId, params.resolution, params.fps) + ); + + const exportWithSettings = createMutation(() => ({ + mutationFn: async () => { + setExportState({ type: "idle" }); + + setShowExportOptions(false); + + const path = await save({ + filters: [{ name: "mp4 filter", extensions: ["mp4"] }], + defaultPath: `~/Desktop/${prettyName()}.mp4`, + }); + if (!path) return; + + setExportState({ type: "starting" }); + + const progress = new Channel(); + + progress.onmessage = (msg) => { + if (msg.type === "EstimatedTotalFrames") + setExportState({ + type: "rendering", + renderedFrames: 0, + totalFrames: msg.total_frames, + }); + else + setExportState( + produce((state) => { + if (msg.type === "FrameRendered" && state.type === "rendering") + state.renderedFrames = msg.current_frame; + }) + ); + }; + + try { + const videoPath = await commands.exportVideo( + videoId, + project, + progress, + true, + props.selectedFps, + { + x: props.selectedResolution.width, + y: props.selectedResolution.height, + } + ); + + setExportState({ type: "saving", done: false }); + + await commands.copyFileToPath(videoPath, path); + + setExportState({ type: "saving", done: false }); + } catch (error) { + throw error; + } + }, + onSettled() { + setTimeout(() => { + exportWithSettings.reset(); + }, 2000); + }, + })); + + const [exportState, setExportState] = createStore< + | { type: "idle" } + | { type: "starting" } + | { type: "rendering"; renderedFrames: number; totalFrames: number } + | { type: "saving"; done: boolean } + >({ type: "idle" }); + + createProgressBar(() => { + if (exportWithSettings.isIdle || exportState.type === "idle") return; + if (exportState.type === "starting") return 0; + if (exportState.type === "rendering") + return (exportState.renderedFrames / exportState.totalFrames) * 100; + return 100; + }); + + return ( +
+ + +
+
+
+ + + options={RESOLUTION_OPTIONS} + optionValue="value" + optionTextValue="label" + placeholder="Select Resolution" + value={props.selectedResolution} + onChange={props.setSelectedResolution} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.label} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500]"> + {(state) => {state.selectedOption()?.label}} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + + +
+
+ + opt.value === props.selectedFps + )} + onChange={(option) => props.setSelectedFps(option?.value ?? 30)} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.label} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500]"> + {(state) => {state.selectedOption()?.label}} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + + +
- -
-
-
- - - options={RESOLUTION_OPTIONS} - optionValue="value" - optionTextValue="label" - placeholder="Select Resolution" - value={selectedResolution()} - onChange={setSelectedResolution} - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.label} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500]"> - {(state) => ( - {state.selectedOption()?.label} - )} - - - - - - - - as={KSelect.Content} - class={cx(topLeftAnimateClasses, "z-50")} - > - - class="max-h-32 overflow-y-auto" - as={KSelect.Listbox} - /> - - - -
-
- - opt.value === selectedFps() - )} - onChange={(option) => setSelectedFps(option?.value ?? 30)} - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.label} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500]"> - {(state) => ( - {state.selectedOption()?.label} - )} - - - - - - - - as={KSelect.Content} - class={cx(topLeftAnimateClasses, "z-50")} - > - - class="max-h-32 overflow-y-auto" - as={KSelect.Listbox} - /> - - - -
- - - {(est) => ( -
-

- - - {(() => { - const totalSeconds = Math.round( - est().duration_seconds - ); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor( - (totalSeconds % 3600) / 60 - ); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; - } - return `${minutes}:${seconds - .toString() - .padStart(2, "0")}`; - })()} - - - - {est().estimated_size_mb.toFixed(2)} MB - - - - {(() => { - const totalSeconds = Math.round( - est().estimated_time_seconds - ); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor( - (totalSeconds % 3600) / 60 - ); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `~${hours}:${minutes - .toString() - .padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; - } - return `~${minutes}:${seconds - .toString() - .padStart(2, "0")}`; - })()} - -

-
- )} -
+ + {(est) => ( +
+

+ + + {(() => { + const totalSeconds = Math.round(est().duration_seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes + .toString() + .padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${seconds + .toString() + .padStart(2, "0")}`; + })()} + + + + {est().estimated_size_mb.toFixed(2)} MB + + + + {(() => { + const totalSeconds = Math.round( + est().estimated_time_seconds + ); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `~${hours}:${minutes + .toString() + .padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `~${minutes}:${seconds + .toString() + .padStart(2, "0")}`; + })()} + +

-
+ )}
-
- ); - }); - - return ( - <> - - {}}> +
+ { + // cancellation doesn't work yet + // if (!o) exportWithSettings.reset(); + }} + > } + close={<>} class="bg-gray-600 text-gray-500 dark:text-gray-500" > -
- - - {(when) => { - const state = progressState as Extract< - ProgressState, - { type: "copying" } - >; - return ( -
-

- {state.stage === "rendering" - ? "Rendering video" - : "Copying to clipboard"} -

- -
-
-
- -

- {state.stage === "rendering" && - state.renderProgress && - state.totalFrames - ? `${state.message} (${state.renderProgress}/${state.totalFrames} frames)` - : state.message} -

-
- ); - }} - - - {(when) => { - const state = progressState as Extract< - ProgressState, - { type: "saving" } - >; - return ( -
-

- {state.stage === "rendering" - ? "Rendering video" - : "Saving file"} -

- -
-
-
- -

- {state.stage === "rendering" && - state.renderProgress && - state.totalFrames - ? `${state.message} (${state.renderProgress}/${state.totalFrames} frames)` - : state.message} -

-
- ); - }} - - - {(when) => { - const state = progressState as Extract< - ProgressState, - { type: "uploading" } - >; - return ( -
-

- {state.stage === "rendering" - ? "Rendering video" - : "Creating shareable link"} -

- -
-
-
- -

- {state.stage === "rendering" - ? `Rendering - ${Math.round( - state.renderProgress || 0 - )}%` - : state.message} -

-
- ); +
+
+
- + /> +
+

+ {exportState.type == "idle" || exportState.type === "starting" + ? "Preparing to render..." + : exportState.type === "rendering" + ? `Rendering video (${exportState.renderedFrames}/${exportState.totalFrames} frames)` + : "Exported successfully!"} +

- +
); } -type ShareButtonProps = { +function ShareButton(props: { selectedResolution: () => ResolutionOption; selectedFps: () => number; -}; - -function ShareButton(props: ShareButtonProps) { - const { videoId, project, presets } = useEditorContext(); +}) { + const { videoId, project } = useEditorContext(); const [recordingMeta, metaActions] = createResource(() => commands.getRecordingMeta(videoId, "recording") ); const uploadVideo = createMutation(() => ({ - mutationFn: async (useCustomMuxer: boolean) => { + mutationFn: async () => { + setUploadState({ type: "idle" }); + console.log("Starting upload process..."); // Check authentication first @@ -616,86 +474,42 @@ function ShareButton(props: ShareButtonProps) { } } - let unlisten: (() => void) | undefined; + const unlisten = await events.uploadProgress.listen((event) => { + console.log("Upload progress event:", event.payload); + setUploadState( + produce((state) => { + if (state.type !== "uploading") return; + + state.progress = Math.round(event.payload.progress * 100); + }) + ); + }); try { - setProgressState({ - type: "uploading", - renderProgress: 0, - uploadProgress: 0, - message: "Rendering - 0%", - mediaPath: videoId, - stage: "rendering", - }); + setUploadState({ type: "starting" }); // Setup progress listener before starting upload - unlisten = await events.uploadProgress.listen((event) => { - console.log("Upload progress event:", event.payload); - if (progressState.type === "uploading") { - const progress = Math.round(event.payload.progress * 100); - if (event.payload.stage === "rendering") { - setProgressState({ - type: "uploading", - renderProgress: progress, - uploadProgress: 0, - message: `Rendering - ${progress}%`, - mediaPath: videoId, - stage: "rendering", - }); - } else { - setProgressState({ - type: "uploading", - renderProgress: 100, - uploadProgress: progress / 100, - message: `Uploading - ${progress}%`, - mediaPath: videoId, - stage: "uploading", - }); - } - } - }); console.log("Starting actual upload..."); - setProgressState({ - type: "uploading", - renderProgress: 0, - uploadProgress: 0, - message: "Rendering - 0%", - mediaPath: videoId, - stage: "rendering", - }); - const progress = new Channel(); - progress.onmessage = (p) => { - console.log("Progress channel message:", p); - if ( - p.type === "FrameRendered" && - progressState.type === "uploading" - ) { - const renderProgress = Math.round( - (p.current_frame / (progressState.totalFrames || 1)) * 100 - ); - setProgressState({ - ...progressState, - message: `Rendering - ${renderProgress}%`, - renderProgress, - }); - } - if ( - p.type === "EstimatedTotalFrames" && - progressState.type === "uploading" - ) { - console.log("Got total frames:", p.total_frames); - setProgressState({ - ...progressState, - totalFrames: p.total_frames, + + progress.onmessage = (msg) => { + if (msg.type === "EstimatedTotalFrames") + setUploadState({ + type: "rendering", + renderedFrames: 0, + totalFrames: msg.total_frames, }); - } + else + setUploadState( + produce((state) => { + if (msg.type === "FrameRendered" && state.type === "rendering") + state.renderedFrames = msg.current_frame; + }) + ); }; - getRequestEvent()?.nativeEvent; - await commands.exportVideo( videoId, project, @@ -710,6 +524,8 @@ function ShareButton(props: ShareButtonProps) { } ); + setUploadState({ type: "uploading", progress: 0 }); + // Now proceed with upload const result = recordingMeta()?.sharing ? await commands.uploadExportedVideo(videoId, "Reupload") @@ -720,123 +536,179 @@ function ShareButton(props: ShareButtonProps) { if (result === "NotAuthenticated") { await commands.showWindow("SignIn"); throw new Error("You need to sign in to share recordings"); - } - if (result === "PlanCheckFailed") { + } else if (result === "PlanCheckFailed") throw new Error("Failed to verify your subscription status"); - } - if (result === "UpgradeRequired") { + else if (result === "UpgradeRequired") throw new Error("This feature requires an upgraded plan"); - } - - // Show success state briefly before resetting - setProgressState({ - type: "uploading", - renderProgress: 100, - uploadProgress: 100, - message: "Upload complete!", - mediaPath: videoId, - stage: "uploading", - }); - - setTimeout(() => { - setProgressState({ type: "idle" }); - }, 1500); return result; } catch (error) { console.error("Upload error:", error); - setProgressState({ type: "idle" }); throw error instanceof Error ? error : new Error("Failed to upload recording"); } finally { - if (unlisten) { - console.log("Cleaning up upload progress listener"); - unlisten(); - } + unlisten(); } }, onSuccess: () => { - console.log("Upload successful, refreshing metadata"); metaActions.refetch(); }, onError: (error) => { - console.error("Upload mutation error:", error); - setProgressState({ type: "idle" }); commands.globalMessageDialog( error instanceof Error ? error.message : "Failed to upload recording" ); }, + onSettled() { + setTimeout(() => { + setUploadState({ type: "idle" }); + }, 1500); + }, })); + const [uploadState, setUploadState] = createStore< + | { type: "idle" } + | { type: "starting" } + | { type: "rendering"; renderedFrames: number; totalFrames: number } + | { type: "uploading"; progress: number } + | { type: "link-copied" } + >({ type: "idle" }); + + createProgressBar(() => { + if (uploadVideo.isIdle || uploadState.type === "idle") return; + if (uploadState.type === "starting") return 0; + if (uploadState.type === "rendering") + return (uploadState.renderedFrames / uploadState.totalFrames) * 100; + if (uploadState.type === "uploading") return uploadState.progress; + return 100; + }); + return ( - - uploadVideo.mutate((e.ctrlKey || e.metaKey) && e.shiftKey) +
+ uploadVideo.mutate()} + variant="primary" + class="flex items-center space-x-1" + > + {uploadVideo.isPending ? ( + <> + Uploading Cap + + + ) : ( + "Create Shareable Link" + )} + + } + > + {(sharing) => { + const url = () => new URL(sharing().link); + + return ( +
+ + + + + + + {uploadVideo.isPending + ? "Reuploading video" + : "Reupload video"} + + + + + + + {url().host} + {url().pathname} + + +
+ ); + }} +
+ + } + close={<>} + class="bg-gray-600 text-gray-500 dark:text-gray-500" > - {uploadVideo.isPending ? ( - <> - Uploading Cap - - - ) : ( - "Create Shareable Link" - )} - - } - > - {(sharing) => { - const url = () => new URL(sharing().link); - - return ( -
- - - - - - - {uploadVideo.isPending - ? "Reuploading video" - : "Reupload video"} - - - - - - - {url().host} - {url().pathname} - - +
+
+
+
+ +

+ {uploadState.type == "idle" || uploadState.type === "starting" + ? "Preparing to render..." + : uploadState.type === "rendering" + ? `Rendering video (${uploadState.renderedFrames}/${uploadState.totalFrames} frames)` + : uploadState.type === "uploading" + ? `Uploading - ${Math.floor(uploadState.progress)}%` + : "Link copied to clipboard!"} +

- ); - }} - + + +
); } + +function createProgressBar(progress: () => number | undefined) { + const currentWindow = getCurrentWindow(); + + createEffect(() => { + const p = progress(); + console.log({ p }); + if (p === undefined) + currentWindow.setProgressBar({ status: ProgressBarStatus.None }); + else currentWindow.setProgressBar({ progress: Math.round(p) }); + }); +} diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 42eb6ab12..e556c9d45 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -34,7 +34,7 @@ export type CurrentDialog = export type DialogState = { open: false } | ({ open: boolean } & CurrentDialog); -export const FPS = 60; +export const FPS = 30; export const OUTPUT_SIZE = { x: 1920, diff --git a/apps/desktop/src/routes/editor/ui.tsx b/apps/desktop/src/routes/editor/ui.tsx index baa062795..f375044b5 100644 --- a/apps/desktop/src/routes/editor/ui.tsx +++ b/apps/desktop/src/routes/editor/ui.tsx @@ -147,7 +147,7 @@ export const Dialog = { ); return
@@ -646,6 +447,42 @@ export default function () { ); } +function ActionProgressOverlay(props: { + title: string; + // percentage 0-100 + progressPercentage: number; + progressMessage?: string | false; +}) { + return ( +
+
+

+ {props.title} +

+
+
+
+ +

+ {typeof props.progressMessage === "string" + ? props.progressMessage + : `${Math.floor(props.progressPercentage)}%`} +

+
+
+ ); +} + const IconButton = (props: ComponentProps<"button">) => { return (