Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
57f518d
wip realtime highlighter
ggolda Jun 10, 2025
8a25c66
added realtime service into highlighter service
ggolda Jun 10, 2025
b3d14a7
wip
ggolda Jun 11, 2025
ca2a172
wip
ggolda Jun 11, 2025
d8ada4c
start fake vision service only after 30 seconds
ggolda Jun 11, 2025
bbf270c
to fix negative video duration
marvinoffers Jun 12, 2025
acca673
wip
ggolda Jun 12, 2025
cf2d24c
wip
ggolda Jun 12, 2025
255004e
wip
ggolda Jun 12, 2025
a527fa3
WIP: fixes error bcs round is now optional
marvinoffers Jun 13, 2025
35f92e2
added new DetectionState
marvinoffers Jun 13, 2025
3734edf
updates stream, wich updates the streamCard
marvinoffers Jun 13, 2025
1e72fba
added local vision service
ggolda Jun 13, 2025
4f45342
refactored and split into several methods
ggolda Jun 13, 2025
5a20e77
[EXPERIMENT] use the start time of the first detected highlight momen…
ggolda Jun 16, 2025
2b96e93
fixed the bug when dekstop sends replay buffer event without requesti…
ggolda Jun 17, 2025
4d37a37
fixed the issue when replay buffer duration couldnt be set
ggolda Jun 18, 2025
c56be84
added round metadata for realtime highlighter
ggolda Jun 19, 2025
e03c3f2
realtime indicator cmoponent
marvinoffers Jun 20, 2025
7742812
reworked instant replay widget to make it work with realtime highligh…
ggolda Jun 20, 2025
3caedd7
comments added
ggolda Jun 20, 2025
c58b2a7
realtime highlights indicator
jankalthoefer Jun 23, 2025
f9aa596
fix audio deduplication bug + added better error handling
ggolda Jun 23, 2025
265d8b3
lower volume for obs source to transition between clips smoother with…
ggolda Jun 23, 2025
894f354
created realtime indicator
marvinoffers Jun 24, 2025
9a0ac83
Merge branch 'highlighter/realtime-highlighter-ui-updates' into highl…
marvinoffers Jun 24, 2025
bb45c2f
Merge branch 'highlighter/realtime-highlighter' into highlighter/real…
marvinoffers Jun 24, 2025
6958e64
added webcam coordinates for realtime highlighter
ggolda Jun 24, 2025
32eae2f
styling
marvinoffers Jun 26, 2025
a373b0d
Merge branch 'highlighter/realtime-highlighter' into highlighter/real…
marvinoffers Jun 26, 2025
21e30a3
added new source type to play highlighter reels
ggolda Jun 26, 2025
3ecf956
reverted changes to instant replay source, should remain intact
ggolda Jun 26, 2025
39a97e1
added more styling
marvinoffers Jun 27, 2025
e61e6b4
Merge branch 'highlighter/realtime-highlighter' into highlighter/real…
marvinoffers Jun 27, 2025
76e2b8d
add description to
marvinoffers Jun 27, 2025
6b37b19
wording fix
marvinoffers Jun 27, 2025
6d0ba81
remove fake buttons
jankalthoefer Jul 7, 2025
fd520a1
change to be able to open correct stream from clip
marvinoffers Aug 1, 2025
d9d8008
navigation to clip view fix
marvinoffers Aug 4, 2025
8b266ba
fix 0 when no round was detected
marvinoffers Aug 4, 2025
90b23ca
merge 1
marvinoffers Aug 4, 2025
5712c46
merge 2
marvinoffers Aug 4, 2025
62c358c
merge fix
marvinoffers Aug 4, 2025
e933dc5
rm logs + added translations
marvinoffers Aug 4, 2025
0c98ba0
adjusted realtimeindicator styling
marvinoffers Aug 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export { VideoSettingsService } from 'services/settings-v2/video';
export { SettingsManagerService } from 'services/settings-manager';
export { MarkersService } from 'services/markers';
export { RealmService } from 'services/realm';
export { RealtimeHighlighterService } from 'services/highlighter/realtime-highlighter-service';
import { VirtualWebcamService } from 'services/virtual-webcam';

// ONLINE SERVICES
Expand Down Expand Up @@ -164,6 +165,7 @@ import { PatchNotesService } from './services/patch-notes';
import { VideoService } from './services/video';
import { ChatService } from './services/chat';
import { HighlighterService } from './services/highlighter';
import { RealtimeHighlighterService } from './services/highlighter/realtime-highlighter-service';
import { GrowService } from './services/grow/grow';
import { TransitionsService } from './services/transitions';
import { TcpServerService } from './services/api/tcp-server';
Expand Down Expand Up @@ -293,4 +295,5 @@ export const AppServices = {
RemoteControlService,
UrlService,
VirtualWebcamService,
RealtimeHighlighterService,
};
80 changes: 49 additions & 31 deletions app/components-react/highlighter/ClipPreviewInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EGame } from 'services/highlighter/models/ai-highlighter.models';
import { EGame, IAiClipInfo } from 'services/highlighter/models/ai-highlighter.models';
import { IAiClip } from 'services/highlighter/models/highlighter.models';
import { getConfigByGame, getEventConfig } from 'services/highlighter/models/game-config.models';
import styles from './ClipPreview.m.less';
Expand All @@ -15,33 +15,7 @@ export default function ClipPreviewInfo({
return <span>No event data</span>;
}

const uniqueInputTypes = new Set<string>();
if (clip.aiInfo.inputs && Array.isArray(clip.aiInfo.inputs)) {
clip.aiInfo.inputs.forEach(input => {
if (input.type) {
uniqueInputTypes.add(input.type);
}
});
}

const eventDisplays = Array.from(uniqueInputTypes).map(type => {
const eventInfo = getEventConfig(game, type);

if (eventInfo) {
return {
emoji: eventInfo.emoji,
description: eventInfo.description.singular,
type,
};
}

return {
emoji: '⚡',
description: type,
type,
};
});

const eventDisplays = getUniqueEmojiConfigFromAiInfo(clip.aiInfo);
return (
<div
style={{
Expand All @@ -53,9 +27,53 @@ export default function ClipPreviewInfo({
{eventDisplays.map((event, index) => {
return <React.Fragment key={index}>{event.emoji}</React.Fragment>;
})}
{clip.aiInfo.metadata?.round && (
<div className={styles.roundTag}>{`Round: ${clip.aiInfo.metadata.round}`}</div>
)}{' '}
{clip.aiInfo.metadata?.round !== undefined &&
clip.aiInfo.metadata?.round !== null &&
clip.aiInfo.metadata?.round !== 0 && (
<div className={styles.roundTag}>{`Round: ${clip.aiInfo.metadata.round}`}</div>
)}
</div>
);
}

export interface EmojiConfig {
emoji: string;
count: number;
description: string;
type: string;
}

export function getUniqueEmojiConfigFromAiInfo(aiInfos: IAiClipInfo): EmojiConfig[] {
const typeCounts: Record<string, number> = {};
if (aiInfos.inputs && Array.isArray(aiInfos.inputs)) {
aiInfos.inputs.forEach(aiInput => {
if (aiInput.type) {
typeCounts[aiInput.type] = (typeCounts[aiInput.type] || 0) + 1;
}
});
}

const uniqueInputTypes = Object.keys(typeCounts);

const eventDisplays = uniqueInputTypes.map(type => {
const count = typeCounts[type];
if (aiInfos.metadata?.game) {
const eventInfo = getEventConfig(aiInfos.metadata.game, type);
if (eventInfo) {
return {
emoji: eventInfo.emoji,
description: count > 1 ? eventInfo.description.plural : eventInfo.description.singular,
count,
type,
};
}
}
return {
emoji: '⚡',
description: type,
count,
type,
};
});
return eventDisplays;
}
99 changes: 99 additions & 0 deletions app/components-react/highlighter/RealtimeIndicator.m.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
.pulseWrapper {
width: 16px;
height: 16px;
margin-right: 4px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}

.pulse {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f85640;
opacity: 0.3;
top: 0;
left: 0;
animation: pulse 2.5s infinite;
}

.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f85640;
position: relative;
z-index: 1;
}

@keyframes pulse {
0% {
transform: scale(0.5);
opacity: 0.5;
}
50% {
transform: scale(1.4);
opacity: 0;
}
99% {
transform: scale(1.4);
opacity: 0;
}
100% {
transform: scale(0.5);
opacity: 0.5;
}
}
.realtime-detection-action {
font-weight: 500;
letter-spacing: 0.2px;
position: relative;
justify-content: space-between;
display: flex;
padding: 4px 8px;
padding-right: 10px;
width: 236px;
align-items: center;
gap: 8px;
border-radius: 48px;
background: #301e24;
color: white;
overflow: hidden;
}

.realtime-detection-streamcard {
background-color: var(--button);
color: var(--paragraph);
font-weight: 400;
font-size: 16px;
border-radius: 8px;
height: 40px;
width: 100%;
}

.activity-animation {
transform: translateX(200%);
}

.background-pulse {
left: -100%;
position: absolute;
width: 200px;
height: 40px;
background: linear-gradient(90deg, #ffffff00, #5d1e1e, #ffffff00);
transition: transform 0.5s ease-in-out;
}

.emoji {
z-index: 999;
margin-right: 4px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
85 changes: 85 additions & 0 deletions app/components-react/highlighter/RealtimeIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Button } from 'antd';
import React, { useState, useEffect } from 'react';
import { $t } from 'services/i18n';
import styles from './RealtimeIndicator.m.less';
import cx from 'classnames';
import { EGame } from 'services/highlighter/models/ai-highlighter.models';
import { GAME_CONFIGS } from 'services/highlighter/models/game-config.models';
import { TRealtimeFeedEvent } from './realtime-highlights/RealtimeHighlightsFeed';

export default function HighlightGenerator({
eventType,
emitCancel,
location,
}: {
eventType?: TRealtimeFeedEvent;
emitCancel: () => void;
location: 'streamCard' | 'statusBar';
}) {
const [animateOnce, setAnimateOnce] = useState(false);
const [emoji, setEmoji] = useState<string>('');
const [description, setDescription] = useState<string>('');

// Run animation once when emoji prop changes
useEffect(() => {
if (eventType) {
setEmoji(getEmojiByEventType(eventType));
setDescription(firstLetterUppercase(getDescriptionByEventType(eventType).singular));
setAnimateOnce(true);
const timeout = setTimeout(() => setAnimateOnce(false), 2000);
return () => clearTimeout(timeout);
}
}, [eventType]);

function getEmojiByEventType(eventType: { type: string; game: EGame }) {
return GAME_CONFIGS[eventType.game].inputTypeMap[eventType.type]?.emoji || '🤖';
}

function getDescriptionByEventType(eventType: { type: string; game: EGame }) {
return GAME_CONFIGS[eventType.game].inputTypeMap[eventType.type]?.description || 'Highlight';
}

function firstLetterUppercase(value: string): string {
return String(value).charAt(0).toUpperCase() + String(value).slice(1);
}
return (
<div
className={cx(
styles.realtimeDetectionAction,
location === 'streamCard' && styles.realtimeDetectionStreamcard,
)}
>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
{location === 'statusBar' && (
<div
className={cx(styles.backgroundPulse, animateOnce && styles.activityAnimation)}
// onAnimationEnd={() => setAnimateOnce(false)}
/>
)}
{animateOnce ? (
<div className={styles.emoji}>{emoji}</div>
) : (
<div className={styles.pulseWrapper}>
<div className={styles.pulse} />
<div className={styles.dot} />
</div>
)}
<p style={{ margin: 0, zIndex: 3, opacity: location === 'statusBar' ? 0.7 : 1 }}>
{' '}
{animateOnce ? `${description} detected` : $t('AI detection in progress')}
</p>
</div>
<Button
size="small"
type="ghost"
style={{ border: 'none', margin: 0, display: 'flex', alignItems: 'center' }}
onClick={e => {
e.stopPropagation();
emitCancel();
}}
>
<i className="icon-close" />
</Button>
</div>
);
}
27 changes: 24 additions & 3 deletions app/components-react/highlighter/StreamCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as remote from '@electron/remote';
import StreamCardInfo from './StreamCardInfo';
import StreamCardModal, { TModalStreamCard } from './StreamCardModal';
import { supportedGames } from 'services/highlighter/models/game-config.models';
import RealtimeIndicator from './RealtimeIndicator';

export default function StreamCard({
streamId,
Expand Down Expand Up @@ -230,7 +231,8 @@ export default function StreamCard({
<RotatedClips clips={clips} />
</div>
<h3 className={styles.emojiWrapper}>
{stream.state.type === EAiDetectionState.FINISHED ? (
{stream.state.type === EAiDetectionState.FINISHED ||
stream.state.type === EAiDetectionState.REALTIME_DETECTION_IN_PROGRESS ? (
<StreamCardInfo clips={clips} game={game} />
) : (
<div style={{ height: '22px' }}> </div>
Expand All @@ -239,12 +241,19 @@ export default function StreamCard({
<ActionBar
stream={stream}
clips={clips}
emitCancelHighlightGeneration={cancelHighlightGeneration}
emitCancelHighlightGeneration={() => {
cancelHighlightGeneration();
}}
emitExportVideo={() => exportVideo(streamId)}
emitShowStreamClips={showStreamClips}
clipsOfStreamAreLoading={clipsOfStreamAreLoading}
emitRestartAiDetection={() => {
HighlighterService.actions.restartAiDetection(stream.path, stream);
if (stream.path) {
HighlighterService.actions.restartAiDetection(stream.path, stream);
} else {
// Can't be restarted because it was a realtime highlight detection
// This scenario shouldn't happen
}
}}
emitSetView={emitSetView}
emitFeedbackForm={() => {
Expand Down Expand Up @@ -313,6 +322,18 @@ function ActionBar({
emitFeedbackForm(clips.length);
};

if (stream?.state.type === EAiDetectionState.REALTIME_DETECTION_IN_PROGRESS) {
return (
<RealtimeIndicator
location="streamCard"
eventType={undefined}
emitCancel={() => {
emitCancelHighlightGeneration();
}}
/>
);
}

// In Progress
if (stream?.state.type === EAiDetectionState.IN_PROGRESS) {
return (
Expand Down
Loading