From ceb5e01683e19a0214da4c9a6b382cddfc9ae4c1 Mon Sep 17 00:00:00 2001 From: apple <245524539+apples-kksk@users.noreply.github.com> Date: Sun, 10 May 2026 13:50:47 +0800 Subject: [PATCH 1/2] Show estimated GIF size in editor --- main/common/types/remote-states.ts | 6 +- main/remote-states/editor-options.ts | 35 +++++- main/utils/gif-size-estimate.ts | 55 ++++++++++ .../editor/options/gif-size-estimate.tsx | 101 ++++++++++++++++++ renderer/components/editor/options/right.tsx | 2 + test/gif-size-estimate.ts | 35 ++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 main/utils/gif-size-estimate.ts create mode 100644 renderer/components/editor/options/gif-size-estimate.tsx create mode 100644 test/gif-size-estimate.ts diff --git a/main/common/types/remote-states.ts b/main/common/types/remote-states.ts index 74bda5d3..dce5281a 100644 --- a/main/common/types/remote-states.ts +++ b/main/common/types/remote-states.ts @@ -1,5 +1,5 @@ import {App, Format} from './base'; -import {ExportStatus} from './conversion-options'; +import {ConversionOptions, ExportStatus} from './conversion-options'; // eslint-disable-next-line @typescript-eslint/ban-types export type RemoteState any> = {}> = { @@ -59,6 +59,10 @@ export type EditorOptionsRemoteState = RemoteState void; + estimateGifSize: ({filePath, conversionOptions}: { + filePath: string; + conversionOptions: ConversionOptions; + }) => Promise; }>; export interface ExportState { diff --git a/main/remote-states/editor-options.ts b/main/remote-states/editor-options.ts index 6b572134..4e88ce88 100644 --- a/main/remote-states/editor-options.ts +++ b/main/remote-states/editor-options.ts @@ -1,10 +1,12 @@ import Store from 'electron-store'; -import {EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, RemoteStateHandler} from '../common/types'; +import {ConversionOptions, EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, RemoteStateHandler} from '../common/types'; import {formats} from '../common/constants'; import {plugins} from '../plugins'; import {apps} from '../plugins/built-in/open-with-plugin'; import {prettifyFormat} from '../utils/formats'; +import {Video} from '../video'; +import {estimateGifSize} from '../utils/gif-size-estimate'; const exportUsageHistory = new Store<{[key in Format]: {lastUsed: number; plugins: Record}}>({ name: 'export-usage-history', @@ -54,6 +56,8 @@ const fpsUsageHistory = new Store<{[key in Format]: number}>({ } }); +const gifSizeEstimateProcesses = new Map>(); + const getEditOptions = () => { return plugins.editPlugins.flatMap( plugin => plugin.editServices @@ -133,6 +137,35 @@ const editorOptionsRemoteState: RemoteStateHandler = s fpsUsageHistory.set(format, fps); state.fpsHistory = fpsUsageHistory.store; sendUpdate(state); + }, + estimateGifSize: async (id: string, {filePath, conversionOptions}: { + filePath: string; + conversionOptions: ConversionOptions; + }) => { + const video = Video.fromId(filePath); + + if (!video) { + return; + } + + gifSizeEstimateProcesses.get(id)?.cancel(); + + const process = estimateGifSize(video, conversionOptions); + gifSizeEstimateProcesses.set(id, process); + + try { + return await process; + } catch (error) { + if ((error as any)?.isCanceled) { + return; + } + + throw error; + } finally { + if (gifSizeEstimateProcesses.get(id) === process) { + gifSizeEstimateProcesses.delete(id); + } + } } }; diff --git a/main/utils/gif-size-estimate.ts b/main/utils/gif-size-estimate.ts new file mode 100644 index 00000000..9390bc9a --- /dev/null +++ b/main/utils/gif-size-estimate.ts @@ -0,0 +1,55 @@ +import fs from 'fs'; +import path from 'path'; +import PCancelable from 'p-cancelable'; +import prettyBytes from 'pretty-bytes'; +import {convertTo} from '../converters'; +import {ConversionOptions, Format} from '../common/types'; +import {Video} from '../video'; + +const maximumSampleDuration = 2; +const noop = () => undefined; + +export const estimateGifSize = PCancelable.fn(async ( + video: Video, + options: ConversionOptions, + onCancel: PCancelable.OnCancelFunction +) => { + const duration = Math.max(options.endTime - options.startTime, 0); + + if (duration === 0) { + return; + } + + await video.whenReady(); + + const sampleDuration = Math.min(duration, maximumSampleDuration); + let samplePath: string | undefined; + + const conversionProcess = convertTo( + Format.gif, + { + ...options, + defaultFileName: `${video.title}-size-estimate`, + endTime: options.startTime + sampleDuration, + inputPath: video.filePath, + onCancel: noop, + onProgress: noop + }, + video.encoding + ); + + onCancel(() => { + conversionProcess.cancel(); + }); + + try { + samplePath = await conversionProcess; + const {size} = await fs.promises.stat(samplePath); + return prettyBytes(Math.ceil(size * (duration / sampleDuration))); + } finally { + if (samplePath) { + await fs.promises.unlink(samplePath).catch(noop); + await fs.promises.rmdir(path.dirname(samplePath)).catch(noop); + } + } +}); diff --git a/renderer/components/editor/options/gif-size-estimate.tsx b/renderer/components/editor/options/gif-size-estimate.tsx new file mode 100644 index 00000000..804abbaf --- /dev/null +++ b/renderer/components/editor/options/gif-size-estimate.tsx @@ -0,0 +1,101 @@ +import {useEffect, useRef, useState} from 'react'; +import {Format} from 'common/types'; +import useEditorOptions from 'hooks/editor/use-editor-options'; +import useEditorWindowState from 'hooks/editor/use-editor-window-state'; +import OptionsContainer from '../options-container'; +import VideoTimeContainer from '../video-time-container'; + +const estimateDelay = 500; + +const GifSizeEstimate = () => { + const {format, width, height, fps} = OptionsContainer.useContainer(); + const {startTime, endTime} = VideoTimeContainer.useContainer(); + const {filePath} = useEditorWindowState(); + const {estimateGifSize} = useEditorOptions(); + + const [size, setSize] = useState(); + const [isEstimating, setIsEstimating] = useState(false); + const requestId = useRef(0); + + useEffect(() => { + const canEstimate = ( + format === Format.gif && + filePath && + width && + height && + fps && + endTime > startTime && + estimateGifSize + ); + + if (!canEstimate) { + requestId.current++; + setSize(undefined); + setIsEstimating(false); + return; + } + + const id = ++requestId.current; + setSize(undefined); + setIsEstimating(true); + + const timer = window.setTimeout(() => { + estimateGifSize({ + filePath, + conversionOptions: { + width, + height, + startTime, + endTime, + fps, + shouldCrop: true, + shouldMute: true + } + }).then(estimatedSize => { + if (id === requestId.current) { + setSize(estimatedSize); + } + }).catch(() => { + if (id === requestId.current) { + setSize(undefined); + } + }).finally(() => { + if (id === requestId.current) { + setIsEstimating(false); + } + }); + }, estimateDelay); + + return () => { + window.clearTimeout(timer); + }; + }, [endTime, estimateGifSize, filePath, format, fps, height, startTime, width]); + + if (format !== Format.gif) { + return null; + } + + const label = size ? `GIF ~${size}` : (isEstimating ? 'GIF ...' : 'GIF N/A'); + + return ( +
+ {label} + +
+ ); +}; + +export default GifSizeEstimate; diff --git a/renderer/components/editor/options/right.tsx b/renderer/components/editor/options/right.tsx index 9c8e306c..f2e6a947 100644 --- a/renderer/components/editor/options/right.tsx +++ b/renderer/components/editor/options/right.tsx @@ -8,6 +8,7 @@ import VideoTimeContainer from '../video-time-container'; import VideoControlsContainer from '../video-controls-container'; import useSharePlugins from 'hooks/editor/use-share-plugins'; import useEditorOptions from 'hooks/editor/use-editor-options'; +import GifSizeEstimate from './gif-size-estimate'; const FormatSelect = () => { const {formats, format, updateFormat} = OptionsContainer.useContainer(); @@ -195,6 +196,7 @@ const RightOptions = () => {
+