Skip to content

Commit ceb5e01

Browse files
committed
Show estimated GIF size in editor
1 parent c42692f commit ceb5e01

6 files changed

Lines changed: 232 additions & 2 deletions

File tree

main/common/types/remote-states.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {App, Format} from './base';
2-
import {ExportStatus} from './conversion-options';
2+
import {ConversionOptions, ExportStatus} from './conversion-options';
33

44
// eslint-disable-next-line @typescript-eslint/ban-types
55
export type RemoteState<State = any, Actions extends Record<string, (...args: any[]) => any> = {}> = {
@@ -59,6 +59,10 @@ export type EditorOptionsRemoteState = RemoteState<ExportOptions, {
5959
format: Format;
6060
fps: number;
6161
}) => void;
62+
estimateGifSize: ({filePath, conversionOptions}: {
63+
filePath: string;
64+
conversionOptions: ConversionOptions;
65+
}) => Promise<string | undefined>;
6266
}>;
6367

6468
export interface ExportState {

main/remote-states/editor-options.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import Store from 'electron-store';
2-
import {EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, RemoteStateHandler} from '../common/types';
2+
import {ConversionOptions, EditorOptionsRemoteState, ExportOptions, ExportOptionsPlugin, Format, RemoteStateHandler} from '../common/types';
33
import {formats} from '../common/constants';
44

55
import {plugins} from '../plugins';
66
import {apps} from '../plugins/built-in/open-with-plugin';
77
import {prettifyFormat} from '../utils/formats';
8+
import {Video} from '../video';
9+
import {estimateGifSize} from '../utils/gif-size-estimate';
810

911
const exportUsageHistory = new Store<{[key in Format]: {lastUsed: number; plugins: Record<string, number>}}>({
1012
name: 'export-usage-history',
@@ -54,6 +56,8 @@ const fpsUsageHistory = new Store<{[key in Format]: number}>({
5456
}
5557
});
5658

59+
const gifSizeEstimateProcesses = new Map<string, ReturnType<typeof estimateGifSize>>();
60+
5761
const getEditOptions = () => {
5862
return plugins.editPlugins.flatMap(
5963
plugin => plugin.editServices
@@ -133,6 +137,35 @@ const editorOptionsRemoteState: RemoteStateHandler<EditorOptionsRemoteState> = s
133137
fpsUsageHistory.set(format, fps);
134138
state.fpsHistory = fpsUsageHistory.store;
135139
sendUpdate(state);
140+
},
141+
estimateGifSize: async (id: string, {filePath, conversionOptions}: {
142+
filePath: string;
143+
conversionOptions: ConversionOptions;
144+
}) => {
145+
const video = Video.fromId(filePath);
146+
147+
if (!video) {
148+
return;
149+
}
150+
151+
gifSizeEstimateProcesses.get(id)?.cancel();
152+
153+
const process = estimateGifSize(video, conversionOptions);
154+
gifSizeEstimateProcesses.set(id, process);
155+
156+
try {
157+
return await process;
158+
} catch (error) {
159+
if ((error as any)?.isCanceled) {
160+
return;
161+
}
162+
163+
throw error;
164+
} finally {
165+
if (gifSizeEstimateProcesses.get(id) === process) {
166+
gifSizeEstimateProcesses.delete(id);
167+
}
168+
}
136169
}
137170
};
138171

main/utils/gif-size-estimate.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import PCancelable from 'p-cancelable';
4+
import prettyBytes from 'pretty-bytes';
5+
import {convertTo} from '../converters';
6+
import {ConversionOptions, Format} from '../common/types';
7+
import {Video} from '../video';
8+
9+
const maximumSampleDuration = 2;
10+
const noop = () => undefined;
11+
12+
export const estimateGifSize = PCancelable.fn(async (
13+
video: Video,
14+
options: ConversionOptions,
15+
onCancel: PCancelable.OnCancelFunction
16+
) => {
17+
const duration = Math.max(options.endTime - options.startTime, 0);
18+
19+
if (duration === 0) {
20+
return;
21+
}
22+
23+
await video.whenReady();
24+
25+
const sampleDuration = Math.min(duration, maximumSampleDuration);
26+
let samplePath: string | undefined;
27+
28+
const conversionProcess = convertTo(
29+
Format.gif,
30+
{
31+
...options,
32+
defaultFileName: `${video.title}-size-estimate`,
33+
endTime: options.startTime + sampleDuration,
34+
inputPath: video.filePath,
35+
onCancel: noop,
36+
onProgress: noop
37+
},
38+
video.encoding
39+
);
40+
41+
onCancel(() => {
42+
conversionProcess.cancel();
43+
});
44+
45+
try {
46+
samplePath = await conversionProcess;
47+
const {size} = await fs.promises.stat(samplePath);
48+
return prettyBytes(Math.ceil(size * (duration / sampleDuration)));
49+
} finally {
50+
if (samplePath) {
51+
await fs.promises.unlink(samplePath).catch(noop);
52+
await fs.promises.rmdir(path.dirname(samplePath)).catch(noop);
53+
}
54+
}
55+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {useEffect, useRef, useState} from 'react';
2+
import {Format} from 'common/types';
3+
import useEditorOptions from 'hooks/editor/use-editor-options';
4+
import useEditorWindowState from 'hooks/editor/use-editor-window-state';
5+
import OptionsContainer from '../options-container';
6+
import VideoTimeContainer from '../video-time-container';
7+
8+
const estimateDelay = 500;
9+
10+
const GifSizeEstimate = () => {
11+
const {format, width, height, fps} = OptionsContainer.useContainer();
12+
const {startTime, endTime} = VideoTimeContainer.useContainer();
13+
const {filePath} = useEditorWindowState();
14+
const {estimateGifSize} = useEditorOptions();
15+
16+
const [size, setSize] = useState<string>();
17+
const [isEstimating, setIsEstimating] = useState(false);
18+
const requestId = useRef(0);
19+
20+
useEffect(() => {
21+
const canEstimate = (
22+
format === Format.gif &&
23+
filePath &&
24+
width &&
25+
height &&
26+
fps &&
27+
endTime > startTime &&
28+
estimateGifSize
29+
);
30+
31+
if (!canEstimate) {
32+
requestId.current++;
33+
setSize(undefined);
34+
setIsEstimating(false);
35+
return;
36+
}
37+
38+
const id = ++requestId.current;
39+
setSize(undefined);
40+
setIsEstimating(true);
41+
42+
const timer = window.setTimeout(() => {
43+
estimateGifSize({
44+
filePath,
45+
conversionOptions: {
46+
width,
47+
height,
48+
startTime,
49+
endTime,
50+
fps,
51+
shouldCrop: true,
52+
shouldMute: true
53+
}
54+
}).then(estimatedSize => {
55+
if (id === requestId.current) {
56+
setSize(estimatedSize);
57+
}
58+
}).catch(() => {
59+
if (id === requestId.current) {
60+
setSize(undefined);
61+
}
62+
}).finally(() => {
63+
if (id === requestId.current) {
64+
setIsEstimating(false);
65+
}
66+
});
67+
}, estimateDelay);
68+
69+
return () => {
70+
window.clearTimeout(timer);
71+
};
72+
}, [endTime, estimateGifSize, filePath, format, fps, height, startTime, width]);
73+
74+
if (format !== Format.gif) {
75+
return null;
76+
}
77+
78+
const label = size ? `GIF ~${size}` : (isEstimating ? 'GIF ...' : 'GIF N/A');
79+
80+
return (
81+
<div className="gif-size-estimate" title="Estimated GIF size">
82+
{label}
83+
<style jsx>{`
84+
.gif-size-estimate {
85+
color: #aaaaaa;
86+
flex-shrink: 0;
87+
font-size: 12px;
88+
line-height: 24px;
89+
margin-right: 8px;
90+
min-width: 80px;
91+
overflow: hidden;
92+
text-align: right;
93+
text-overflow: ellipsis;
94+
white-space: nowrap;
95+
}
96+
`}</style>
97+
</div>
98+
);
99+
};
100+
101+
export default GifSizeEstimate;

renderer/components/editor/options/right.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import VideoTimeContainer from '../video-time-container';
88
import VideoControlsContainer from '../video-controls-container';
99
import useSharePlugins from 'hooks/editor/use-share-plugins';
1010
import useEditorOptions from 'hooks/editor/use-editor-options';
11+
import GifSizeEstimate from './gif-size-estimate';
1112

1213
const FormatSelect = () => {
1314
const {formats, format, updateFormat} = OptionsContainer.useContainer();
@@ -195,6 +196,7 @@ const RightOptions = () => {
195196
<EditPluginsControl/>
196197
<div className="format"><FormatSelect/></div>
197198
<div className="plugin"><PluginsSelect/></div>
199+
<GifSizeEstimate/>
198200
<ConvertButton/>
199201
<style jsx>{`
200202
.container {

test/gif-size-estimate.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import test from 'ava';
2+
import path from 'path';
3+
import {mockImport} from './helpers/mocks';
4+
5+
mockImport('../common/analytics', 'analytics');
6+
mockImport('../plugins/service-context', 'service-context');
7+
mockImport('../plugins', 'plugins');
8+
mockImport('../common/settings', 'settings');
9+
10+
import {estimateGifSize} from '../main/utils/gif-size-estimate';
11+
import {Encoding} from '../main/common/types';
12+
import {Video} from '../main/video';
13+
14+
const input = path.resolve(__dirname, 'fixtures', 'input.mp4');
15+
16+
test('estimates GIF size', async t => {
17+
const video = new Video({
18+
filePath: input,
19+
title: 'input',
20+
fps: 30,
21+
encoding: Encoding.h264
22+
});
23+
24+
const estimate = await estimateGifSize(video, {
25+
fps: 10,
26+
width: 255,
27+
height: 143,
28+
startTime: 0,
29+
endTime: 2,
30+
shouldCrop: true,
31+
shouldMute: true
32+
});
33+
34+
t.regex(estimate!, /\d+.*B$/);
35+
});

0 commit comments

Comments
 (0)