diff --git a/app/components-react/highlighter/ClipsView.m.less b/app/components-react/highlighter/ClipsView.m.less index 50c069546b28..948248e9023c 100644 --- a/app/components-react/highlighter/ClipsView.m.less +++ b/app/components-react/highlighter/ClipsView.m.less @@ -8,6 +8,9 @@ :global(.ant-modal-wrap), :global(.ant-modal-mask) { position: absolute; + top: 0 !important; + height: 100% !important; + overflow: hidden !important; } :global(.os-content-glue) { width: 100% !important; diff --git a/app/components-react/highlighter/ClipsView.tsx b/app/components-react/highlighter/ClipsView.tsx index 02e5fa9e1ebb..3924dcfe7596 100644 --- a/app/components-react/highlighter/ClipsView.tsx +++ b/app/components-react/highlighter/ClipsView.tsx @@ -211,6 +211,7 @@ export default function ClipsView({ + {sortedList.length === 0 ? (
{$t('No clips found')} @@ -307,8 +308,10 @@ export default function ClipsView({ )}
{ - setModal({ modal }); + emitSetShowModal={(modal: TModalClipsView | null) => { + if (modal) { + setModal({ modal }); + } }} /> ; + +interface HypeWrapperProps { + gameConfig: GameConfig; + isAnimating: boolean; + artwork: string | undefined; + children: React.ReactNode; +} + +export function HypeWrapper({ gameConfig, isAnimating, artwork, children }: HypeWrapperProps) { + return ( +
+
+ +
+
+
+
+
+

+ {$t('Turn your gameplay into epic highlight reels')} +

+

+ {$t('Dominate, showcase, inspire!')} +

+
+ {gameConfig?.importModalConfig?.horizontalExampleVideo && + gameConfig?.importModalConfig?.verticalExampleVideo && ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {' '} + +
+
+ )} +
+
+
+
+
+
+ {children} +
+
+ ); +} + +export function YouTubeLogo() { + return ( + + + + + ); +} + +export function DiscordLogo() { + return ( + + + + + ); +} + +export function TikTokLogo() { + return ( + + + + + + ); +} + +export function InstagramLogo() { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/components-react/highlighter/ImportStream.tsx b/app/components-react/highlighter/ImportStream.tsx index 58c92b3cf54b..9e6771aef27e 100644 --- a/app/components-react/highlighter/ImportStream.tsx +++ b/app/components-react/highlighter/ImportStream.tsx @@ -1,5 +1,4 @@ import { Button, Form, Select } from 'antd'; -import cx from 'classnames'; import { Services } from 'components-react/service-provider'; import { ListInput, TextInput } from 'components-react/shared/inputs'; import * as remote from '@electron/remote'; @@ -10,11 +9,15 @@ import { TOpenedFrom, } from 'services/highlighter/models/highlighter.models'; import { $t } from 'services/i18n'; -import uuid from 'uuid'; import React, { useEffect, useRef, useState } from 'react'; import styles from './StreamView.m.less'; import { getConfigByGame, supportedGames } from 'services/highlighter/models/game-config.models'; import path from 'path'; +import MigrationNotice from './migration/MigrationNotice'; +import { HypeWrapper } from './HypeWrapper'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +type GameConfig = ReturnType; export function ImportStreamModal({ close, @@ -29,7 +32,21 @@ export function ImportStreamModal({ selectedGame?: EGame; streamInfo?: IStreamInfoForAiHighlighter; }) { - const { HighlighterService, UsageStatisticsService } = Services; + const { HighlighterService, UsageStatisticsService, IncrementalRolloutService } = Services; + const [replayInstalled, setReplayInstalled] = useState(null); + const [showingInstallFlow, setShowingInstallFlow] = useState(false); + const [pendingImport, setPendingImport] = useState<{ + game: EGame; + filePath: string; + streamId?: string; + } | null>(null); + + useEffect(() => { + HighlighterService.isStreamlabsReplayInstalled().then(installed => { + setReplayInstalled(installed); + }); + }, []); + const [inputValue, setInputValue] = useState(streamInfo?.title || ''); const [filePath, setFilePath] = useState(videoPath); const [draggingOver, setDraggingOver] = useState(false); @@ -40,6 +57,9 @@ export function ImportStreamModal({ ); const gameOptions = supportedGames; const gameConfig = getConfigByGame(game); + const migrationEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.highlighterMigration, + ); function handleInputChange(value: string) { setInputValue(value); @@ -76,46 +96,34 @@ export function ImportStreamModal({ } close(); } - async function startAiDetection( - title: string, - game: EGame, - filePath: string[] | undefined, - id?: string, - ) { - const streamInfo: IStreamInfoForAiHighlighter = { - id: id ?? 'manual_' + uuid(), - title: title.replace(/[\\/:"*?<>|]+/g, ''), - game, - }; + async function startImport(game: EGame, filePath: string[] | undefined, id?: string) { try { + // Check if Replay is installed, auto-install for after-stream flow + const isInstalled = await HighlighterService.isStreamlabsReplayInstalled(); + if (!isInstalled && openedFrom === 'after-stream') { + // Store import details and start installation + if (filePath && filePath.length > 0) { + setPendingImport({ game, filePath: filePath[0], streamId: id }); + setShowingInstallFlow(true); + HighlighterService.installStreamlabsReplay(); + } + return; + } + if (game && filePath && filePath.length > 0) { - HighlighterService.actions.detectAndClipAiHighlights(filePath[0], streamInfo); - UsageStatisticsService.recordAnalyticsEvent('AIHighlighter', { - type: 'DetectionInModalStarted', - openedFrom, - streamId: id, - game, - }); + HighlighterService.actions.openReplayImport(filePath[0], game, openedFrom, id, inputValue); closeModal(false); return; } filePath = await importStreamFromDevice(); if (filePath && filePath.length > 0) { - HighlighterService.actions.detectAndClipAiHighlights(filePath[0], streamInfo); - UsageStatisticsService.recordAnalyticsEvent('AIHighlighter', { - type: 'DetectionInModalStarted', - openedFrom, - streamId: id, - game, - }); + HighlighterService.actions.openReplayImport(filePath[0], game, openedFrom, id, inputValue); closeModal(false); - } else { - // No file selected } } catch (error: unknown) { - console.error('Error importing file from device', error); + console.error('Error importing file via Replay deeplink', error); } } const [artwork, setArtwork] = useState( @@ -131,363 +139,203 @@ export function ImportStreamModal({ }, 200); // Match the duration of the CSS animation } }, [gameConfig?.importModalConfig?.artwork]); - return ( - <> -
{ + setShowingInstallFlow(false); + closeModal(true); }} - > -
- -
-
-
-
-
-

- {$t('Turn your gameplay into epic highlight reels')} -

-

- {$t('Dominate, showcase, inspire!')} -

-
- {gameConfig?.importModalConfig?.horizontalExampleVideo && - gameConfig?.importModalConfig?.verticalExampleVideo && ( -
-
- -
-
- -
+ onInstallComplete={() => { + setReplayInstalled(true); + setShowingInstallFlow(false); -
- -
+ // If there's a pending import, execute it now + if (pendingImport) { + HighlighterService.actions.openReplayImport( + pendingImport.filePath, + pendingImport.game, + openedFrom, + pendingImport.streamId, + inputValue, + ); + setPendingImport(null); + closeModal(false); + } + }} + /> + ); + } -
- -
-
- -
-
- {' '} - -
-
- )} -
-
-
-
+ // Show MigrationNotice if Replay is not installed + // For non-after-stream flows (Highlighter component), show install UI immediately + // For after-stream flow, only show when user triggers installation + if ( + migrationEnabled && + replayInstalled === false && + (openedFrom !== 'after-stream' || showingInstallFlow) + ) { + return ( + + {renderMigrationNotice()} + + ); + } + + return ( + +
+

+ {openedFrom === 'after-stream' ? 'Ai Highlighter' : `${$t('Import Game Recording')}`} +

{' '} +
+
-
-
-

- {openedFrom === 'after-stream' ? 'Ai Highlighter' : `${$t('Import Game Recording')}`} -

{' '} -
- -
-
+
- -
{ - const path = await importStreamFromDevice(); - setFilePath(path ? path[0] : undefined); - }} - onDragOver={e => { - e.preventDefault(); - setDraggingOver(true); - }} - onDrop={e => { - const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); - const files: string[] = []; - let fi = e.dataTransfer.files.length; - while (fi--) { - const file = e.dataTransfer.files.item(fi)?.path; - if (file) files.push(file); - } - const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); - if (filtered.length) { - setFilePath(filtered[0]); - setDraggingOver(false); - } + +
{ + const path = await importStreamFromDevice(); + setFilePath(path ? path[0] : undefined); + }} + onDragOver={e => { + e.preventDefault(); + setDraggingOver(true); + }} + onDrop={e => { + const extensions = SUPPORTED_FILE_TYPES.map(e => `.${e}`); + const files: string[] = []; + let fi = e.dataTransfer.files.length; + while (fi--) { + const file = e.dataTransfer.files.item(fi)?.path; + if (file) files.push(file); + } + const filtered = files.filter(f => extensions.includes(path.parse(f).ext)); + if (filtered.length) { + setFilePath(filtered[0]); + setDraggingOver(false); + } - e.preventDefault(); - e.stopPropagation(); - }} - onDragLeave={() => setDraggingOver(false)} - className={styles.videoPreview} - style={ - { - '--border-style': filePath ? 'solid' : 'dashed', - '--border-color': draggingOver ? 'var(--teal)' : 'var(--midtone)', - cursor: 'pointer', - } as React.CSSProperties - } + e.preventDefault(); + e.stopPropagation(); + }} + onDragLeave={() => setDraggingOver(false)} + className={styles.videoPreview} + style={ + { + '--border-style': filePath ? 'solid' : 'dashed', + '--border-color': draggingOver ? 'var(--teal)' : 'var(--midtone)', + cursor: 'pointer', + } as React.CSSProperties + } + > + {filePath ? ( + + ) : ( +
) => {}} + style={{ display: 'grid', placeItems: 'center' }} > - {filePath ? ( - - ) : ( -
) => {}} - style={{ display: 'grid', placeItems: 'center' }} - > - -

- {$t('Drag and drop game recording or click to select')} -

-
- )} -
-
-

+

- {$t('Select game played in recording')} -

- { - onSelect(opts.value); - }} - onChange={value => { - setGame(value || null); - }} - placeholder={$t('Start typing to search')} - options={gameOptions} - defaultValue={game} - showSearch - optionRender={option => ( -
- {option.image && ( - {option.label} - )} - {option.label} - {option.description} -
- )} - debounce={500} - allowClear - /> - - -
- {openedFrom === 'after-stream' && ( - - )} - - + {$t('Drag and drop game recording or click to select')} +

-
+ )}
- - ); -} - -export function YouTubeLogo() { - return ( - - - - - ); -} - -export function DiscordLogo() { - return ( - - - - - ); -} +
+

+ {$t('Select game played in recording')} +

+ { + onSelect(opts.value); + }} + onChange={value => { + setGame(value || null); + }} + placeholder={$t('Start typing to search')} + options={gameOptions} + defaultValue={game} + showSearch + optionRender={option => ( +
+ {option.image && ( + {option.label} + )} + {option.label} + {option.description} +
+ )} + debounce={500} + allowClear + /> + -export function TikTokLogo() { - return ( - - - - - - ); -} +
+ {openedFrom === 'after-stream' && ( + + )} -export function InstagramLogo() { - return ( - - - - - - - - - - - - startImport(game!, filePath ? [filePath] : undefined, streamInfo?.id)} > - - - - - - + {filePath ? $t('Find game highlights') : $t('Select video and start import')} + +
+
+ {replayInstalled ? ( +

Continuing will open Streamlabs Highlighter

+ ) : ( +

Continuing will install Streamlabs Highlighter

+ )} +
+
); } diff --git a/app/components-react/highlighter/MigrationNotice.m.less b/app/components-react/highlighter/MigrationNotice.m.less new file mode 100644 index 000000000000..bdb286cf7a28 --- /dev/null +++ b/app/components-react/highlighter/MigrationNotice.m.less @@ -0,0 +1,472 @@ +@sectionWidth: 424px; +@sectionHeight: @sectionWidth; +@gap: 16px; + +.migration-notice { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 50vh; +} + +.migration-notice-modal { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.title { + font-size: 32px; + font-weight: 600; + margin: 0 0 16px 0; + line-height: 1.2; +} + +.title-modal { + font-size: 24px; +} + +.subtitle { + font-size: 16px; + opacity: 0.7; + margin: 0 0 32px 0; + line-height: 1.5; +} + +.actions { + display: flex; + gap: 16px; + justify-content: center; + align-items: center; +} + +// ================================================================================================= +// Installation Flow +// ================================================================================================= + +.installation-flow { + display: flex; + height: 100%; + flex-direction: column; + justify-content: space-between; +} + +.installation-flow-page { + max-height: 50vh; +} + +.installation-flow-modal { + max-height: 100%; +} + +.install-title { + font-size: 16px; + font-weight: 600; + align-self: flex-start; + margin: 0; +} + +.install-content { + text-align: center; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.progress-percent { + font-size: 48px; + font-weight: 700; + margin-bottom: 16px; +} + +.install-subtext { + font-size: 14px; + opacity: 0.7; + line-height: 1.6; + margin: 0; + text-align: center; +} + +.error-title { + font-size: 28px; + font-weight: 700; + color: var(--warning); + margin-bottom: 16px; +} + +.success-title { + font-size: 28px; + font-weight: 700; + color: var(--teal); + margin-bottom: 16px; +} + +.support-link { + color: inherit; + text-decoration: underline; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } +} + +.install-actions { + width: 100%; + max-width: 400px; +} + +.cancel-install-button { + width: 100%; + background: var(--button); + color: var(--title); + border: none; + padding: 14px 24px; + font-size: 14px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: var(--button-hover); + } + + &:active { + background: var(--dark-background); + } +} + +.retry-button { + width: 100%; + background: var(--title); + color: var(--background); + border: none; + padding: 14px 24px; + font-size: 14px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: var(--paragraph); + } + + &:active { + background: var(--border); + } +} + +// ================================================================================================= +// Feature Carousel +// ================================================================================================= + +.carousel-wrapper { + display: flex; + background-color: var(--dark-background); + border-radius: 10px; + padding: 48px; + border: solid 1px var(--border); + width: 100%; + gap: 64px; +} + +.carousel-left { + max-width: 550px; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 24px; +} + +.carousel-title-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.carousel-title { + font-size: 36px; + font-weight: 700; + line-height: 1.15; + margin: 0; +} + +.carousel-subtitle { + font-weight: 500; + font-style: italic; + opacity: 0.8; + margin: 0; +} + +.carousel-description { + font-size: 14px; + line-height: 1.6; + font-weight: 600; + opacity: 0.6; + margin: 0; +} + +.feature-list { + list-style: none; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + margin-left: -12px; +} + +.feature-item { + cursor: pointer; + border-radius: 9px; + padding: 4px 12px; + font-size: 14px; + line-height: 26px; + letter-spacing: -0.004em; + transition: color 0.2s, background-color 0.2s; + opacity: 0.6; + width: fit-content; + + &:hover { + opacity: 1; + } +} + +.feature-item-active { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + background: var(--section-alt); + border-radius: 9px; + font-weight: 700; + font-size: 14px; + line-height: 26px; + letter-spacing: -0.004em; + color: var(--title); + opacity: 1; + width: fit-content; +} + +.carousel-cta { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.cta-hint { + font-size: 14px; + opacity: 0.6; + font-style: italic; + margin: 0; +} + +.cta-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} + +.cta-annotation { + font-size: 14px; + opacity: 0.6; + font-style: italic; + margin: 0; +} + +// Carousel: progress bar (inline install states) + +.progress-wrapper { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.progress-left { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.progress-status { + font-size: 18px; + font-weight: 600; +} + +.progress-bar-row { + display: flex; + align-items: center; + max-width: 280px; + height: 20px; + gap: 12px; + justify-content: space-between; +} + +.progress-track { + height: 10px; + flex: 1; + overflow: hidden; + border-radius: 9999px; + background: var(--icon-semi); +} + +.progress-fill { + height: 100%; + border-radius: 9999px; + background: var(--teal); + transition: width 0.3s ease-out; +} + +.progress-fill-pulse { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.cancel-x { + background: none; + border: none; + color: var(--icon); + font-size: 18px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.2s; + + &:hover { + color: var(--title); + } +} + +.progress-pct-large { + font-size: 56px; + font-weight: 700; + line-height: 1; + opacity: 0.1; + white-space: nowrap; +} + +// Carousel: inline done / error states + +.carousel-done { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + gap: 4px; +} + +.carousel-done-title { + font-size: 14px; + font-weight: 600; +} + +.carousel-done-sub { + font-size: 12px; + opacity: 0.6; +} + +.carousel-error { + display: flex; + flex-direction: column; +} + +.carousel-error-title { + font-size: 14px; + font-weight: 600; + color: var(--warning); + margin-bottom: 4px; +} + +.carousel-error-sub { + font-size: 12px; + opacity: 0.6; + margin-bottom: 8px; +} + +// Carousel: right column (image area) + +.carousel-right { + position: relative; + flex: 1; + overflow: hidden; +} + +.carousel-card { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: 16px; +} + +.carousel-card-inner { + display: flex; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; +} + +.carousel-card-image { + height: 100%; + width: 100%; + object-fit: contain; +} + +.carousel-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px; + text-align: center; + opacity: 0.6; +} + +.carousel-placeholder-icon { + width: 64px; + height: 64px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.carousel-placeholder-icon-inner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.4); + border-radius: 4px; +} + +.carousel-placeholder-text { + font-size: 14px; + font-weight: 500; +} + +.handwritten-annotations { + font-family: 'Patrick Hand', cursive; + color: var(--teal); +} diff --git a/app/components-react/highlighter/StreamView.m.less b/app/components-react/highlighter/StreamView.m.less index 9a07422a6418..ad4e57e30bad 100644 --- a/app/components-react/highlighter/StreamView.m.less +++ b/app/components-react/highlighter/StreamView.m.less @@ -12,6 +12,9 @@ :global(.ant-modal-wrap), :global(.ant-modal-mask) { position: absolute; + top: 0 !important; + height: 100% !important; + overflow: hidden !important; } :global(.os-content-glue) { @@ -33,6 +36,9 @@ :global(.ant-modal-wrap), :global(.ant-modal-mask) { position: absolute; + top: 0 !important; + height: 100% !important; + overflow: hidden !important; } :global(.os-content-glue) { @@ -62,6 +68,13 @@ .stream-card-modal-root { position: relative; + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { + position: absolute; + top: 0 !important; + height: 100% !important; + overflow: hidden !important; + } } // Upload @@ -81,8 +94,9 @@ --fixed-height: 504px; --fixed-width: 441px; position: relative; - width: 80vw; height: 80vh; + width: 80vw; + max-width: 1500px; display: grid; border-radius: 24px; overflow: hidden; @@ -253,6 +267,7 @@ left: 50%; top: 50%; transform: translate(-50%, -50%); + height: 556px; // needs to be hardcoded. to match hypewrapper :global { /* Hide labels */ @@ -370,3 +385,14 @@ object-fit: cover; border-radius: 1px; } + +.explainer-text-wrapper { + display: flex; + justify-content: center; +} + +.explainer-text { + opacity: 60%; + text-align: center; + width: 100%; +} diff --git a/app/components-react/highlighter/StreamView.tsx b/app/components-react/highlighter/StreamView.tsx index b7ff83116154..8bbca0c581c3 100644 --- a/app/components-react/highlighter/StreamView.tsx +++ b/app/components-react/highlighter/StreamView.tsx @@ -26,6 +26,8 @@ import EducationCarousel from './EducationCarousel'; import { EGame } from 'services/highlighter/models/ai-highlighter.models'; import { ImportStreamModal } from './ImportStream'; import SupportedGames from './supportedGames/SupportedGames'; +import MigrationNotice from './migration/MigrationNotice'; +import { EAvailableFeatures } from 'services/incremental-rollout'; type TModalStreamView = { type: 'upload'; @@ -36,7 +38,12 @@ type TModalStreamView = { } | null; export default function StreamView({ emitSetView }: { emitSetView: (data: IViewState) => void }) { - const { HighlighterService, HotkeysService, UsageStatisticsService } = Services; + const { + HighlighterService, + HotkeysService, + UsageStatisticsService, + IncrementalRolloutService, + } = Services; const v = useVuex(() => ({ error: HighlighterService.views.error, uploadInfo: HighlighterService.views.uploadInfo, @@ -44,6 +51,10 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS tempRecordingInfoPath: HighlighterService.views.tempRecordingInfo.recordingPath, })); + const migrationEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.highlighterMigration, + ); + useEffect(() => { const recordingInfo = { ...HighlighterService.views.tempRecordingInfo }; HighlighterService.setTempRecordingInfo({}); @@ -169,6 +180,14 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS
+ {migrationEnabled && ( + { + emitSetView({ view: EHighlighterView.CLIPS, id: undefined }); + }} + /> + )} {highlightedStreams.length === 0 ? ( <>No highlight clips created from streams // TODO: Add empty state ) : ( @@ -211,6 +230,8 @@ export default function StreamView({ emitSetView }: { emitSetView: (data: IViewS visible={!!showModal} destroyOnClose={true} keyboard={false} + transitionName="" + maskTransitionName="" > {!!v.error && } {showModal?.type === 'upload' && v.highlighterVersion !== '' && ( diff --git a/app/components-react/highlighter/migration/FeatureCarousel.tsx b/app/components-react/highlighter/migration/FeatureCarousel.tsx new file mode 100644 index 000000000000..926ee87ee158 --- /dev/null +++ b/app/components-react/highlighter/migration/FeatureCarousel.tsx @@ -0,0 +1,274 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import cx from 'classnames'; +import styles from '../MigrationNotice.m.less'; +import FeatureItemCard from './FeatureItemCard'; + +export interface Feature { + id: string; + headline: string; + previewImage?: string; + description?: string; + topColor?: string; + bottomColor?: string; + videoUrl?: string; + blobColor?: string; + iconUrl?: string; +} + +export const CAROUSEL_FEATURES: Feature[] = [ + { + id: 'ai-reels', + headline: 'Auto created reels in seconds powered by Streamlabs AI', + topColor: '#19242A', + bottomColor: '#19242A', + previewImage: '/public/graphics/auto-create.png', + }, + { + id: 'subtitles', + headline: 'Automatic subtitles', + description: 'Add subtitles like the pros with a click of a button. Zero extra work.', + topColor: '#0C2C52', + bottomColor: '#0C2C52', + previewImage: '/public/graphics/subtitles.png', + }, + { + id: 'verticaliser', + headline: 'Ai Verticaliser', + description: 'Convert your horizontal Stream in to TikTok and Instagram formats.', + topColor: '#380E29', + bottomColor: '#380E29', + previewImage: '/public/graphics/layout.png', + blobColor: '#FE08AD', + }, + { + id: 'sharing', + headline: 'Grow everywhere', + description: + 'Direct sharing to YouTube, Discord and shortcuts to TikTok, Instagram or X. Grow on twitch and on all platforms.', + topColor: '#280e08', + bottomColor: '#2D1712', + previewImage: '/public/graphics/Grow.png', + }, + { + id: 'gameplay', + headline: 'Auto record gameplay', + description: + 'Not streaming? No problem! Your gameplay gets recorded automatically - if you want us to', + topColor: '#1E0101', + bottomColor: '#1E0101', + previewImage: '/public/graphics/auto-record.png', + blobColor: '#FF4655', + iconUrl: '/public/graphics/rec-icon.svg', + }, + { + id: 'titles', + headline: 'Get Pro Titles, Thumbnails and descriptions', + topColor: '#1a1a2e', + bottomColor: '#16213e', + }, +]; + +const ANIM_DURATION = 220; +const AUTO_ANIM_DURATION = 500; +const AUTO_ADVANCE_INTERVAL = 5000; + +interface IFeatureCarouselProps { + title: string; + description?: string; + features: Feature[]; + children?: React.ReactNode; +} + +type TransitionState = 'hover' | 'animating' | null; + +export default function FeatureCarousel(props: IFeatureCarouselProps) { + const { title, description, features, children } = props; + + const [currentIndex, setCurrentIndex] = useState(0); + const [incomingIndex, setIncomingIndex] = useState(null); + const [transitionState, setTransitionState] = useState(null); + const [animDuration, setAnimDuration] = useState(ANIM_DURATION); + const [direction, setDirection] = useState<-1 | 1>(1); + + const isListHoveredRef = useRef(false); + const isAnimatingRef = useRef(false); + const currentIndexRef = useRef(currentIndex); + const transitionStateRef = useRef(transitionState); + const incomingIndexRef = useRef(incomingIndex); + + currentIndexRef.current = currentIndex; + isAnimatingRef.current = transitionState === 'animating'; + transitionStateRef.current = transitionState; + incomingIndexRef.current = incomingIndex; + + const currentFeature = useMemo(() => features[currentIndex], [features, currentIndex]); + const incomingFeature = useMemo(() => (incomingIndex !== null ? features[incomingIndex] : null), [ + features, + incomingIndex, + ]); + + const currentCardStyle = useMemo(() => { + if (transitionState === 'animating') { + return { + transform: `translateY(${direction * -500}px)`, + opacity: 0, + transition: `transform ${animDuration}ms ease, opacity ${animDuration}ms ease`, + }; + } + return { transform: 'translateY(0)', opacity: 1, transition: 'none' }; + }, [transitionState, direction, animDuration]); + + const incomingCardStyle = useMemo(() => { + if (transitionState === 'hover') { + return { + transform: `translateY(${direction * 500}px)`, + opacity: 0, + transition: 'none', + }; + } + if (transitionState === 'animating') { + return { + transform: 'translateY(0)', + opacity: 1, + transition: `transform ${animDuration}ms ease, opacity ${animDuration}ms ease`, + }; + } + return {}; + }, [transitionState, direction, animDuration]); + + const finishAnimation = useCallback((index: number) => { + setCurrentIndex(index); + setIncomingIndex(null); + setTransitionState(null); + }, []); + + const selectFeature = useCallback( + (index: number, duration = ANIM_DURATION, forceDirection?: -1 | 1) => { + if (isAnimatingRef.current || index === currentIndexRef.current) return; + + setAnimDuration(duration); + const dir = forceDirection ?? (index < currentIndexRef.current ? -1 : 1); + setDirection(dir); + + const wasPreloaded = + transitionStateRef.current === 'hover' && incomingIndexRef.current === index; + setIncomingIndex(index); + + if (wasPreloaded) { + setTransitionState('animating'); + } else { + setTransitionState('hover'); + requestAnimationFrame(() => { + setTransitionState('animating'); + }); + } + + setTimeout(() => finishAnimation(index), duration); + }, + [finishAnimation], + ); + + const onListItemHover = useCallback((index: number) => { + if (index === currentIndexRef.current) { + isListHoveredRef.current = true; + return; + } + if (isAnimatingRef.current) return; + setDirection(index < currentIndexRef.current ? -1 : 1); + setIncomingIndex(index); + setTransitionState('hover'); + }, []); + + const onListItemLeave = useCallback((index: number) => { + if (index === currentIndexRef.current) { + isListHoveredRef.current = false; + return; + } + if (isAnimatingRef.current) return; + if (incomingIndexRef.current === index) { + setIncomingIndex(null); + setTransitionState(null); + } + }, []); + + // Auto-advance timer + useEffect(() => { + let timer: ReturnType; + + const schedule = () => { + timer = setTimeout(() => { + if (!isListHoveredRef.current && !isAnimatingRef.current) { + const next = (currentIndexRef.current + 1) % features.length; + selectFeature(next, AUTO_ANIM_DURATION, 1); + } + schedule(); + }, AUTO_ADVANCE_INTERVAL); + }; + + schedule(); + return () => clearTimeout(timer); + }, [features.length, selectFeature]); + + const renderCard = (feature: Feature) => ( + + ); + + return ( +
+ {/* Left column */} +
+
+

{title}

+
+ + {description &&

{description}

} + + {/* Feature list */} +
    + {features.map((feature, index) => ( +
  • onListItemHover(index)} + onMouseLeave={() => onListItemLeave(index)} + onClick={() => selectFeature(index)} + > + {feature.headline} +
  • + ))} +
+ + {/* CTA slot — rendered as children */} + {children &&
{children}
} +
+ + {/* Right column — animated cards */} +
+ {/* Current card */} +
+
+ {renderCard(currentFeature)} +
+
+ + {/* Incoming card */} + {incomingFeature && ( +
+
+ {renderCard(incomingFeature)} +
+
+ )} +
+
+ ); +} diff --git a/app/components-react/highlighter/migration/FeatureItemCard.m.less b/app/components-react/highlighter/migration/FeatureItemCard.m.less new file mode 100644 index 000000000000..e2ce5dd02a5c --- /dev/null +++ b/app/components-react/highlighter/migration/FeatureItemCard.m.less @@ -0,0 +1,95 @@ +// ================================================================================================= +// FeatureItemCard +// ================================================================================================= + +.card-gradient { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + width: fit-content; + min-height: 250px; + overflow: hidden; + border-radius: 16px; + box-shadow: 0 1.063px 0 0 rgba(255, 255, 255, 0.1) inset, + 0 0 0 1.063px rgba(255, 255, 255, 0.06) inset, 0 0 21.261px 3.189px rgba(7, 13, 79, 0.05), + 0 0 42.522px 21.261px rgba(7, 13, 79, 0.05); +} + +.blob { + position: absolute; + bottom: 0; + left: 50%; + z-index: 0; + width: 100%; + height: 100%; + transform: translateX(-75%) translateY(50%) rotate(9.319deg); + opacity: 0.9; + filter: blur(150px); + pointer-events: none; +} + +.card-content { + position: relative; + z-index: 1; + padding: 24px; +} + +.card-header { + display: flex; + align-items: center; + gap: 16px; +} + +.feature-icon-badge { + display: flex; + justify-content: center; + align-items: center; + padding: 2px; + width: fit-content; + min-width: 70px; + height: 32px; + margin-bottom: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.1) 100%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.05), + inset 0 -1px 0 rgba(0, 0, 0, 0.2); + border-radius: 8px; +} + +.icon-image { + max-height: 100%; + max-width: 100%; + flex-shrink: 0; + object-fit: contain; +} + +.headline { + margin-bottom: 12px; + font-size: 18px; + font-weight: 600; + line-height: 1.3; +} + +.description { + font-size: 14px; + line-height: 1.6; + opacity: 0.8; + margin: 0; +} + +.card-media { + position: relative; + z-index: 1; + height: 100%; +} + +.card-video { + height: 100%; + width: 100%; + object-fit: cover; +} + +.card-image { + width: 100%; + display: block; +} diff --git a/app/components-react/highlighter/migration/FeatureItemCard.tsx b/app/components-react/highlighter/migration/FeatureItemCard.tsx new file mode 100644 index 000000000000..53c85235648d --- /dev/null +++ b/app/components-react/highlighter/migration/FeatureItemCard.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import styles from './FeatureItemCard.m.less'; + +interface IFeatureItemCardProps { + topColor?: string; + bottomColor?: string; + headline?: string; + description?: string; + imageUrl?: string; + videoUrl?: string; + blobColor?: string; + iconUrl?: string; + children?: React.ReactNode; +} + +export default function FeatureItemCard(props: IFeatureItemCardProps) { + const { + topColor = '#1a1a2e', + bottomColor = '#16213e', + headline, + description, + imageUrl, + videoUrl, + blobColor, + iconUrl, + children, + } = props; + + const gradientStyle: React.CSSProperties = { + background: `linear-gradient(to bottom, ${topColor}, ${bottomColor})`, + }; + + return ( +
+ {blobColor &&
} + + {(headline || description) && ( +
+
+ {iconUrl && ( +
+ +
+ )} + {headline &&

{headline}

} +
+ {description &&

{description}

} +
+ )} + + {children || } +
+ ); +} + +function CardMedia(props: { videoUrl?: string; imageUrl?: string; headline?: string }) { + const { videoUrl, imageUrl, headline } = props; + + if (videoUrl) { + return ( +
+
+ ); + } + + if (imageUrl) { + return ( +
+ {headline +
+ ); + } + + return null; +} diff --git a/app/components-react/highlighter/migration/MigrationNotice.tsx b/app/components-react/highlighter/migration/MigrationNotice.tsx new file mode 100644 index 000000000000..654bef52fa6e --- /dev/null +++ b/app/components-react/highlighter/migration/MigrationNotice.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Services } from 'components-react/service-provider'; +import ModalInstallationFlow from './ModalInstallationFlow'; +import PageInstallationFlow from './PageInstallationFlow'; +import { EAvailableFeatures } from 'services/incremental-rollout'; + +interface IMigrationNoticeProps { + variant?: 'page' | 'modal'; + onShowAllClips?: () => void; + onCancel?: () => void; + onInstallComplete?: () => void; +} + +export default function MigrationNotice(props: IMigrationNoticeProps) { + const { IncrementalRolloutService } = Services; + const variant = props.variant || 'page'; + + const isMigrationEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.highlighterMigration, + ); + + if (!isMigrationEnabled) { + return null; + } + + function handleCancel() { + if (props.onCancel) { + props.onCancel(); + } + } + + function showAllClips() { + if (props.onShowAllClips) { + props.onShowAllClips(); + } + } + + if (variant === 'modal') { + return ( + + ); + } + + return ( +
+ +
+ ); +} diff --git a/app/components-react/highlighter/migration/ModalInstallationFlow.tsx b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx new file mode 100644 index 000000000000..992cc10a4544 --- /dev/null +++ b/app/components-react/highlighter/migration/ModalInstallationFlow.tsx @@ -0,0 +1,155 @@ +import React, { useEffect } from 'react'; +import { Button } from 'antd'; +import cx from 'classnames'; +import styles from '../MigrationNotice.m.less'; +import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { $t } from 'services/i18n'; +import SectionHeader from './SectionHeader'; +import { useInstallState, getStatusText } from './useInstallState'; + +interface IModalInstallationFlowProps { + onCancel: () => void; + onInstallComplete?: () => void; +} + +export default function ModalInstallationFlow(props: IModalInstallationFlowProps) { + const { + step, + progress, + isInstalled, + isInstalling, + isRecorderRunning, + handleOpenOrInstall, + handleRetry, + handleCancel, + } = useInstallState(); + + useEffect(() => { + if (step === 'done' && props.onInstallComplete) { + props.onInstallComplete(); + } + }, [step]); + + function onCancel() { + handleCancel(); + props.onCancel(); + } + + // Idle — show install/open CTA + if (step === 'idle') { + // Replay is installed and recorder is running + if (isInstalled && isRecorderRunning) { + return ( +
+ +

+ {$t( + `The ${REPLAY_APP_NAME} recorder is currently running and capturing your gameplay.`, + )} +

+
+ ); + } + + return ( +
+ +

+ {$t(`Install ${REPLAY_APP_NAME} to import and detect game highlights.`)} +

+
+ +
+
+ ); + } + + // Done + if (step === 'done') { + return ( +
+ +
+
{$t('Installation finished')}
+

+ {$t(`${REPLAY_APP_NAME} has been installed and is now running.`)} +

+
+
+ ); + } + + // Error + if (step === 'error') { + return ( +
+ +
+
{$t('Installation interrupted')}
+

+ {$t("It seems the installation didn't finish.")} +
+ {$t("Let's figure this out together and")}{' '} + { + e.preventDefault(); + require('@electron/remote').shell.openExternal('https://support.streamlabs.com'); + }} + > + {$t('contact support')} + +

+
+
+ +
+
+ ); + } + + // Downloading / Installing / Verifying — progress bar + const displayProgress = Math.round(progress); + + return ( +
+ +
+
{displayProgress}%
+

+ {getStatusText(step)} + {step === 'downloading' && ( + <> +
+ {$t('The installation will start automatically')} + + )} +

+
+
+ +
+
+ ); +} diff --git a/app/components-react/highlighter/migration/PageInstallationFlow.tsx b/app/components-react/highlighter/migration/PageInstallationFlow.tsx new file mode 100644 index 000000000000..ae6cf9a85896 --- /dev/null +++ b/app/components-react/highlighter/migration/PageInstallationFlow.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { Button } from 'antd'; +import cx from 'classnames'; +import styles from '../MigrationNotice.m.less'; +import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { $t } from 'services/i18n'; +import FeatureCarousel, { CAROUSEL_FEATURES } from './FeatureCarousel'; +import { useInstallState, getStatusText } from './useInstallState'; +import { EReplayInstallStep } from 'services/highlighter/models/highlighter.models'; + +interface IPageInstallationFlowProps { + onCancel: () => void; + onShowAllClips: () => void; +} + +export default function PageInstallationFlow(props: IPageInstallationFlowProps) { + const { + step, + progress, + isInstalled, + handleOpenOrInstall, + handleRetry, + handleCancel, + } = useInstallState(); + + function onCancel() { + handleCancel(); + props.onCancel(); + } + + return ( + +
+ handleOpenOrInstall('page')} + onRetry={handleRetry} + onCancel={onCancel} + onShowAllClips={props.onShowAllClips} + /> +
+
+ ); +} + +// ── Inline install CTA rendered as FeatureCarousel children ── + +interface IPageInstallCtaProps { + step: EReplayInstallStep; + progress: number; + isInstalled: boolean; + onOpenOrInstall: () => void; + onShowAllClips: () => void; + onRetry: () => void; + onCancel: () => void; +} + +function PageInstallCta({ + step, + progress, + isInstalled, + onOpenOrInstall, + onShowAllClips, + onRetry, + onCancel, +}: IPageInstallCtaProps) { + const isInstalling = step === 'downloading' || step === 'installing' || step === 'verifying'; + + // Idle — CTA button (install or open depending on whether Replay is already installed) + if (step === 'idle') { + return ( +
+
+
+ + + + + + {$t('Try now')} + +
+ + +
+ {isInstalled && ( + + )} +
+ ); + } + + // Downloading / Installing / Verifying — progress bar + if (isInstalling) { + const statusText = getStatusText(step); + + return ( +
+
+ {statusText} +
+
+
+
+ {step === 'downloading' && ( + + )} +
+
+ {step === 'downloading' && ( + {Math.round(progress)}% + )} +
+ ); + } + + // Done + if (step === 'done') { + return ( +
+ {$t('Installation finished')} + + {$t(`${REPLAY_APP_NAME} has been installed and is now running.`)} + +
+ ); + } + + // Error + if (step === 'error') { + return ( +
+ {$t('Installation interrupted')} + + {$t("It seems the installation didn't finish.")}{' '} + {$t("Let's figure this out together and")}{' '} + { + e.preventDefault(); + require('@electron/remote').shell.openExternal('https://support.streamlabs.com'); + }} + > + {$t('contact support')} + + + +
+ ); + } + + return null; +} diff --git a/app/components-react/highlighter/migration/SectionHeader.tsx b/app/components-react/highlighter/migration/SectionHeader.tsx new file mode 100644 index 000000000000..5e2e9ec21d9f --- /dev/null +++ b/app/components-react/highlighter/migration/SectionHeader.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Button } from 'antd'; + +interface ISectionHeaderProps { + title: string; + onClose?: () => void; + closeDisabled?: boolean; +} + +export default function SectionHeader(props: ISectionHeaderProps) { + return ( +
+

{props.title}

+ {props.onClose && ( +
+ +
+ )} +
+ ); +} diff --git a/app/components-react/highlighter/migration/useInstallState.ts b/app/components-react/highlighter/migration/useInstallState.ts new file mode 100644 index 000000000000..d7f394bbaffb --- /dev/null +++ b/app/components-react/highlighter/migration/useInstallState.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { Services } from 'components-react/service-provider'; +import { useVuex } from 'components-react/hooks'; +import { EReplayInstallStep } from 'services/highlighter/models/highlighter.models'; +import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { $t } from 'services/i18n'; + +export function useInstallState() { + const { HighlighterService } = Services; + + const [isInstalled, setIsInstalled] = useState(null); + const [isRecorderRunning, setIsRecorderRunning] = useState(false); + + const { step, progress, error } = useVuex(() => ({ + step: HighlighterService.state.replayInstall.step as EReplayInstallStep, + progress: HighlighterService.state.replayInstall.progress as number, + error: HighlighterService.state.replayInstall.error as string | null, + })); + + const isInstalling = step === 'downloading' || step === 'installing' || step === 'verifying'; + + useEffect(() => { + HighlighterService.actions.return.isStreamlabsReplayInstalled().then(installed => { + setIsInstalled(installed); + if (installed) { + HighlighterService.actions.return.isStreamlabsRecorderRunning().then(setIsRecorderRunning); + } + }); + }, []); + + useEffect(() => { + if (step === 'done') setIsInstalled(true); + }, [step]); + + function handleOpenOrInstall(source: 'page' | 'modal') { + HighlighterService.actions.openReplay(source); + } + + function handleRetry() { + HighlighterService.actions.installStreamlabsReplay(); + } + + function handleCancel() { + HighlighterService.actions.cancelReplayInstall(); + } + + return { + step, + progress, + error, + isInstalled, + isInstalling, + isRecorderRunning, + handleOpenOrInstall, + handleRetry, + handleCancel, + }; +} + +export function getStatusText(step: EReplayInstallStep): string { + if (step === 'installing') return $t(`Installing ${REPLAY_APP_NAME}...`); + if (step === 'verifying') return $t('Verifying installation...'); + return $t(`Downloading ${REPLAY_APP_NAME}...`); +} diff --git a/app/components-react/pages/Highlighter.m.less b/app/components-react/pages/Highlighter.m.less index 96d56e2c777b..4e20dca29652 100644 --- a/app/components-react/pages/Highlighter.m.less +++ b/app/components-react/pages/Highlighter.m.less @@ -1,4 +1,4 @@ -@import "../../styles/index"; +@import '../../styles/index'; .clip-loader { width: 400px; @@ -9,8 +9,12 @@ .clips-view-root { position: relative; - :global(.ant-modal-wrap), :global(.ant-modal-mask) { + :global(.ant-modal-wrap), + :global(.ant-modal-mask) { position: absolute; + top: 0 !important; + height: 100% !important; + overflow: hidden !important; } :global(.os-content-glue) { @@ -32,7 +36,7 @@ cursor: pointer; &:hover { - opacity: 1.0; + opacity: 1; } } diff --git a/app/components-react/pages/Highlighter.tsx b/app/components-react/pages/Highlighter.tsx index 1bdb10115de0..bcb427830a80 100644 --- a/app/components-react/pages/Highlighter.tsx +++ b/app/components-react/pages/Highlighter.tsx @@ -11,7 +11,6 @@ import { Services } from 'components-react/service-provider'; import StreamView from 'components-react/highlighter/StreamView'; import ClipsView from 'components-react/highlighter/ClipsView'; import UpdateModal from 'components-react/highlighter/UpdateModal'; -import { EAvailableFeatures } from 'services/incremental-rollout'; export default function Highlighter(props: { params?: { view: string } }) { const { HighlighterService, UsageStatisticsService } = Services; diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.m.less b/app/components-react/windows/go-live/AiHighlighterToggle.m.less index 1475890cae93..303d6f64fa7f 100644 --- a/app/components-react/windows/go-live/AiHighlighterToggle.m.less +++ b/app/components-react/windows/go-live/AiHighlighterToggle.m.less @@ -20,7 +20,7 @@ } .colored-blob { width: 90%; - position: absolute; + height: 30%; bottom: -22%; left: 5%; @@ -50,8 +50,7 @@ flex-direction: column; width: 100%; gap: 2px; - position: absolute; - top: 17px; + padding-top: 16px; } .headline-wrapper { @@ -86,6 +85,7 @@ .artwork-image { flex-shrink: 0; min-height: 100%; + // max-width: 190px; } .toggle-text-wrapper { display: flex; @@ -122,7 +122,7 @@ position: absolute; width: calc(@width / 12); height: calc(@height / 12); - top: 105px; + top: 21px; transform: rotate(-5deg); overflow: hidden; border-radius: 5px; @@ -134,7 +134,7 @@ position: absolute; height: calc(@width / 12); width: calc(@height / 12); - top: 85px; + top: 1px; left: 200px; transform: rotate(2deg); overflow: hidden; @@ -150,9 +150,10 @@ } .education-section { - padding-top: 92px; + padding-top: 24px; gap: 5px; width: 100%; + min-width: 260px; display: flex; flex-direction: column; font-size: 16px; diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.tsx b/app/components-react/windows/go-live/AiHighlighterToggle.tsx index e3bbabac52cd..3dae8b249648 100644 --- a/app/components-react/windows/go-live/AiHighlighterToggle.tsx +++ b/app/components-react/windows/go-live/AiHighlighterToggle.tsx @@ -2,6 +2,7 @@ import { SwitchInput } from 'components-react/shared/inputs/SwitchInput'; import React, { useEffect, useState } from 'react'; import styles from './AiHighlighterToggle.m.less'; import { Services } from 'components-react/service-provider'; +import * as remote from '@electron/remote'; import { useDebounce, useVuex } from 'components-react/hooks'; import EducationCarousel from 'components-react/highlighter/EducationCarousel'; import { DownOutlined, UpOutlined } from '@ant-design/icons'; @@ -9,11 +10,13 @@ import { Button } from 'antd'; import { getConfigByGame, isGameSupported } from 'services/highlighter/models/game-config.models'; import { $t } from 'services/i18n'; import { + YouTubeLogo, DiscordLogo, - InstagramLogo, TikTokLogo, - YouTubeLogo, -} from 'components-react/highlighter/ImportStream'; + InstagramLogo, +} from 'components-react/highlighter/HypeWrapper'; +import { REPLAY_APP_NAME } from 'services/highlighter/constants'; +import { EAvailableFeatures } from 'services/incremental-rollout'; export default function AiHighlighterToggle({ game, @@ -23,7 +26,7 @@ export default function AiHighlighterToggle({ cardIsExpanded: boolean; }) { //TODO M: Probably good way to integrate the highlighter in to GoLiveSettings - const { HighlighterService } = Services; + const { HighlighterService, IncrementalRolloutService } = Services; const { useHighlighter, highlighterVersion } = useVuex(() => { return { useHighlighter: HighlighterService.views.useAiHighlighter, @@ -33,6 +36,7 @@ export default function AiHighlighterToggle({ const [gameIsSupported, setGameIsSupported] = useState(false); const [gameConfig, setGameConfig] = useState(null); + const [showReplayRecordingAlert, setShowReplayRecordingAlert] = useState(false); useEffect(() => { const supportedGame = isGameSupported(game); @@ -45,6 +49,34 @@ export default function AiHighlighterToggle({ } }, [game]); + useEffect(() => { + checkRecorderStatus(); + }, []); + + async function checkRecorderStatus() { + // Check migration feature flag first + const migrationEnabled = IncrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.highlighterMigration, + ); + if (!migrationEnabled) return; + + const running = await HighlighterService.actions.return.isStreamlabsRecorderRunning(); + setShowReplayRecordingAlert(running); + } + + async function handleStopRecording() { + try { + HighlighterService.actions.requestStopRecordingReplay(); + // Hide the warning alert + setShowReplayRecordingAlert(false); + // Recheck recorder status after a short delay + // Maybe we want to add that back later + // setTimeout(checkRecorderStatus, 5000); + } catch (error: unknown) { + console.error('Failed to send stop recording command:', error); + } + } + function getInitialExpandedState() { if (gameIsSupported) { return true; @@ -79,6 +111,43 @@ export default function AiHighlighterToggle({
+ {showReplayRecordingAlert && ( +
+
+ ⚠️ +
+
+ {$t('External Streamlabs Recorder is Running')} +
+
+ {$t( + `The ${REPLAY_APP_NAME} recorder is currently active and capturing gameplay.`, + )} +
+
+
+
+ + +
+
+ )}
{ - HighlighterService.installAiHighlighter(false, 'Go-live-flow', game); + HighlighterService.actions.installAiHighlighter( + false, + 'Go-live-flow', + game, + ); }} > {$t('Install AI Highlighter')} @@ -138,33 +211,35 @@ export default function AiHighlighterToggle({ <>
{!useHighlighter ? ( -
+
{gameConfig?.importModalConfig?.horizontalExampleVideo && gameConfig?.importModalConfig?.verticalExampleVideo ? ( <>
diff --git a/app/services/highlighter/constants.ts b/app/services/highlighter/constants.ts index c52bde01f74f..519f6dbed8cc 100644 --- a/app/services/highlighter/constants.ts +++ b/app/services/highlighter/constants.ts @@ -30,3 +30,13 @@ export const AI_HIGHLIGHTER_BUILDS_URL_STAGING = export const AI_HIGHLIGHTER_BUILDS_URL_PRODUCTION = 'https://cdn-highlighter-builds.streamlabs.com/production/manifest_win_x86_64.json'; + +export const HIGHLIGHTER_SETUP_URL_STAGING = + 'https://cdn-highlighter-desktop.streamlabs.com/staging/win32/x64/G%20HUB%20Replay-Setup.exe'; + +export const HIGHLIGHTER_SETUP_URL_PRODUCTION = + 'https://cdn-highlighter-desktop.streamlabs.com/streamlabs-highlighter/production/win32/x64/Streamlabs%20Highlighter-Setup.exe'; + +export const REPLAY_PROTOCOL = 'streamlabs-highlighter'; +export const REPLAY_APP_NAME = 'Streamlabs Highlighter'; +export const REPLAY_SETUP_EXE_NAME = 'G HUB Replay-Setup.exe'; diff --git a/app/services/highlighter/index.ts b/app/services/highlighter/index.ts index 79867a76e74d..fa02d4bdfcd5 100644 --- a/app/services/highlighter/index.ts +++ b/app/services/highlighter/index.ts @@ -12,7 +12,14 @@ import { } from 'services/platforms/youtube/uploader'; import { YoutubeService } from 'services/platforms/youtube'; import os from 'os'; -import { SCRUB_SPRITE_DIRECTORY, SUPPORTED_FILE_TYPES } from './constants'; +import { + SCRUB_SPRITE_DIRECTORY, + SUPPORTED_FILE_TYPES, + HIGHLIGHTER_SETUP_URL_STAGING, + HIGHLIGHTER_SETUP_URL_PRODUCTION, + REPLAY_PROTOCOL, + REPLAY_SETUP_EXE_NAME, +} from './constants'; import { pmap } from 'util/pmap'; import { RenderingClip } from './rendering/rendering-clip'; import { throttle } from 'lodash-decorators'; @@ -29,7 +36,7 @@ import moment from 'moment'; import uuid from 'uuid'; import { EMenuItemKey } from 'services/side-nav'; import { AiHighlighterUpdater } from './ai-highlighter-updater'; -import { IDownloadProgress } from 'util/requests'; +import { IDownloadProgress, downloadFile } from 'util/requests'; import { IncrementalRolloutService } from 'app-services'; import { EAvailableFeatures } from 'services/incremental-rollout'; @@ -47,6 +54,9 @@ import { TStreamInfo, EHighlighterView, ITempRecordingInfo, + IReplayInstallState, + EReplayInstallStep, + TOpenedFrom, } from './models/highlighter.models'; import { EExportStep, @@ -79,6 +89,10 @@ import { addVerticalFilterToExportOptions } from './vertical-export'; import { isGameSupported } from './models/game-config.models'; import Utils from 'services/utils'; import { getOS, OS } from '../../util/operating-systems'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); @InitAfter('StreamingService') export class HighlighterService extends PersistentStatefulService { @@ -131,6 +145,11 @@ export class HighlighterService extends PersistentStatefulService) { + this.state.replayInstall = { + ...this.state.replayInstall, + ...installState, + }; + } + + // ================================================================================================= + // STREAMLABS REPLAY MIGRATION logic + // ================================================================================================= + + /** + * Checks if Streamlabs Replay is installed by verifying the Windows deeplink protocol registration + * @returns Promise - true if the ${REPLAY_PROTOCOL} protocol is registered, false otherwise + */ + async isStreamlabsReplayInstalled(): Promise { + // Only check on Windows + if (getOS() !== OS.Windows) { + return false; + } + + try { + // Query the Windows Registry for the protocol handler command + const { stdout, stderr } = await execAsync( + `reg query "HKEY_CLASSES_ROOT\\${REPLAY_PROTOCOL}\\shell\\open\\command" /ve`, + { + timeout: 5000, + }, + ); + + // Check if stderr is empty and stdout contains meaningful data + if (stderr) { + return false; + } + + // Check if the output contains an actual executable path + const hasValidCommand = stdout.includes('.exe'); + + return hasValidCommand; + } catch (error: unknown) { + // If the registry key doesn't exist, reg query will throw an error + return false; + } + } + + /** + * Checks if the StreamlabsRecorder.exe process is currently running + * @returns Promise - true if process is running, false otherwise + */ + async isStreamlabsRecorderRunning(): Promise { + // Only check on Windows + if (getOS() !== OS.Windows) { + return false; + } + + try { + const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq StreamlabsRecorder.exe"', { + timeout: 5000, + }); + + // Check if the process name appears in the output + const isRunning = stdout.includes('StreamlabsRecorder.exe'); + + return isRunning; + } catch (error: unknown) { + return false; + } + } + + // ================================================================================================= + // STREAMLABS REPLAY INSTALLATION logic + // ================================================================================================= + + private getReplaySetupUrl(): string { + if (Utils.isProduction) { + return HIGHLIGHTER_SETUP_URL_PRODUCTION; + } + return HIGHLIGHTER_SETUP_URL_STAGING; + } + + /** + * Verifies the Authenticode signature of a Windows executable using PowerShell. + * Throws if the signature is invalid or the subject does not contain 'Streamlabs'. + */ + private async verifyAuthenticodeSignature(filePath: string): Promise { + // Use PS single-quote escaping then encode as UTF-16LE base64 + // to avoid any command-line quoting/injection issues with the file path. + const escapedPath = filePath.replace(/'/g, "''"); + const script = `$sig = Get-AuthenticodeSignature '${escapedPath}'; if ($sig.Status -ne 'Valid') { exit 1 }; if ($sig.SignerCertificate.Subject -notmatch 'CN=.*Streamlabs') { exit 2 }; exit 0`; + const encodedCommand = Buffer.from(script, 'utf16le').toString('base64'); + + await execAsync( + `powershell -NonInteractive -NoProfile -EncodedCommand ${encodedCommand}`, + { timeout: 15000 }, + ).catch((err: Error & { code?: number }) => { + const code = err.code ?? -1; + if (code === 1) { + throw new Error('Installer signature verification failed: invalid or missing signature.'); + } + if (code === 2) { + throw new Error( + 'Installer signature verification failed: publisher does not match Streamlabs.', + ); + } + throw new Error(`Installer signature check failed: ${err.message}`); + }); + } + + /** + * Downloads and installs Streamlabs Replay. + * Fakes progress increments during the download/install phases, + * verifies the deeplink registry after install, and auto-launches the app. + */ + private replayInstallAbortController: AbortController | null = null; + + async installStreamlabsReplay(): Promise { + if (getOS() !== OS.Windows) { + this.SET_REPLAY_INSTALL({ + step: 'error', + error: 'Installation is only supported on Windows', + }); + return false; + } + + // Abort any previous in-flight install + this.replayInstallAbortController?.abort(); + this.replayInstallAbortController = new AbortController(); + const { signal } = this.replayInstallAbortController; + + // Track installation started + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationStarted', + }); + + let progressInterval: NodeJS.Timeout | null = null; + + const clearProgress = () => { + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + }; + + try { + // --- Downloading phase --- + this.SET_REPLAY_INSTALL({ step: 'downloading', progress: 0, error: null }); + + const setupUrl = this.getReplaySetupUrl(); + + // Download the setup exe to temp directory + const tempDir = os.tmpdir(); + const setupPath = path.join(tempDir, REPLAY_SETUP_EXE_NAME); + + await downloadFile(setupUrl, setupPath, (progress: IDownloadProgress) => { + // Map download progress to 0-75% + const downloadPercent = progress.percent * 75; + const current = this.state.replayInstall.progress; + if (downloadPercent > current) { + this.SET_REPLAY_INSTALL({ progress: downloadPercent }); + } + }); + + clearProgress(); + + if (signal.aborted) { + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationCancelled', + phase: 'downloading', + }); + return false; + } + + this.SET_REPLAY_INSTALL({ progress: 75 }); + + // Verify the Authenticode signature before execution + await this.verifyAuthenticodeSignature(setupPath); + + // --- Installing phase --- + this.SET_REPLAY_INSTALL({ step: 'installing', progress: 75 }); + + // Fake progress for install phase + progressInterval = setInterval(() => { + const current = this.state.replayInstall.progress; + if (current < 95) { + const increment = Math.max(0.3, (95 - current) * 0.03); + this.SET_REPLAY_INSTALL({ progress: Math.min(95, current + increment) }); + } + }, 500); + + // Run the installer silently + await execAsync(`"${setupPath}"`, { timeout: 120000 }); + + clearProgress(); + + if (signal.aborted) { + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationCancelled', + phase: 'installing', + }); + return false; + } + + this.SET_REPLAY_INSTALL({ progress: 95 }); + + // --- Verifying phase --- + this.SET_REPLAY_INSTALL({ step: 'verifying', progress: 96 }); + + // Wait a moment for registry to propagate + await this.wait(2000); + + if (signal.aborted) { + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationCancelled', + phase: 'verifying', + }); + return false; + } + + const isInstalled = await this.isStreamlabsReplayInstalled(); + if (!isInstalled) { + throw new Error( + 'Installation could not be verified. The deeplink protocol was not registered.', + ); + } + + this.SET_REPLAY_INSTALL({ step: 'done', progress: 100 }); + + // Auto-launch Streamlabs Replay + try { + remote.shell.openExternal(`${REPLAY_PROTOCOL}://open`); + } catch (launchError: unknown) { + console.error('Failed to auto-launch Streamlabs Replay:', launchError); + } + + // Clean up setup file + try { + await fs.remove(setupPath); + } catch { + // Non-critical cleanup + } + + // Track installation finished successfully + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationFinished', + }); + + return true; + } catch (error: unknown) { + clearProgress(); + if (signal.aborted) { + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationCancelled', + phase: 'error', + }); + return false; + } + const errorMessage = error instanceof Error ? error.message : 'Unknown installation error'; + console.error('Streamlabs Replay installation failed:', errorMessage); + this.SET_REPLAY_INSTALL({ step: 'error', progress: 0, error: errorMessage }); + + // Track installation failed + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationFailed', + }); + + return false; + } finally { + if (this.replayInstallAbortController?.signal === signal) { + this.replayInstallAbortController = null; + } + } + } + + cancelReplayInstall() { + const wasInstalling = + this.state.replayInstall.step !== 'idle' && + this.state.replayInstall.step !== 'done' && + this.state.replayInstall.step !== 'error'; + + // Track cancellation if an installation was in progress + if (wasInstalling) { + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationCancelled', + phase: this.state.replayInstall.step, + }); + } + + this.replayInstallAbortController?.abort(); + this.replayInstallAbortController = null; + this.SET_REPLAY_INSTALL({ step: 'idle', progress: 0, error: null }); + } + + /** + * Opens Streamlabs Replay or starts installation if not installed + * @param source - Where the action was initiated from ('page' or 'modal') + * @returns Promise - true if Replay was opened, false if installation was started + */ + async openReplay(source: 'page' | 'modal'): Promise { + const isInstalled = await this.isStreamlabsReplayInstalled(); + + if (isInstalled) { + // Track opening Replay + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayOpen', + source, + }); + + // Open Streamlabs Replay via deeplink + try { + remote.shell.openExternal(`${REPLAY_PROTOCOL}://open`); + return true; + } catch (error: unknown) { + console.error('Failed to open Streamlabs Replay:', error); + try { + remote.shell.openExternal(`${REPLAY_PROTOCOL}:`); + return true; + } catch (fallbackError: unknown) { + console.error('Failed to open Streamlabs Replay with fallback:', fallbackError); + return false; + } + } + } else { + // Track installation click + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayInstallationClick', + source, + }); + + // Start installation flow for Streamlabs Replay (don't await so UI can update) + this.installStreamlabsReplay(); + return false; + } + } + + /** + * Opens Streamlabs Replay with an import deeplink + * @param videoPath - Path to the video file to import + * @param game - The game type for the video + * @param openedFrom - Where the import was initiated from + * @param streamId - Optional stream ID for tracking + * @param title - Optional title for the recording + */ + openReplayImport( + videoPath: string, + game: string, + openedFrom: TOpenedFrom, + streamId?: string, + title?: string, + ): void { + let deeplink = `${REPLAY_PROTOCOL}://import?path=${encodeURIComponent( + videoPath, + )}&game=${encodeURIComponent(game)}`; + + if (title) { + deeplink += `&title=${encodeURIComponent(title)}`; + } + + remote.shell.openExternal(deeplink); + + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayImport', + openedFrom, + streamId, + game, + }); + } + requestStopRecordingReplay() { + remote.shell.openExternal(`${REPLAY_PROTOCOL}://stop-recording`); + + this.usageStatisticsService.recordAnalyticsEvent('AIHighlighter', { + type: 'ReplayRequestStopRecording', + }); + } + + // ================================================================================================= + //Legacy highlighter support + // ================================================================================================= + get views() { return new HighlighterViews(this.state); } diff --git a/app/services/highlighter/models/highlighter.models.ts b/app/services/highlighter/models/highlighter.models.ts index aa7293032db2..da02d662baa1 100644 --- a/app/services/highlighter/models/highlighter.models.ts +++ b/app/services/highlighter/models/highlighter.models.ts @@ -33,6 +33,21 @@ export interface IHighlighterState { isUpdaterRunning: boolean; highlighterVersion: string; tempRecordingInfo: ITempRecordingInfo; + replayInstall: IReplayInstallState; +} + +export type EReplayInstallStep = + | 'idle' + | 'downloading' + | 'installing' + | 'verifying' + | 'done' + | 'error'; + +export interface IReplayInstallState { + step: EReplayInstallStep; + progress: number; + error: string | null; } // CLIP diff --git a/app/services/incremental-rollout.ts b/app/services/incremental-rollout.ts index 3d42a1eb6eaa..8d22f90900ec 100644 --- a/app/services/incremental-rollout.ts +++ b/app/services/incremental-rollout.ts @@ -16,6 +16,7 @@ export enum EAvailableFeatures { tiktok = 'slobs--tiktok', highlighter = 'slobs--highlighter', aiHighlighter = 'slobs--ai-highlighter', + highlighterMigration = 'slobs--highlighter-migration', growTab = 'slobs--grow-tab', themeAudit = 'slobs--theme-audit', reactWidgets = 'slobs--react-widgets',