diff --git a/.circleci/config.yml b/.circleci/config.yml index f87363e2..0d843678 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,9 +2,33 @@ version: 2 jobs: build: macos: - xcode: '13.4.1' + xcode: '14.3.1' + resource_class: m4pro.medium steps: - checkout + - run: + name: Use Node.js 16 + command: | + nvm install 16 + nvm alias default 16 + { + echo 'export NVM_DIR="$HOME/.nvm"' + echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' + echo 'nvm use 16 >/dev/null' + } >> "$BASH_ENV" + nvm use 16 + npm install --global yarn@1.22.19 + node --version + yarn --version + - run: + name: Install native build tools + command: | + export HOMEBREW_NO_AUTO_UPDATE=1 + export HOMEBREW_NO_INSTALL_CLEANUP=1 + brew install automake libtool pkg-config || true + echo 'export PATH="/opt/homebrew/opt/libtool/libexec/gnubin:$PATH"' >> "$BASH_ENV" + export PATH="/opt/homebrew/opt/libtool/libexec/gnubin:$PATH" + command -v aclocal - run: yarn - run: mkdir -p ~/reports - run: yarn lint diff --git a/main/common/types/remote-states.ts b/main/common/types/remote-states.ts index 74bda5d3..e211ecb3 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> = {}> = { @@ -17,9 +17,10 @@ export type RemoteStateHook = Base extends RemoteState export type RemoteStateHandler = Base extends RemoteState ? (sendUpdate: (state: State, id?: string) => void) => { actions: { - [Key in keyof Actions]: Actions[Key] extends (...args: any[]) => any ? (id: string, ...args: Parameters) => void : never + [Key in keyof Actions]: Actions[Key] extends (...args: any[]) => infer Result ? (id: string, ...args: Parameters) => Result : never }; getState: (id: string) => State | undefined; + subscribe?: (id: string) => undefined | (() => void); } : never; export interface ExportOptionsPlugin { @@ -59,6 +60,15 @@ export type EditorOptionsRemoteState = RemoteState void; + estimateGifSize: ({filePath, conversionOptions}: { + filePath: string; + conversionOptions: ConversionOptions; + }) => Promise<{ + bytes: number; + sampleCount: number; + sampledDuration: number; + } | undefined>; + cancelGifSizeEstimate: () => void; }>; export interface ExportState { diff --git a/main/converters/h264.ts b/main/converters/h264.ts index 40a32f40..8ba6f90f 100644 --- a/main/converters/h264.ts +++ b/main/converters/h264.ts @@ -12,93 +12,99 @@ import fs from 'fs'; const convertToGif = PCancelable.fn(async (options: ConvertOptions, onCancel: PCancelable.OnCancelFunction) => { const palettePath = tempy.file({extension: 'png'}); - const paletteProcess = convert(palettePath, {shouldTrack: false}, conditionalArgs( - '-i', options.inputPath, - '-vf', `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''},palettegen`, - { - args: [ - '-ss', - options.startTime.toString(), - '-to', - options.endTime.toString() - ], - if: options.shouldCrop - }, - palettePath - )); + try { + const paletteProcess = convert(palettePath, {shouldTrack: false}, conditionalArgs( + '-i', options.inputPath, + '-vf', `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''},palettegen`, + { + args: [ + '-ss', + options.startTime.toString(), + '-to', + options.endTime.toString() + ], + if: options.shouldCrop + }, + palettePath + )); - onCancel(() => { - paletteProcess.cancel(); - }); + onCancel(() => { + paletteProcess.cancel(); + }); - await paletteProcess; + await paletteProcess; - // Sometimes if the clip is too short or fps too low, the palette is not generated - const hasPalette = fs.existsSync(palettePath); + // Sometimes if the clip is too short or fps too low, the palette is not generated + const hasPalette = fs.existsSync(palettePath); - const shouldLoop = settings.get('loopExports'); + const shouldLoop = settings.get('loopExports'); - const conversionProcess = convert(options.outputPath, { - onProgress: (progress, estimate) => { - options.onProgress('Converting', progress, estimate); - }, - startTime: options.startTime, - endTime: options.endTime - }, conditionalArgs( - '-i', options.inputPath, - { - args: [ - '-i', - palettePath, - '-filter_complex', - `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}[x]; [x][1:v]paletteuse` - ], - if: hasPalette - }, - { - args: [ - '-vf', - `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}` - ], - if: !hasPalette - }, - '-loop', shouldLoop ? '0' : '-1', // 0 == forever; -1 == no loop - { - args: [ - '-ss', - options.startTime.toString(), - '-to', - options.endTime.toString() - ], - if: options.shouldCrop - }, - options.outputPath - )); + const conversionProcess = convert(options.outputPath, { + shouldTrack: options.shouldTrack, + onProgress: (progress, estimate) => { + options.onProgress('Converting', progress, estimate); + }, + startTime: options.startTime, + endTime: options.endTime + }, conditionalArgs( + '-i', options.inputPath, + { + args: [ + '-i', + palettePath, + '-filter_complex', + `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}[x]; [x][1:v]paletteuse` + ], + if: hasPalette + }, + { + args: [ + '-vf', + `fps=${options.fps}${options.shouldCrop ? `,scale=${options.width}:${options.height}:flags=lanczos` : ''}` + ], + if: !hasPalette + }, + '-loop', shouldLoop ? '0' : '-1', // 0 == forever; -1 == no loop + { + args: [ + '-ss', + options.startTime.toString(), + '-to', + options.endTime.toString() + ], + if: options.shouldCrop + }, + options.outputPath + )); - onCancel(() => { - conversionProcess.cancel(); - }); + onCancel(() => { + conversionProcess.cancel(); + }); - await conversionProcess; + await conversionProcess; - const compressProcess = compress(options.outputPath, { - onProgress: (progress, estimate) => { - options.onProgress('Compressing', progress, estimate); - }, - startTime: options.startTime, - endTime: options.endTime - }, [ - '--batch', - options.outputPath - ]); + const compressProcess = compress(options.outputPath, { + shouldTrack: options.shouldTrack, + onProgress: (progress, estimate) => { + options.onProgress('Compressing', progress, estimate); + }, + startTime: options.startTime, + endTime: options.endTime + }, [ + '--batch', + options.outputPath + ]); - onCancel(() => { - compressProcess.cancel(); - }); + onCancel(() => { + compressProcess.cancel(); + }); - await compressProcess; + await compressProcess; - return options.outputPath; + return options.outputPath; + } finally { + await fs.promises.unlink(palettePath).catch(() => undefined); + } }); // eslint-disable-next-line @typescript-eslint/promise-function-async diff --git a/main/converters/index.ts b/main/converters/index.ts index 2025bae4..f30b9a56 100644 --- a/main/converters/index.ts +++ b/main/converters/index.ts @@ -23,7 +23,7 @@ const croppingHandlers = new Map([ // eslint-disable-next-line @typescript-eslint/promise-function-async export const convertTo = ( format: Format, - options: Except & {defaultFileName: string}, + options: Except & {defaultFileName: string; outputPath?: string}, encoding: Encoding = Encoding.h264 ) => { if (!converters.has(encoding)) { @@ -36,12 +36,14 @@ export const convertTo = ( throw new Error(`Unsupported file format for ${encoding}: ${format}`); } - track(`file/export/encoding/${encoding}`); - track(`file/export/format/${format}`); + if (options.shouldTrack !== false) { + track(`file/export/encoding/${encoding}`); + track(`file/export/format/${format}`); + } const conversionOptions = { - outputPath: path.join(tempy.directory(), `${options.defaultFileName}.${getFormatExtension(format)}`), - ...options + ...options, + outputPath: options.outputPath ?? path.join(tempy.directory(), `${options.defaultFileName}.${getFormatExtension(format)}`) }; if (options.editService) { diff --git a/main/converters/utils.ts b/main/converters/utils.ts index 8af39bde..4ffcc2eb 100644 --- a/main/converters/utils.ts +++ b/main/converters/utils.ts @@ -13,6 +13,7 @@ export interface ConvertOptions { shouldMute: boolean; onCancel: () => void; onProgress: (action: string, progress: number, estimate?: string) => void; + shouldTrack?: boolean; editService?: { pluginName: string; serviceTitle: string; diff --git a/main/remote-states/editor-options.ts b/main/remote-states/editor-options.ts index 6b572134..1d500e0c 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 {estimateGifSize} from '../utils/gif-size-estimate'; +import {Video} from '../video'; 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,12 +137,49 @@ 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); + } + } + }, + cancelGifSizeEstimate: (id: string) => { + gifSizeEstimateProcesses.get(id)?.cancel(); + gifSizeEstimateProcesses.delete(id); } }; return { actions, - getState: () => state + getState: () => state, + subscribe: id => () => { + gifSizeEstimateProcesses.get(id)?.cancel(); + 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..13f23a63 --- /dev/null +++ b/main/utils/gif-size-estimate.ts @@ -0,0 +1,157 @@ +import fs from 'fs'; +import path from 'path'; +import PCancelable from 'p-cancelable'; +import tempy from 'tempy'; +import {ConversionOptions, Format} from '../common/types'; +import {convertTo} from '../converters'; +import {Video} from '../video'; + +const maximumSampleCount = 3; +const maximumSampleDuration = 1; +const noop = () => undefined; + +export type GifSampleRange = { + startTime: number; + endTime: number; +}; + +export type GifSizeEstimate = { + bytes: number; + sampleCount: number; + sampledDuration: number; +}; + +export const getGifSampleRanges = (startTime: number, endTime: number): GifSampleRange[] => { + const duration = endTime - startTime; + + if (!Number.isFinite(duration) || duration <= 0) { + return []; + } + + const maximumTotalSampleDuration = maximumSampleCount * maximumSampleDuration; + + if (duration <= maximumTotalSampleDuration) { + return [{startTime, endTime}]; + } + + const finalSampleStart = endTime - maximumSampleDuration; + + return Array.from({length: maximumSampleCount}, (_, index) => { + const progress = index / (maximumSampleCount - 1); + const sampleStartTime = startTime + ((finalSampleStart - startTime) * progress); + + return { + startTime: sampleStartTime, + endTime: sampleStartTime + maximumSampleDuration + }; + }); +}; + +export const extrapolateGifSize = ( + sampleSizes: number[], + sampleRanges: GifSampleRange[], + totalDuration: number +) => { + let sampledDuration = 0; + + for (const sample of sampleRanges) { + sampledDuration += sample.endTime - sample.startTime; + } + + if (sampleSizes.length === 0 || sampleSizes.length !== sampleRanges.length || sampledDuration <= 0 || totalDuration <= 0) { + return; + } + + let sampledBytes = 0; + + for (const size of sampleSizes) { + sampledBytes += size; + } + + return Math.ceil(sampledBytes * (totalDuration / sampledDuration)); +}; + +export const estimateGifSize = PCancelable.fn(async ( + video: Video, + options: ConversionOptions, + onCancel: PCancelable.OnCancelFunction +): Promise => { + const sampleRanges = getGifSampleRanges(options.startTime, options.endTime); + + if (sampleRanges.length === 0) { + return; + } + + await video.whenReady(); + + const sampleDirectory = tempy.directory(); + const samplePaths: string[] = []; + const sampleSizes: number[] = []; + let conversionProcess: ReturnType | undefined; + let isCanceled = false; + + onCancel(() => { + isCanceled = true; + conversionProcess?.cancel(); + }); + + try { + for (const [index, sample] of sampleRanges.entries()) { + if (isCanceled) { + return; + } + + const samplePath = path.join(sampleDirectory, `sample-${index}.gif`); + samplePaths.push(samplePath); + conversionProcess = convertTo( + Format.gif, + { + ...options, + defaultFileName: `sample-${index}`, + outputPath: samplePath, + startTime: sample.startTime, + endTime: sample.endTime, + inputPath: video.filePath, + // Estimation is not an export and should not affect conversion analytics. + shouldTrack: false, + // Edit services may be interactive or have side effects. The renderer does not + // request an estimate while one is selected, and this is a final safety net. + editService: undefined, + onCancel: noop, + onProgress: noop + }, + video.encoding + ); + + await conversionProcess; + + if (isCanceled) { + return; + } + + sampleSizes.push((await fs.promises.stat(samplePath)).size); + } + + const totalDuration = options.endTime - options.startTime; + const bytes = extrapolateGifSize(sampleSizes, sampleRanges, totalDuration); + + if (bytes === undefined) { + return; + } + + let sampledDuration = 0; + + for (const sample of sampleRanges) { + sampledDuration += sample.endTime - sample.startTime; + } + + return { + bytes, + sampleCount: sampleRanges.length, + sampledDuration + }; + } finally { + await Promise.all(samplePaths.map(async samplePath => fs.promises.unlink(samplePath).catch(noop))); + await fs.promises.rmdir(sampleDirectory).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..4cbb83a3 --- /dev/null +++ b/renderer/components/editor/options/gif-size-estimate.tsx @@ -0,0 +1,136 @@ +import {useEffect, useRef, useState} from 'react'; +import prettyBytes from 'pretty-bytes'; +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 VideoControlsContainer from '../video-controls-container'; +import VideoTimeContainer from '../video-time-container'; + +const estimateDelay = 500; + +type Estimate = { + bytes: number; + sampleCount: number; + sampledDuration: number; +}; + +const GifSizeEstimate = () => { + const {format, width, height, fps, editPlugin} = OptionsContainer.useContainer(); + const {startTime, endTime} = VideoTimeContainer.useContainer(); + const {isMuted} = VideoControlsContainer.useContainer(); + const {filePath} = useEditorWindowState(); + const {estimateGifSize, cancelGifSizeEstimate} = useEditorOptions(); + + const [estimate, setEstimate] = useState(); + const [isEstimating, setIsEstimating] = useState(false); + const [hasFailed, setHasFailed] = useState(false); + const requestId = useRef(0); + + useEffect(() => { + const canEstimate = ( + format === Format.gif && + !editPlugin && + Boolean(filePath) && + Number.isFinite(width) && + Number.isFinite(height) && + Number.isFinite(fps) && + width > 0 && + height > 0 && + fps > 0 && + endTime > startTime && + Boolean(estimateGifSize) + ); + + if (!canEstimate) { + requestId.current++; + cancelGifSizeEstimate?.(); + setEstimate(undefined); + setIsEstimating(false); + setHasFailed(false); + return; + } + + const id = ++requestId.current; + setEstimate(undefined); + setIsEstimating(true); + setHasFailed(false); + + const timer = window.setTimeout(() => { + estimateGifSize({ + filePath, + conversionOptions: { + width, + height, + startTime, + endTime, + fps, + shouldCrop: true, + shouldMute: isMuted + } + }).then(result => { + if (id === requestId.current) { + setEstimate(result); + setHasFailed(!result); + } + }).catch(() => { + if (id === requestId.current) { + setEstimate(undefined); + setHasFailed(true); + } + }).finally(() => { + if (id === requestId.current) { + setIsEstimating(false); + } + }); + }, estimateDelay); + + return () => { + window.clearTimeout(timer); + cancelGifSizeEstimate?.(); + }; + }, [cancelGifSizeEstimate, editPlugin, endTime, estimateGifSize, filePath, format, fps, height, isMuted, startTime, width]); + + if (format !== Format.gif) { + return null; + } + + let label = 'GIF —'; + let title = 'GIF size estimate unavailable'; + + if (editPlugin) { + title = 'GIF size estimate is unavailable while an edit plugin is enabled'; + } else if (isEstimating) { + label = 'GIF …'; + title = 'Estimating GIF size'; + } else if (estimate) { + label = `GIF ≈${prettyBytes(estimate.bytes)}`; + title = estimate.sampleCount === 1 ? + 'Estimated by converting the selected clip' : + `Estimated from ${estimate.sampleCount} samples spread across the selected clip`; + } else if (hasFailed) { + title = 'GIF size estimate failed'; + } + + 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 = () => {
+