From 0019789fb031bdbaa0690503bf93f004b79a0799 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Fri, 29 May 2026 05:01:12 +0530 Subject: [PATCH] feat(gameplay): AI gameplay review and highlight workflows for streamers (#1674) (#2856) Co-authored-by: Ayush Raj Chourasia Co-authored-by: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Co-authored-by: Steven Enamakel Co-authored-by: Claude Opus 4.7 (1M context) --- .../GameplayReviewWorkspace.test.tsx | 436 +++++++++ .../intelligence/GameplayReviewWorkspace.tsx | 598 ++++++++++++ app/src/lib/i18n/chunks/ar-1.ts | 4 + app/src/lib/i18n/chunks/bn-1.ts | 4 + app/src/lib/i18n/chunks/de-1.ts | 4 + app/src/lib/i18n/chunks/en-1.ts | 4 + app/src/lib/i18n/chunks/es-1.ts | 4 + app/src/lib/i18n/chunks/fr-1.ts | 4 + app/src/lib/i18n/chunks/hi-1.ts | 4 + app/src/lib/i18n/chunks/id-1.ts | 4 + app/src/lib/i18n/chunks/it-1.ts | 4 + app/src/lib/i18n/chunks/ko-1.ts | 4 + app/src/lib/i18n/chunks/pl-1.ts | 4 + app/src/lib/i18n/chunks/pt-1.ts | 4 + app/src/lib/i18n/chunks/ru-1.ts | 4 + app/src/lib/i18n/chunks/zh-CN-1.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/pages/Intelligence.tsx | 13 +- .../services/gameplayReviewService.test.ts | 296 ++++++ app/src/services/gameplayReviewService.ts | 231 +++++ src/core/all.rs | 4 + src/openhuman/about_app/catalog.rs | 50 + src/openhuman/gameplay_review/mod.rs | 14 + src/openhuman/gameplay_review/ops.rs | 875 ++++++++++++++++++ src/openhuman/gameplay_review/schemas.rs | 437 +++++++++ src/openhuman/gameplay_review/store.rs | 289 ++++++ src/openhuman/gameplay_review/types.rs | 173 ++++ src/openhuman/mod.rs | 1 + 28 files changed, 3476 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/GameplayReviewWorkspace.test.tsx create mode 100644 app/src/components/intelligence/GameplayReviewWorkspace.tsx create mode 100644 app/src/services/gameplayReviewService.test.ts create mode 100644 app/src/services/gameplayReviewService.ts create mode 100644 src/openhuman/gameplay_review/mod.rs create mode 100644 src/openhuman/gameplay_review/ops.rs create mode 100644 src/openhuman/gameplay_review/schemas.rs create mode 100644 src/openhuman/gameplay_review/store.rs create mode 100644 src/openhuman/gameplay_review/types.rs diff --git a/app/src/components/intelligence/GameplayReviewWorkspace.test.tsx b/app/src/components/intelligence/GameplayReviewWorkspace.test.tsx new file mode 100644 index 000000000..bbe9c9476 --- /dev/null +++ b/app/src/components/intelligence/GameplayReviewWorkspace.test.tsx @@ -0,0 +1,436 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + analyzeGameplaySession, + askGameplaySession, + draftGameplayClipMetadata, + type GameplayReviewAnalysis, + type GameplayReviewSession, + listGameplaySessions, + prepareGameplayFrames, + registerGameplaySession, + saveGameplayPreset, +} from '../../services/gameplayReviewService'; +import { GameplayReviewWorkspace } from './GameplayReviewWorkspace'; + +vi.mock('../../services/gameplayReviewService', () => ({ + analyzeGameplaySession: vi.fn(), + askGameplaySession: vi.fn(), + draftGameplayClipMetadata: vi.fn(), + flattenClipCandidates: vi.fn((session: any) => session?.analysis?.clip_candidates ?? []), + flattenDrafts: vi.fn((session: any) => session?.analysis?.draft_metadata ?? []), + flattenHighlights: vi.fn((session: any) => session?.analysis?.highlights ?? []), + formatSpoilerMode: vi.fn((mode: string) => + mode === 'full' ? 'Full spoilers' : 'Light spoilers' + ), + listGameplaySessions: vi.fn(), + normalizeGameplayError: vi.fn((error: unknown) => + error instanceof Error ? error.message : String(error) + ), + prepareGameplayFrames: vi.fn(), + registerGameplaySession: vi.fn(), + saveGameplayPreset: vi.fn(), +})); + +const mockedListGameplaySessions = vi.mocked(listGameplaySessions); +const mockedPrepareGameplayFrames = vi.mocked(prepareGameplayFrames); +const mockedRegisterGameplaySession = vi.mocked(registerGameplaySession); +const mockedAnalyzeGameplaySession = vi.mocked(analyzeGameplaySession); +const mockedAskGameplaySession = vi.mocked(askGameplaySession); +const mockedDraftGameplayClipMetadata = vi.mocked(draftGameplayClipMetadata); +const mockedSaveGameplayPreset = vi.mocked(saveGameplayPreset); + +describe('GameplayReviewWorkspace', () => { + beforeEach(() => { + mockedListGameplaySessions.mockResolvedValue([]); + mockedPrepareGameplayFrames.mockReset(); + mockedRegisterGameplaySession.mockReset(); + mockedAnalyzeGameplaySession.mockReset(); + mockedAskGameplaySession.mockReset(); + mockedDraftGameplayClipMetadata.mockReset(); + mockedSaveGameplayPreset.mockReset(); + }); + + it('imports selected frames, analyzes the session, and asks follow-up questions', async () => { + mockedPrepareGameplayFrames.mockResolvedValue([ + { + source_name: 'frame-1.png', + file_name: 'frame-1.png', + image_ref: 'data:image/png;base64,AAA', + captured_at_ms: 123, + }, + ]); + mockedRegisterGameplaySession.mockResolvedValue({ + session_id: 'gameplay-apex-1', + game_id: 'Apex Legends', + session_title: 'Ranked climb', + source_label: '/recordings/apex', + spoiler_mode: 'light', + preset_id: 'Apex preset', + imported_at_ms: 1000, + analyzed_at_ms: null, + frames: [ + { file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 }, + ], + analysis: null, + }); + mockedAnalyzeGameplaySession.mockResolvedValue({ + session_id: 'gameplay-apex-1', + game_id: 'Apex Legends', + session_title: 'Ranked climb', + source_label: '/recordings/apex', + spoiler_mode: 'light', + preset_id: 'Apex preset', + imported_at_ms: 1000, + analyzed_at_ms: 2000, + frames: [ + { file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 }, + ], + analysis: { + recap: 'Gameplay recap', + highlights: [ + { + id: 'h1', + frame_index: 0, + captured_at_ms: 123, + title: 'Highlight: clutch fight', + rationale: 'Clean finish', + confidence: 0.93, + kind: 'highlight', + }, + ], + clip_candidates: [ + { + id: 'c1', + frame_index: 0, + start_label: 'frame-1.png', + end_label: 'frame-1.png', + rationale: 'Clean finish', + confidence: 0.93, + }, + ], + draft_metadata: [ + { + platform: 'twitch', + title: 'Apex Legends — Highlight: clutch fight (twitch)', + description: 'Session: Ranked climb', + tags: ['apex-legends'], + }, + ], + follow_up_questions: ['What was the turning point?'], + spoiler_note: null, + }, + }); + mockedAskGameplaySession.mockResolvedValue({ + answer: 'Best clip candidate for Ranked climb: Highlight: clutch fight — Clean finish', + matched_highlights: [], + suggested_follow_up: ['What was the turning point?'], + }); + mockedDraftGameplayClipMetadata.mockResolvedValue([ + { + platform: 'twitch', + title: 'Apex Legends — Highlight: clutch fight (twitch)', + description: 'Session: Ranked climb', + tags: ['apex-legends'], + }, + ]); + mockedSaveGameplayPreset.mockResolvedValue({}); + + const { container } = render(); + + await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { + target: { value: 'Apex Legends' }, + }); + fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), { + target: { value: 'Ranked climb' }, + }); + fireEvent.change(screen.getByPlaceholderText('/recordings/apex/night-01'), { + target: { value: '/recordings/apex' }, + }); + fireEvent.change(screen.getByPlaceholderText('Apex coaching preset'), { + target: { value: 'Apex preset' }, + }); + fireEvent.change(screen.getByPlaceholderText('aim, positioning, fight selection'), { + target: { value: 'aim, positioning' }, + }); + fireEvent.change(screen.getByPlaceholderText('What should the reviewer pay attention to?'), { + target: { value: 'Play clean and keep it spoiler-safe.' }, + }); + fireEvent.change(screen.getByPlaceholderText('twitch,kick,youtube'), { + target: { value: 'twitch,kick' }, + }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(fileInput).not.toBeNull(); + const file = new File([new Uint8Array([1, 2, 3])], 'frame-1.png', { type: 'image/png' }); + Object.defineProperty(file, 'webkitRelativePath', { value: 'session/frame-1.png' }); + fireEvent.change(fileInput as HTMLInputElement, { target: { files: [file] } }); + + fireEvent.click(screen.getByRole('button', { name: /import and analyze/i })); + + await waitFor(() => expect(mockedPrepareGameplayFrames).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText(/Gameplay recap/)).toBeInTheDocument()); + expect( + screen.getByText('Highlight: clutch fight', { selector: 'div.font-medium.text-stone-900' }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /ask session/i })); + await waitFor(() => expect(mockedAskGameplaySession).toHaveBeenCalled()); + expect(screen.getByText(/Best clip candidate/)).toBeInTheDocument(); + }); + + const BASE_ANALYSIS: GameplayReviewAnalysis = { + recap: 'Solid session recap', + highlights: [ + { + id: 'h1', + frame_index: 0, + captured_at_ms: 123, + title: 'Clutch fight', + rationale: 'Clean finish', + confidence: 0.9, + kind: 'highlight', + }, + ], + clip_candidates: [ + { + id: 'c1', + frame_index: 0, + start_label: 'frame-1.png', + end_label: 'frame-2.png', + rationale: 'Great pacing', + confidence: 0.8, + }, + ], + draft_metadata: [ + { platform: 'twitch', title: 'Clip title', description: 'Clip desc', tags: ['apex'] }, + ], + follow_up_questions: ['What was the turning point?'], + spoiler_note: 'Endgame results hidden.', + }; + + function analyzedSession(overrides: Partial = {}): GameplayReviewSession { + return { + session_id: 'session-1', + game_id: 'Apex Legends', + session_title: 'Ranked climb', + source_label: null, + spoiler_mode: 'full', + preset_id: null, + imported_at_ms: 1000, + analyzed_at_ms: 2000, + frames: [ + { file_name: 'frame-1.png', image_ref: 'data:image/png;base64,AAA', captured_at_ms: 123 }, + ], + analysis: BASE_ANALYSIS, + ...overrides, + }; + } + + function selectFile(container: HTMLElement) { + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File([new Uint8Array([1, 2, 3])], 'frame-1.png', { type: 'image/png' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + } + + it('surfaces an error when the initial session fetch fails', async () => { + mockedListGameplaySessions.mockRejectedValueOnce(new Error('list failed')); + + render(); + + await waitFor(() => expect(screen.getByText('list failed')).toBeInTheDocument()); + }); + + it('validates required fields before importing', async () => { + render(); + await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument()); + + const importButton = screen.getByRole('button', { name: /import and analyze/i }); + + fireEvent.click(importButton); + expect(screen.getByText('Game name is required.')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } }); + fireEvent.click(importButton); + expect(screen.getByText('Session title is required.')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), { + target: { value: 'Ranked' }, + }); + fireEvent.click(importButton); + expect( + screen.getByText('Choose a folder or a set of keyframe images first.') + ).toBeInTheDocument(); + expect(mockedPrepareGameplayFrames).not.toHaveBeenCalled(); + }); + + it('errors when no image frames are found and skips preset save without a preset name', async () => { + mockedPrepareGameplayFrames.mockResolvedValue([]); + + const { container } = render(); + await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } }); + fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), { + target: { value: 'Ranked' }, + }); + selectFile(container); + + fireEvent.click(screen.getByRole('button', { name: /import and analyze/i })); + + await waitFor(() => + expect(screen.getByText('No image frames were found in that folder.')).toBeInTheDocument() + ); + expect(mockedSaveGameplayPreset).not.toHaveBeenCalled(); + expect(mockedRegisterGameplaySession).not.toHaveBeenCalled(); + }); + + it('reports a toast and inline error when registration fails', async () => { + mockedPrepareGameplayFrames.mockResolvedValue([ + { + source_name: 'frame-1.png', + file_name: 'frame-1.png', + image_ref: 'data:image/png;base64,AAA', + captured_at_ms: 123, + }, + ]); + mockedRegisterGameplaySession.mockRejectedValue(new Error('register boom')); + const onToast = vi.fn(); + + const { container } = render(); + await waitFor(() => expect(screen.getByText('No saved sessions yet.')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Apex Legends'), { target: { value: 'Apex' } }); + fireEvent.change(screen.getByPlaceholderText('Ranked climb on Friday night'), { + target: { value: 'Ranked' }, + }); + selectFile(container); + + fireEvent.click(screen.getByRole('button', { name: /import and analyze/i })); + + await waitFor(() => expect(screen.getByText('register boom')).toBeInTheDocument()); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', title: 'Gameplay review failed' }) + ); + }); + + it('renders an analyzed session from history with highlights, clips, drafts, and follow-ups', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession()]); + + render(); + + await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument()); + expect(screen.getByText('Endgame results hidden.')).toBeInTheDocument(); + expect(screen.getByText('Clean finish')).toBeInTheDocument(); + expect(screen.getByText(/through frame-2.png/)).toBeInTheDocument(); + expect(screen.getByText('Clip title')).toBeInTheDocument(); + expect(screen.getByText('#apex')).toBeInTheDocument(); + + // Clicking a follow-up question seeds the question input. + fireEvent.click(screen.getByRole('button', { name: 'What was the turning point?' })); + expect( + (screen.getByPlaceholderText('What were my best fights?') as HTMLInputElement).value + ).toBe('What was the turning point?'); + + // Refresh history re-fetches sessions. + fireEvent.click(screen.getByRole('button', { name: /refresh history/i })); + await waitFor(() => expect(mockedListGameplaySessions).toHaveBeenCalledTimes(2)); + }); + + it('shows empty states when the active session has no analysis', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession({ analysis: null })]); + + render(); + + await waitFor(() => + expect(screen.getByText('No highlights detected yet.')).toBeInTheDocument() + ); + expect(screen.getByText('No clip candidates yet.')).toBeInTheDocument(); + expect(screen.getByText('Draft metadata will appear after analysis.')).toBeInTheDocument(); + }); + + it('refreshes clip metadata for the active session', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession()]); + mockedDraftGameplayClipMetadata.mockResolvedValue([ + { platform: 'kick', title: 'Refreshed title', description: 'New desc', tags: ['fresh'] }, + ]); + + render(); + await waitFor(() => expect(screen.getByText('Clip title')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /refresh clip metadata/i })); + + await waitFor(() => expect(screen.getByText('Refreshed title')).toBeInTheDocument()); + expect(mockedDraftGameplayClipMetadata).toHaveBeenCalledWith( + expect.objectContaining({ session_id: 'session-1', highlight_id: 'h1' }) + ); + }); + + it('surfaces an error when refreshing clip metadata fails', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession()]); + mockedDraftGameplayClipMetadata.mockRejectedValue(new Error('draft boom')); + + render(); + await waitFor(() => expect(screen.getByText('Clip title')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /refresh clip metadata/i })); + + await waitFor(() => expect(screen.getByText('draft boom')).toBeInTheDocument()); + }); + + it('surfaces an error when asking the session fails', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession()]); + mockedAskGameplaySession.mockRejectedValue(new Error('ask boom')); + + render(); + await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /ask session/i })); + + await waitFor(() => expect(screen.getByText('ask boom')).toBeInTheDocument()); + }); + + it('updates spoiler mode, audio feedback, and the question input', async () => { + mockedListGameplaySessions.mockResolvedValue([analyzedSession()]); + + const { container } = render(); + await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument()); + + const spoilerSelect = container.querySelector('select') as HTMLSelectElement; + fireEvent.change(spoilerSelect, { target: { value: 'off' } }); + expect(spoilerSelect.value).toBe('off'); + + const audioCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(audioCheckbox); + expect(audioCheckbox.checked).toBe(true); + + const questionInput = screen.getByPlaceholderText( + 'What were my best fights?' + ) as HTMLInputElement; + fireEvent.change(questionInput, { target: { value: 'Where did I lose tempo?' } }); + expect(questionInput.value).toBe('Where did I lose tempo?'); + + // The hidden file picker is triggered via the visible button. + fireEvent.click(screen.getByRole('button', { name: /choose folder \/ frames/i })); + }); + + it('switches the active session when picking one from history', async () => { + const first = analyzedSession(); + const second = analyzedSession({ + session_id: 'session-2', + session_title: 'Second session', + analysis: { ...BASE_ANALYSIS, recap: 'Second recap' }, + }); + mockedListGameplaySessions.mockResolvedValue([first, second]); + + render(); + await waitFor(() => expect(screen.getByText('Solid session recap')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /Second session/ })); + + await waitFor(() => expect(screen.getByText('Second recap')).toBeInTheDocument()); + }); +}); diff --git a/app/src/components/intelligence/GameplayReviewWorkspace.tsx b/app/src/components/intelligence/GameplayReviewWorkspace.tsx new file mode 100644 index 000000000..935e4f5ca --- /dev/null +++ b/app/src/components/intelligence/GameplayReviewWorkspace.tsx @@ -0,0 +1,598 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + analyzeGameplaySession, + askGameplaySession, + draftGameplayClipMetadata, + flattenClipCandidates, + flattenDrafts, + flattenHighlights, + formatSpoilerMode, + type GameplayFrameInput, + type GameplayReviewSession, + listGameplaySessions, + normalizeGameplayError, + prepareGameplayFrames, + registerGameplaySession, + saveGameplayPreset, + type SpoilerMode, +} from '../../services/gameplayReviewService'; +import type { ToastNotification } from '../../types/intelligence'; + +interface GameplayReviewWorkspaceProps { + onToast?: (toast: Omit) => void; +} + +interface ImportState { + gameId: string; + sessionTitle: string; + sourceLabel: string; + spoilerMode: SpoilerMode; + presetName: string; + coachingFocus: string; + notes: string; + audioFeedback: boolean; + platforms: string; +} + +const INITIAL_IMPORT_STATE: ImportState = { + gameId: '', + sessionTitle: '', + sourceLabel: '', + spoilerMode: 'light', + presetName: '', + coachingFocus: '', + notes: '', + audioFeedback: false, + platforms: 'twitch,kick,youtube', +}; + +const DIRECTORY_INPUT_PROPS: Record = { webkitdirectory: '' }; + +function formatTimestamp(ms?: number | null): string { + if (!ms) return 'n/a'; + return new Date(ms).toLocaleString(); +} + +function splitPlatforms(value: string): string[] { + return value + .split(',') + .map(item => item.trim()) + .filter(Boolean); +} + +export function GameplayReviewWorkspace({ onToast }: GameplayReviewWorkspaceProps) { + const { t } = useT(); + const [form, setForm] = useState(INITIAL_IMPORT_STATE); + const [selectedFiles, setSelectedFiles] = useState([]); + const [recentSessions, setRecentSessions] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [question, setQuestion] = useState('What were my best moments?'); + const [questionAnswer, setQuestionAnswer] = useState(''); + const [questionBusy, setQuestionBusy] = useState(false); + const [importBusy, setImportBusy] = useState(false); + const [analysisBusy, setAnalysisBusy] = useState(false); + const [draftBusy, setDraftBusy] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const refreshSessions = useCallback(async () => { + try { + const sessions = await listGameplaySessions(); + setRecentSessions(sessions); + setActiveSession(currentSession => currentSession ?? sessions[0] ?? null); + } catch (err) { + setError(normalizeGameplayError(err)); + } + }, []); + + useEffect(() => { + void refreshSessions(); + }, [refreshSessions]); + + const activeHighlights = useMemo(() => flattenHighlights(activeSession), [activeSession]); + const activeDrafts = useMemo(() => flattenDrafts(activeSession), [activeSession]); + const activeClips = useMemo(() => flattenClipCandidates(activeSession), [activeSession]); + + const handleSelectFiles = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFilesChanged = useCallback((event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + setSelectedFiles(files); + }, []); + + const handleImportSession = useCallback(async () => { + setError(null); + if (!form.gameId.trim()) { + setError('Game name is required.'); + return; + } + if (!form.sessionTitle.trim()) { + setError('Session title is required.'); + return; + } + if (selectedFiles.length === 0) { + setError('Choose a folder or a set of keyframe images first.'); + return; + } + + setImportBusy(true); + try { + const frames = await prepareGameplayFrames(selectedFiles); + if (frames.length === 0) { + throw new Error('No image frames were found in that folder.'); + } + + if (form.presetName.trim()) { + await saveGameplayPreset({ + game_id: form.gameId.trim(), + display_name: form.presetName.trim(), + coaching_focus: form.coachingFocus + .split(',') + .map(item => item.trim()) + .filter(Boolean), + audio_feedback: form.audioFeedback, + spoiler_mode: form.spoilerMode, + notes: form.notes.trim() || null, + }); + } + + const session = await registerGameplaySession({ + game_id: form.gameId.trim(), + session_title: form.sessionTitle.trim(), + source_label: form.sourceLabel.trim() || null, + spoiler_mode: form.spoilerMode, + preset_id: form.gameId.trim() || null, + frames: frames.map( + frame => + ({ + file_name: frame.file_name, + image_ref: frame.image_ref, + captured_at_ms: frame.captured_at_ms, + }) satisfies GameplayFrameInput + ), + }); + + setAnalysisBusy(true); + const analyzed = await analyzeGameplaySession({ + session_id: session.session_id, + max_highlights: 5, + platforms: splitPlatforms(form.platforms), + }); + setActiveSession(analyzed); + setQuestionAnswer(''); + onToast?.({ + type: 'success', + title: 'Gameplay session analyzed', + message: `Reviewed ${analyzed.frames.length} frame(s) for ${analyzed.game_id}.`, + }); + void refreshSessions(); + } catch (err) { + const message = normalizeGameplayError(err); + setError(message); + onToast?.({ type: 'error', title: 'Gameplay review failed', message }); + } finally { + setAnalysisBusy(false); + setImportBusy(false); + } + }, [form, onToast, refreshSessions, selectedFiles]); + + const handleQuestion = useCallback(async () => { + if (!activeSession) { + setError('Select or analyze a session first.'); + return; + } + setQuestionBusy(true); + try { + const response = await askGameplaySession({ session_id: activeSession.session_id, question }); + setQuestionAnswer(response.answer); + } catch (err) { + setError(normalizeGameplayError(err)); + } finally { + setQuestionBusy(false); + } + }, [activeSession, question]); + + const handleDraftClips = useCallback(async () => { + if (!activeSession) return; + setDraftBusy(true); + try { + const drafts = await draftGameplayClipMetadata({ + session_id: activeSession.session_id, + platform: splitPlatforms(form.platforms)[0] || 'twitch', + highlight_id: activeHighlights[0]?.id ?? null, + }); + setActiveSession(prev => + prev + ? { + ...prev, + analysis: prev.analysis + ? { ...prev.analysis, draft_metadata: drafts } + : prev.analysis, + } + : prev + ); + } catch (err) { + setError(normalizeGameplayError(err)); + } finally { + setDraftBusy(false); + } + }, [activeHighlights, activeSession, form.platforms]); + + return ( +
+
+
+
+

+ Gameplay review +

+

+ Import a session, find the clips, draft the post +

+

+ Load a folder of keyframes from a long session, generate a concise recap, ask + follow-up questions, and turn the strongest moments into platform-ready clip metadata. +

+
+
+
Selected files
+
{selectedFiles.length} file(s)
+
{t(formatSpoilerMode(form.spoilerMode))}
+
+
+ +
+ + + + + + +