diff --git a/app/components-react/highlighter/ClipsViewModal.tsx b/app/components-react/highlighter/ClipsViewModal.tsx index 4e73aa085be9..ccd2bf187267 100644 --- a/app/components-react/highlighter/ClipsViewModal.tsx +++ b/app/components-react/highlighter/ClipsViewModal.tsx @@ -48,7 +48,7 @@ export default function ClipsViewModal({ { trim: '60%', preview: '700px', - export: '700px', + export: 'fit-content', remove: '400px', }[modal], ); diff --git a/app/components-react/highlighter/EducationCarousel.tsx b/app/components-react/highlighter/EducationCarousel.tsx index 5fc5773905b1..c2d5636a7f34 100644 --- a/app/components-react/highlighter/EducationCarousel.tsx +++ b/app/components-react/highlighter/EducationCarousel.tsx @@ -119,28 +119,24 @@ const SupportedGameModes = () => (
@@ -163,7 +159,6 @@ const Overlay = () => ( @@ -176,7 +171,6 @@ const Overlay = () => ( {' '} @@ -187,7 +181,6 @@ const Overlay = () => ( diff --git a/app/components-react/highlighter/ExportModal.m.less b/app/components-react/highlighter/ExportModal.m.less index 0d3302d9ddd9..eddf45f03f0d 100644 --- a/app/components-react/highlighter/ExportModal.m.less +++ b/app/components-react/highlighter/ExportModal.m.less @@ -22,3 +22,262 @@ .log-in { text-align: center; } + +.modal-wrapper { + :global { + /* Hide labels */ + .ant-form-item-label, + [class*='ant-col'] .ant-form-item-label { + display: none !important; + } + + /* Hide the button addon */ + .ant-input-group-addon, + .ant-input-wrapper .ant-input-group-addon, + .ant-input-group-wrapper .ant-input-group-addon { + position: absolute; + left: 88%; + bottom: 24px; + background-color: transparent; + } + + /* Style the input */ + .ant-input, + input.ant-input, + .ant-input-disabled, + input.ant-input-disabled { + border: none !important; + background-color: transparent !important; + font-family: monospace !important; + box-shadow: none !important; + padding: 0 !important; + color: inherit !important; + } + + /* Fix overall form item spacing */ + .ant-form-item, + .ant-row.ant-form-item { + margin-bottom: 8px !important; + } + + /* Adjust column widths */ + .ant-col.ant-form-item-control { + flex: 1 1 100% !important; + max-width: 100% !important; + } + } +} + +.settings-and-progress { + width: 422px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.path-wrapper { + display: flex; + justify-content: space-between; + flex-direction: column; + margin-top: 12px; + width: 100%; +} + +.custom-input { + width: 100%; + border: none; + outline: none; + background-color: transparent; + padding: 0; + color: inherit; +} + +.stream-path { + font-family: monospace; + font-size: 10px; + opacity: 0.8; +} + +.thumbnail { + position: relative; + border-radius: 8px; + overflow: hidden; + max-height: 390px; + margin: 0 auto; + + --thumbHeight: 180px; + overflow-x: clip; + + height: calc(var(--thumbHeight) * 1.32); + background: var(--Day-Colors-Dark-4, #4f5e65); +} + +.thumbnail-in-progress img { + position: relative; + filter: blur(10px); +} + +.thumbnail img { + height: 100%; +} + +.thumbnail::before { + content: ''; + border-style: solid; + border-width: 1px; + border-color: #ffffff29; + border-radius: 8px; + position: absolute; + width: 100%; + height: 100%; +} + +.clip-info-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + +.progress-item { + position: absolute; + width: 100%; + padding: 24px; + z-index: 10; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.progress-item h1 { + font-size: 32px; + margin: 0; + margin-bottom: -8px; + font-weight: 600; + color: white; +} + +.progress-item p { + margin: 0; + font-size: 14px; + color: white; +} + +.is-disabled { + pointer-events: none; + opacity: 0.7; +} + +.inner-dropdown-wrapper { + position: relative; + border-radius: 8px; + width: 100%; + height: 40px; + background-color: var(--dropdown-alt-bg); + padding: 4px; + padding-left: 12px; + padding-right: 14px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--title); + cursor: pointer; +} + +.inner-item-wrapper { + position: absolute; + width: 100%; + background-color: #232d35; + padding: 4px; + border-radius: 8px; +} +.inner-dropdown-item { + color: white; + width: 100%; + height: 32px; + border-radius: 4px; + background-color: #232d35; + display: flex; + align-items: center; + padding-left: 8px; + cursor: pointer; +} + +.inner-dropdown-item.active { + background-color: #e6e8f807; + cursor: default; +} + +.inner-dropdown-item:hover { + background-color: rgba(230, 232, 248, 0.12); +} + +.dropdown-text { + font-size: 16px; + display: flex; + align-items: center; +} +.dropdown-text p { + margin: 0; + font-size: 12px; + margin-left: 8px; + opacity: 0.6; + font-size: 12px; +} + +.custom-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; +} + +.custom-item-wrapper { + display: flex; + justify-content: space-between; + width: 100%; + padding-left: 14px; + align-items: center; +} + +.orientation-toggle { + display: flex; + padding: 4px; + gap: 4px; + border-radius: 8px; + background-color: var(--dropdown-alt-bg); + cursor: pointer; + box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.13), 0px 1px 4px 0px rgba(0, 0, 0, 0.13); +} + +.orientation-button { + width: 32px; + height: 32px; + border-radius: 4px; + display: grid; + place-content: center; + opacity: 0.6; + background-color: transparent; + + &.active { + background-color: var(--dropdown-bg); + opacity: 1; + } +} + +.vertical-icon { + width: 14px; + height: 22px; + border: 2px solid var(--title); + border-radius: 3px; +} + +.horizontal-icon { + width: 22px; + height: 14px; + border: 2px solid var(--title); + border-radius: 3px; +} diff --git a/app/components-react/highlighter/ExportModal.tsx b/app/components-react/highlighter/ExportModal.tsx index 56e2dee37a7e..388b4eaaad44 100644 --- a/app/components-react/highlighter/ExportModal.tsx +++ b/app/components-react/highlighter/ExportModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { EExportStep, TFPS, @@ -9,7 +9,7 @@ import { Services } from 'components-react/service-provider'; import { FileInput, TextInput, ListInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import path from 'path'; -import { Button, Progress, Alert } from 'antd'; +import { Button, Progress, Alert, Dropdown } from 'antd'; import YoutubeUpload from './YoutubeUpload'; import { RadioInput } from 'components-react/shared/inputs/RadioInput'; import { confirmAsync } from 'components-react/modals'; @@ -17,9 +17,20 @@ import { $t } from 'services/i18n'; import StorageUpload from './StorageUpload'; import { useVuex } from 'components-react/hooks'; import { initStore, useController } from '../hooks/zustand'; -import { TOrientation } from 'services/highlighter/models/ai-highlighter.models'; +import { EOrientation, TOrientation } from 'services/highlighter/models/ai-highlighter.models'; import { fileExists } from 'services/highlighter/file-utils'; - +import { SCRUB_HEIGHT, SCRUB_WIDTH, SCRUB_FRAMES } from 'services/highlighter/constants'; +import styles from './ExportModal.m.less'; +import { getCombinedClipsDuration } from './utils'; +import { formatSecondsToHMS } from './ClipPreview'; +import cx from 'classnames'; + +type TSetting = { name: string; fps: TFPS; resolution: TResolution; preset: TPreset }; +const settings: TSetting[] = [ + { name: 'Standard', fps: 30, resolution: 1080, preset: 'fast' }, + { name: 'Best', fps: 60, resolution: 1080, preset: 'slow' }, + { name: 'Custom', fps: 30, resolution: 720, preset: 'ultrafast' }, +]; class ExportController { get service() { return Services.HighlighterService; @@ -37,6 +48,16 @@ class ExportController { ); } + getClips(streamId?: string) { + return this.service.getClips(this.service.views.clips, streamId).filter(clip => clip.enabled); + } + getClipThumbnail(streamId?: string) { + return this.getClips(streamId).find(clip => clip.enabled)?.scrubSprite; + } + getDuration(streamId?: string) { + return getCombinedClipsDuration(this.getClips(streamId)); + } + dismissError() { return this.service.actions.dismissError(); } @@ -60,7 +81,10 @@ class ExportController { this.service.actions.setExportFile(exportFile); } - exportCurrentFile(streamId: string | undefined, orientation: TOrientation = 'horizontal') { + exportCurrentFile( + streamId: string | undefined, + orientation: TOrientation = EOrientation.HORIZONTAL, + ) { this.service.actions.export(false, streamId, orientation); } @@ -108,10 +132,10 @@ function ExportModal({ close, streamId }: { close: () => void; streamId: string // Clear all errors when this component unmounts useEffect(() => unmount, []); - if (exportInfo.exporting) return ; - if (!exportInfo.exported) { + if (!exportInfo.exported || exportInfo.exporting) { return ( - void; streamId: string return ; } -function ExportProgress() { - const { exportInfo, cancelExport } = useController(ExportModalCtx); - - return ( -
-

{$t('Export Progress')}

- - {!exportInfo.cancelRequested && exportInfo.step === EExportStep.FrameRender && ( -
- {$t('Rendering Frames: %{currentFrame}/%{totalFrames}', { - currentFrame: exportInfo.currentFrame, - totalFrames: exportInfo.totalFrames, - })} -
- )} - {!exportInfo.cancelRequested && exportInfo.step === EExportStep.AudioMix && ( -
- {$t('Mixing Audio:')} - -
- )} - {exportInfo.cancelRequested && {$t('Canceling...')}} -
- -
- ); -} - -function ExportOptions({ +function ExportFlow({ close, + isExporting, streamId, videoName, onVideoNameChange, }: { close: () => void; + isExporting: boolean; streamId: string | undefined; videoName: string; onVideoNameChange: (name: string) => void; @@ -175,6 +162,7 @@ function ExportOptions({ const { UsageStatisticsService } = Services; const { exportInfo, + cancelExport, dismissError, setResolution, setFps, @@ -183,8 +171,43 @@ function ExportOptions({ setExport, exportCurrentFile, getStreamTitle, + getClips, + getDuration, + getClipThumbnail, } = useController(ExportModalCtx); + const [currentFormat, setCurrentFormat] = useState(EOrientation.HORIZONTAL); + + const clipsAmount = getClips(streamId).length; + const clipsDuration = formatSecondsToHMS(getDuration(streamId)); + + function settingMatcher(initialSetting: TSetting) { + const matchingSetting = settings.find( + setting => + setting.fps === initialSetting.fps && + setting.resolution === initialSetting.resolution && + setting.preset === initialSetting.preset, + ); + if (matchingSetting) { + return matchingSetting; + } + return { + name: 'Custom', + fps: initialSetting.fps, + resolution: initialSetting.resolution, + preset: initialSetting.preset, + }; + } + + const [currentSetting, setSetting] = useState( + settingMatcher({ + name: 'from default', + fps: exportInfo.fps, + resolution: exportInfo.resolution, + preset: exportInfo.preset, + }), + ); + // Video name and export file are kept in sync const [exportFile, setExportFile] = useState(getExportFileFromVideoName(videoName)); @@ -220,84 +243,211 @@ function ExportOptions({ } return ( -
-

Export Video

-
- { - onVideoNameChange(name); - setExportFile(getExportFileFromVideoName(name)); - }} - uncontrolled={false} - /> - { - setExportFile(file); - onVideoNameChange(getVideoNameFromExportFile(file)); - }} - /> - - - - {exportInfo.error && ( - - )} -
- - - - + +
+
+

{$t('Export')}

{' '} +
+ +
- -
+
+
+
+

+ { + const name = e.target.value; + onVideoNameChange(name); + setExportFile(getExportFileFromVideoName(name)); + }} + /> +

+ { + setExportFile(file); + onVideoNameChange(getVideoNameFromExportFile(file)); + }} + buttonContent={} + /> +
+ +
+ {isExporting && ( +
+

+ {Math.round((exportInfo.currentFrame / exportInfo.totalFrames) * 100) || 0}% +

+

+ {exportInfo.cancelRequested ? ( + {$t('Canceling...')} + ) : ( + {$t('Exporting video...')} + )} +

+ +
+ )} + +
+ +
+
+

+ {clipsDuration} | {$t('%{clipsAmount} clips', { clipsAmount })} +

+
+ setCurrentFormat(format)} + /> +
+ +
+ { + setSetting(setting); + if (setting.name !== 'Custom') { + setFps(setting.fps.toString()); + setResolution(setting.resolution.toString()); + setPreset(setting.preset); + } + }} + /> +
+ {currentSetting.name === 'Custom' && ( +
+
+

{$t('Resolution')}

+ +
+ +
+

{$t('Frame Rate')}

+ +
+ +
+

{$t('File Size')}

+ +
+
+ )} + + {exportInfo.error && ( + + )} +
+ {isExporting ? ( + + ) : ( + + )} +
+
{' '} +
+
+ ); } @@ -347,3 +497,104 @@ function PlatformSelect({ ); } + +function CustomDropdownWrapper({ + initialSetting, + disabled, + emitSettings, +}: { + initialSetting: TSetting; + disabled: boolean; + emitSettings: (settings: TSetting) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [currentSetting, setSetting] = useState(initialSetting); + + return ( +
+ + {settings.map(setting => { + return ( +
{ + setSetting(setting); + emitSettings(setting); + setIsOpen(false); + }} + key={setting.name} + > +
+ {setting.name}{' '} + {setting.name !== 'Custom' && ( + <> +

{setting.fps}fps

{setting.resolution}p

+ + )} +
+
+ ); + })} +
+ } + trigger={['click']} + visible={isOpen} + onVisibleChange={setIsOpen} + placement="bottomLeft" + > +
setIsOpen(!isOpen)}> +
+ {currentSetting.name}{' '} + {currentSetting.name !== 'Custom' && ( + <> +

{currentSetting.fps}fps

{currentSetting.resolution}p

+ + )} +
+ +
+ +
+ ); +} + +function OrientationToggle({ + initialState, + disabled, + emitState, +}: { + initialState: TOrientation; + disabled: boolean; + emitState: (state: TOrientation) => void; +}) { + const [currentFormat, setCurrentFormat] = useState(initialState); + + function setFormat(format: TOrientation) { + setCurrentFormat(format); + emitState(format); + } + return ( +
+
setFormat(EOrientation.VERTICAL)} + > +
+
+
setFormat(EOrientation.HORIZONTAL)} + > +
+
+
+ ); +} diff --git a/app/components-react/highlighter/StreamCard.tsx b/app/components-react/highlighter/StreamCard.tsx index 15d4b0b4f881..77a9c96b9fe1 100644 --- a/app/components-react/highlighter/StreamCard.tsx +++ b/app/components-react/highlighter/StreamCard.tsx @@ -378,7 +378,6 @@ export function Thumbnail({ clips.find(clip => clip?.streamInfo?.[stream.id]?.orderPosition === 0)?.scrubSprite || clips.find(clip => clip.scrubSprite)?.scrubSprite } - alt="" />
(null); - const [modalWidth, setModalWidth] = useState('700px'); const [clipsOfStreamAreLoading, setClipsOfStreamAreLoading] = useState(null); - // This is kind of weird, but ensures that modals stay the right - // size while the closing animation is played. This is why modal - // width has its own state. This makes sure we always set the right - // size whenever displaying a modal. function setShowModal(modal: TModalStreamView | null) { rawSetShowModal(modal); - - if (modal && modal.type) { - setModalWidth( - { - trim: '60%', - preview: '700px', - export: '700px', - remove: '400px', - upload: '400px', - requirements: '400px', - }[modal.type], - ); - } } async function previewVideo(id: string) { @@ -306,7 +288,7 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS getContainer={`.${styles.streamViewRoot}`} onCancel={closeModal} footer={null} - width={modalWidth} + width={showModal?.type === 'preview' ? 700 : 'fit-content'} closable={false} visible={!!showModal} destroyOnClose={true} diff --git a/app/components-react/shared/inputs/FileInput.tsx b/app/components-react/shared/inputs/FileInput.tsx index c9fb3e4cab46..6b961966ef16 100644 --- a/app/components-react/shared/inputs/FileInput.tsx +++ b/app/components-react/shared/inputs/FileInput.tsx @@ -7,7 +7,12 @@ import InputWrapper from './InputWrapper'; import { $t } from '../../../services/i18n'; type TFileInputProps = TSlobsInputProps< - { directory?: boolean; filters?: Electron.FileFilter[]; save?: boolean }, + { + directory?: boolean; + filters?: Electron.FileFilter[]; + save?: boolean; + buttonContent?: React.ReactNode; + }, string, InputProps >; @@ -53,9 +58,13 @@ export const FileInput = InputComponent((p: TFileInputProps) => { inputAttrs?.onChange(val.target.value)} - disabled value={p.value} - addonAfter={} + disabled + addonAfter={ + + } /> ); diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json index 7dbe7ce46a34..0e548c29441f 100644 --- a/app/i18n/en-US/highlighter.json +++ b/app/i18n/en-US/highlighter.json @@ -170,5 +170,7 @@ "• Game mode is supported (Battle Royale, Reload, Zero Build, OG)": "• Game mode is supported (Battle Royale, Reload, Zero Build, OG)", "Show details": "Show details", "All requirements met but no luck?": "All requirements met but no luck?", - "Take a screenshot of your stream and share it here": "Take a screenshot of your stream and share it here" + "Take a screenshot of your stream and share it here": "Take a screenshot of your stream and share it here", + "Exporting video...": "Exporting video...", + "%{clipsAmount} clips": "%{clipsAmount} clips" } \ No newline at end of file diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 8add8b5efbcf..c098acc6b124 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -64,6 +64,7 @@ import { IHighlight, IHighlighterMilestone, IInput, + EOrientation, EGame, } from './models/ai-highlighter.models'; import { HighlighterViews } from './highlighter-views'; @@ -111,8 +112,8 @@ export class HighlighterService extends PersistentStatefulService; @@ -26,8 +31,6 @@ export enum EGame { UNSET = 'unset', } -export type TOrientation = 'horizontal' | 'vertical'; - export enum EHighlighterInputTypes { KILL = 'kill', KNOCKED = 'knocked',