Revert gameplay-specific review workflows (#2962)

This commit is contained in:
Steven Enamakel
2026-05-29 10:59:32 -07:00
committed by GitHub
parent fb708b92ae
commit 97808f6560
28 changed files with 0 additions and 3469 deletions
@@ -1,436 +0,0 @@
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(<GameplayReviewWorkspace />);
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> = {}): 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(<GameplayReviewWorkspace />);
await waitFor(() => expect(screen.getByText('list failed')).toBeInTheDocument());
});
it('validates required fields before importing', async () => {
render(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace onToast={onToast} />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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(<GameplayReviewWorkspace />);
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());
});
});
@@ -1,598 +0,0 @@
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<ToastNotification, 'id'>) => 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<string, string> = { 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<File[]>([]);
const [recentSessions, setRecentSessions] = useState<GameplayReviewSession[]>([]);
const [activeSession, setActiveSession] = useState<GameplayReviewSession | null>(null);
const [question, setQuestion] = useState('What were my best moments?');
const [questionAnswer, setQuestionAnswer] = useState<string>('');
const [questionBusy, setQuestionBusy] = useState(false);
const [importBusy, setImportBusy] = useState(false);
const [analysisBusy, setAnalysisBusy] = useState(false);
const [draftBusy, setDraftBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="space-y-4" data-testid="gameplay-review-workspace">
<div className="rounded-2xl border border-sage-200 bg-gradient-to-br from-white via-sage-50 to-amber-50 p-5 shadow-soft">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-sage-700">
Gameplay review
</p>
<h2 className="text-2xl font-bold text-stone-900">
Import a session, find the clips, draft the post
</h2>
<p className="max-w-2xl text-sm text-stone-600">
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.
</p>
</div>
<div className="rounded-xl border border-sage-200 bg-white/80 px-4 py-3 text-sm text-stone-700 shadow-sm">
<div className="font-semibold text-stone-900">Selected files</div>
<div>{selectedFiles.length} file(s)</div>
<div>{t(formatSpoilerMode(form.spoilerMode))}</div>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Game</span>
<input
value={form.gameId}
onChange={event => setForm(prev => ({ ...prev, gameId: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="Apex Legends"
/>
</label>
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Session title</span>
<input
value={form.sessionTitle}
onChange={event => setForm(prev => ({ ...prev, sessionTitle: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="Ranked climb on Friday night"
/>
</label>
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Source label</span>
<input
value={form.sourceLabel}
onChange={event => setForm(prev => ({ ...prev, sourceLabel: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="/recordings/apex/night-01"
/>
</label>
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Spoiler mode</span>
<select
value={form.spoilerMode}
onChange={event =>
setForm(prev => ({ ...prev, spoilerMode: event.target.value as SpoilerMode }))
}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500">
<option value="off">Off</option>
<option value="light">Light</option>
<option value="full">Full</option>
</select>
</label>
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Preset name</span>
<input
value={form.presetName}
onChange={event => setForm(prev => ({ ...prev, presetName: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="Apex coaching preset"
/>
</label>
<label className="space-y-1 text-sm">
<span className="font-medium text-stone-700">Focus areas</span>
<input
value={form.coachingFocus}
onChange={event => setForm(prev => ({ ...prev, coachingFocus: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="aim, positioning, fight selection"
/>
</label>
<label className="space-y-1 text-sm md:col-span-2 xl:col-span-3">
<span className="font-medium text-stone-700">Preset notes</span>
<textarea
value={form.notes}
onChange={event => setForm(prev => ({ ...prev, notes: event.target.value }))}
className="min-h-24 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="What should the reviewer pay attention to?"
/>
</label>
<label className="space-y-1 text-sm md:col-span-2">
<span className="font-medium text-stone-700">Clip platforms</span>
<input
value={form.platforms}
onChange={event => setForm(prev => ({ ...prev, platforms: event.target.value }))}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 outline-none ring-0 transition focus:border-primary-500"
placeholder="twitch,kick,youtube"
/>
</label>
<label className="flex items-center gap-2 self-end text-sm text-stone-700 md:col-span-1">
<input
type="checkbox"
checked={form.audioFeedback}
onChange={event =>
setForm(prev => ({ ...prev, audioFeedback: event.target.checked }))
}
/>
Enable audio summary notes
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
{...DIRECTORY_INPUT_PROPS}
className="hidden"
onChange={handleFilesChanged}
/>
<button
type="button"
onClick={handleSelectFiles}
className="rounded-xl border border-sage-200 bg-white px-4 py-2 text-sm font-semibold text-sage-800 shadow-sm transition hover:bg-sage-50">
Choose folder / frames
</button>
<button
type="button"
onClick={handleImportSession}
disabled={importBusy || analysisBusy}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60">
{analysisBusy ? 'Analyzing…' : importBusy ? 'Importing…' : 'Import and analyze'}
</button>
<button
type="button"
onClick={handleDraftClips}
disabled={draftBusy || !activeSession}
className="rounded-xl border border-amber-200 bg-white px-4 py-2 text-sm font-semibold text-amber-800 shadow-sm transition hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-60">
{draftBusy ? 'Drafting…' : 'Refresh clip metadata'}
</button>
<div className="text-xs text-stone-500">
The core will analyze image frames. For raw video files, point this at extracted
keyframes.
</div>
</div>
</div>
{error && (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-800">
{error}
</div>
)}
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<section className="space-y-4 rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-stone-900">Session viewer</h3>
<p className="text-sm text-stone-500">
Recap, highlights, and clip candidates for the current session.
</p>
</div>
<div className="rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-600">
{activeSession ? activeSession.game_id : 'No session selected'}
</div>
</div>
{activeSession ? (
<div className="space-y-4">
<div className="rounded-xl border border-sage-200 bg-sage-50 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-sage-900">
{activeSession.session_title}
</div>
<div className="text-xs text-sage-700">
Imported {formatTimestamp(activeSession.imported_at_ms)} ·{' '}
{activeSession.frames.length} frame(s)
</div>
</div>
<div className="text-xs font-medium text-sage-700">
{t(formatSpoilerMode(activeSession.spoiler_mode))}
</div>
</div>
{activeSession.analysis?.spoiler_note && (
<p className="mt-3 text-sm text-stone-700">
{activeSession.analysis.spoiler_note}
</p>
)}
{activeSession.analysis && (
<p className="mt-3 whitespace-pre-line text-sm text-stone-800">
{activeSession.analysis.recap}
</p>
)}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-stone-200 p-4">
<div className="text-sm font-semibold text-stone-900">Highlights</div>
<div className="mt-3 space-y-3">
{activeHighlights.length > 0 ? (
activeHighlights.map(highlight => (
<article
key={highlight.id}
className="rounded-lg border border-stone-200 bg-stone-50 p-3">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-stone-900">{highlight.title}</div>
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] text-stone-500">
{(highlight.confidence * 100).toFixed(0)}%
</span>
</div>
<p className="mt-1 text-xs uppercase tracking-wide text-stone-500">
{highlight.kind} · frame {highlight.frame_index + 1} ·{' '}
{formatTimestamp(highlight.captured_at_ms)}
</p>
<p className="mt-2 text-sm text-stone-700">{highlight.rationale}</p>
</article>
))
) : (
<p className="text-sm text-stone-500">No highlights detected yet.</p>
)}
</div>
</div>
<div className="rounded-xl border border-stone-200 p-4">
<div className="text-sm font-semibold text-stone-900">Clip candidates</div>
<div className="mt-3 space-y-3">
{activeClips.length > 0 ? (
activeClips.map(clip => (
<article
key={clip.id}
className="rounded-lg border border-amber-100 bg-amber-50 p-3">
<div className="font-medium text-amber-950">{clip.start_label}</div>
<div className="text-xs text-amber-800">through {clip.end_label}</div>
<p className="mt-2 text-sm text-stone-700">{clip.rationale}</p>
</article>
))
) : (
<p className="text-sm text-stone-500">No clip candidates yet.</p>
)}
</div>
</div>
</div>
<div className="rounded-xl border border-stone-200 p-4">
<div className="text-sm font-semibold text-stone-900">Platform drafts</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{activeDrafts.length > 0 ? (
activeDrafts.map(draft => (
<article
key={`${draft.platform}-${draft.title}`}
className="rounded-lg border border-stone-200 bg-white p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-stone-500">
{draft.platform}
</div>
<div className="mt-1 font-medium text-stone-900">{draft.title}</div>
<p className="mt-2 text-sm text-stone-700 whitespace-pre-line">
{draft.description}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{draft.tags.map(tag => (
<span
key={tag}
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600">
#{tag}
</span>
))}
</div>
</article>
))
) : (
<p className="text-sm text-stone-500">
Draft metadata will appear after analysis.
</p>
)}
</div>
</div>
<div className="rounded-xl border border-sage-200 bg-sage-50 p-4">
<div className="text-sm font-semibold text-sage-900">Ask this session</div>
<div className="mt-3 flex flex-col gap-3 lg:flex-row">
<input
value={question}
onChange={event => setQuestion(event.target.value)}
className="flex-1 rounded-xl border border-sage-200 bg-white px-3 py-2 outline-none ring-0 focus:border-primary-500"
placeholder="What were my best fights?"
/>
<button
type="button"
onClick={handleQuestion}
disabled={questionBusy}
className="rounded-xl bg-sage-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sage-800 disabled:cursor-not-allowed disabled:opacity-60">
{questionBusy ? 'Thinking…' : 'Ask session'}
</button>
</div>
{questionAnswer && (
<p className="mt-3 whitespace-pre-line text-sm text-stone-800">
{questionAnswer}
</p>
)}
{activeSession.analysis?.follow_up_questions &&
activeSession.analysis.follow_up_questions.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-stone-600">
{activeSession.analysis.follow_up_questions.map(item => (
<button
key={item}
type="button"
onClick={() => setQuestion(item)}
className="rounded-full border border-sage-200 bg-white px-3 py-1 transition hover:bg-sage-50">
{item}
</button>
))}
</div>
)}
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-8 text-center text-sm text-stone-500">
Import a folder of keyframes to create the first gameplay session.
</div>
)}
</section>
<aside className="space-y-4 rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
<div>
<h3 className="text-lg font-semibold text-stone-900">Session history</h3>
<p className="text-sm text-stone-500">
Previously imported or analyzed sessions for this workspace.
</p>
</div>
<div className="space-y-3">
{recentSessions.length > 0 ? (
recentSessions.map(session => (
<button
key={session.session_id}
type="button"
onClick={() => setActiveSession(session)}
className={`w-full rounded-xl border p-3 text-left transition ${
activeSession?.session_id === session.session_id
? 'border-primary-300 bg-primary-50'
: 'border-stone-200 bg-white hover:bg-stone-50'
}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-stone-900">{session.session_title}</div>
<div className="text-xs text-stone-500">{session.game_id}</div>
</div>
<div className="text-right text-[11px] text-stone-500">
<div>{t(formatSpoilerMode(session.spoiler_mode))}</div>
<div>{session.frames.length} frame(s)</div>
</div>
</div>
<div className="mt-2 text-xs text-stone-500">
Imported {formatTimestamp(session.imported_at_ms)}
</div>
</button>
))
) : (
<p className="text-sm text-stone-500">No saved sessions yet.</p>
)}
</div>
<button
type="button"
onClick={() => void refreshSessions()}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:bg-stone-50">
Refresh history
</button>
</aside>
</div>
</div>
);
}
-4
View File
@@ -175,10 +175,6 @@ const ar1: TranslationMap = {
'memory.noResults': 'لم يتم العثور على ذكريات',
'memory.empty': 'لا توجد ذكريات بعد. تُنشأ الذكريات تلقائيًا أثناء تفاعلك.',
'memory.tab.memory': 'الذاكرة',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'اللاوعي',
'memory.tab.dreams': 'الأحلام',
'memory.tab.calls': 'المكالمات',
-4
View File
@@ -177,10 +177,6 @@ const bn1: TranslationMap = {
'memory.empty':
'এখনো কোনো মেমোরি নেই। আপনি যত ইন্টারঅ্যাক্ট করবেন, মেমোরি স্বয়ংক্রিয়ভাবে তৈরি হবে।',
'memory.tab.memory': 'মেমোরি',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'সাবকনশাস',
'memory.tab.dreams': 'স্বপ্ন',
'memory.tab.calls': 'কল',
-4
View File
@@ -216,10 +216,6 @@ const de1: TranslationMap = {
'memory.empty':
'Noch keine Erinnerungen. Erinnerungen werden automatisch erstellt, während du interagierst.',
'memory.tab.memory': 'Erinnerung',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Unterbewusstsein',
'memory.tab.dreams': 'Träume',
'memory.tab.calls': 'Anrufe',
-4
View File
@@ -484,10 +484,6 @@ const en1: TranslationMap = {
'memory.noResults': 'No memories found',
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
'memory.tab.memory': 'Memory',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconscious',
'memory.tab.dreams': 'Dreams',
'memory.tab.calls': 'Calls',
-4
View File
@@ -183,10 +183,6 @@ const es1: TranslationMap = {
'memory.noResults': 'No se encontraron recuerdos',
'memory.empty': 'Sin recuerdos aún. Los recuerdos se crean automáticamente mientras interactúas.',
'memory.tab.memory': 'Memoria',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconsciente',
'memory.tab.dreams': 'Sueños',
'memory.tab.calls': 'Llamadas',
-4
View File
@@ -183,10 +183,6 @@ const fr1: TranslationMap = {
'memory.empty':
"Aucun souvenir pour l'instant. Les souvenirs sont créés automatiquement au fil de tes interactions.",
'memory.tab.memory': 'Mémoire',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconscient',
'memory.tab.dreams': 'Rêves',
'memory.tab.calls': 'Appels',
-4
View File
@@ -175,10 +175,6 @@ const hi1: TranslationMap = {
'memory.noResults': 'कोई मेमोरी नहीं मिली',
'memory.empty': 'अभी कोई मेमोरी नहीं है। बातचीत के दौरान मेमोरी अपने आप बनती है।',
'memory.tab.memory': 'मेमोरी',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'सबकॉन्शस',
'memory.tab.dreams': 'ड्रीम्स',
'memory.tab.calls': 'कॉल्स',
-4
View File
@@ -176,10 +176,6 @@ const id1: TranslationMap = {
'memory.noResults': 'Memori tidak ditemukan',
'memory.empty': 'Belum ada memori. Memori dibuat otomatis saat Anda berinteraksi.',
'memory.tab.memory': 'Memori',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Bawah sadar',
'memory.tab.dreams': 'Mimpi',
'memory.tab.calls': 'Panggilan',
-4
View File
@@ -181,10 +181,6 @@ const it1: TranslationMap = {
'memory.empty':
'Nessuna memoria ancora. Le memorie vengono create automaticamente mentre interagisci.',
'memory.tab.memory': 'Memoria',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconscio',
'memory.tab.dreams': 'Sogni',
'memory.tab.calls': 'Chiamate',
-4
View File
@@ -175,10 +175,6 @@ const ko1: TranslationMap = {
'memory.noResults': '메모리를 찾을 수 없습니다',
'memory.empty': '아직 메모리가 없습니다. 메모리는 상호작용하면서 자동으로 생성됩니다.',
'memory.tab.memory': '메모리',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': '잠재의식',
'memory.tab.dreams': '꿈',
'memory.tab.calls': '통화',
-4
View File
@@ -200,10 +200,6 @@ const pl1: TranslationMap = {
'memory.empty':
'Brak wspomnień. Wspomnienia powstają automatycznie podczas korzystania z aplikacji.',
'memory.tab.memory': 'Pamięć',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Podświadomość',
'memory.tab.dreams': 'Marzenia senne',
'memory.tab.calls': 'Połączenia',
-4
View File
@@ -182,10 +182,6 @@ const pt1: TranslationMap = {
'memory.empty':
'Nenhuma memória ainda. As memórias são criadas automaticamente conforme você interage.',
'memory.tab.memory': 'Memória',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconsciente',
'memory.tab.dreams': 'Sonhos',
'memory.tab.calls': 'Chamadas',
-4
View File
@@ -176,10 +176,6 @@ const ru1: TranslationMap = {
'memory.noResults': 'Воспоминания не найдены',
'memory.empty': 'Воспоминаний пока нет. Они создаются автоматически в процессе общения.',
'memory.tab.memory': 'Память',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Подсознание',
'memory.tab.dreams': 'Сны',
'memory.tab.calls': 'Звонки',
-4
View File
@@ -171,10 +171,6 @@ const zhCN1: TranslationMap = {
'memory.noResults': '未找到记忆',
'memory.empty': '暂无记忆。记忆将在你交互时自动创建。',
'memory.tab.memory': '记忆',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': '潜意识',
'memory.tab.dreams': '梦境',
'memory.tab.calls': '调用记录',
-4
View File
@@ -292,10 +292,6 @@ const en: TranslationMap = {
'memory.noResults': 'No memories found',
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
'memory.tab.memory': 'Memory',
'memory.tab.gameplay': 'Gameplay',
'gameplay.spoiler.off': 'Spoiler-safe',
'gameplay.spoiler.light': 'Light spoilers',
'gameplay.spoiler.full': 'Full spoilers',
'memory.tab.subconscious': 'Subconscious',
'memory.tab.dreams': 'Dreams',
'memory.tab.calls': 'Calls',
-5
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
import { GameplayReviewWorkspace } from '../components/intelligence/GameplayReviewWorkspace';
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
import IntelligenceCallsTab from '../components/intelligence/IntelligenceCallsTab';
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
@@ -24,7 +23,6 @@ import type {
type IntelligenceTab =
| 'memory'
| 'gameplay'
| 'subconscious'
| 'calls'
| 'dreams'
@@ -100,7 +98,6 @@ export default function Intelligence() {
const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'gameplay', label: t('memory.tab.gameplay') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{ id: 'tasks', label: 'Tasks' },
{ id: 'diagram', label: t('memory.tab.diagram') },
@@ -161,8 +158,6 @@ export default function Intelligence() {
{/* Tab content */}
{activeTab === 'memory' && <MemoryWorkspace onToast={addToast} />}
{activeTab === 'gameplay' && <GameplayReviewWorkspace onToast={addToast} />}
{activeTab === 'subconscious' && (
<IntelligenceSubconsciousTab
addSubconsciousTask={addSubconsciousTask}
@@ -1,296 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
analyzeGameplaySession,
askGameplaySession,
draftGameplayClipMetadata,
flattenClipCandidates,
flattenDrafts,
flattenHighlights,
formatSpoilerMode,
type GameplayReviewAnalysis,
type GameplayReviewSession,
listGameplayPresets,
listGameplaySessions,
normalizeGameplayError,
prepareGameplayFrames,
registerGameplaySession,
saveGameplayPreset,
} from './gameplayReviewService';
const mockCallCoreRpc = vi.fn();
vi.mock('./coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
function makeImageFile(
name: string,
bytes: number[],
options: { type?: string; relativePath?: string; lastModified?: number } = {}
): File {
const file = new File([new Uint8Array(bytes)], name, {
type: options.type ?? 'image/png',
lastModified: options.lastModified ?? 0,
});
if (options.relativePath !== undefined) {
Object.defineProperty(file, 'webkitRelativePath', { value: options.relativePath });
}
return file;
}
const ANALYSIS: GameplayReviewAnalysis = {
recap: 'Recap',
highlights: [
{
id: 'h1',
frame_index: 0,
captured_at_ms: 1,
title: 'Clutch',
rationale: 'Clean',
confidence: 0.9,
kind: 'highlight',
},
],
clip_candidates: [
{
id: 'c1',
frame_index: 0,
start_label: 'a',
end_label: 'b',
rationale: 'why',
confidence: 0.8,
},
],
draft_metadata: [{ platform: 'twitch', title: 't', description: 'd', tags: ['x'] }],
follow_up_questions: ['q?'],
spoiler_note: null,
};
const SESSION: GameplayReviewSession = {
session_id: 's1',
game_id: 'Apex',
session_title: 'Ranked',
source_label: null,
spoiler_mode: 'light',
preset_id: null,
imported_at_ms: 1000,
analyzed_at_ms: 2000,
frames: [],
analysis: ANALYSIS,
};
describe('gameplayReviewService', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
describe('prepareGameplayFrames', () => {
it('filters to images, sorts by lowercased path, caps to maxFrames, and base64-encodes', async () => {
const files = [
makeImageFile('B.png', [1, 2, 3], { type: 'image/png', lastModified: 42 }),
makeImageFile('a.png', [4, 5], { type: 'image/png', lastModified: 7 }),
// Non-image file should be filtered out.
new File([new Uint8Array([9])], 'notes.txt', { type: 'text/plain' }),
];
const prepared = await prepareGameplayFrames(files, 5);
expect(prepared).toHaveLength(2);
// 'a.png' sorts before 'b.png' (case-insensitive).
expect(prepared[0].file_name).toBe('a.png');
expect(prepared[1].file_name).toBe('B.png');
expect(prepared[0].source_name).toBe('a.png');
expect(prepared[0].image_ref.startsWith('data:image/png;base64,')).toBe(true);
expect(prepared[0].captured_at_ms).toBe(7);
// Decoding the payload yields the original bytes.
const payload = prepared[0].image_ref.split(',')[1];
const decoded = atob(payload);
expect([decoded.charCodeAt(0), decoded.charCodeAt(1)]).toEqual([4, 5]);
});
it('prefers webkitRelativePath and maps lastModified=0 to null', async () => {
const file = makeImageFile('frame.png', [10], {
type: 'image/jpeg',
relativePath: 'session/frame.png',
lastModified: 0,
});
const [prepared] = await prepareGameplayFrames([file]);
expect(prepared.file_name).toBe('session/frame.png');
expect(prepared.source_name).toBe('session/frame.png');
expect(prepared.image_ref.startsWith('data:image/jpeg;base64,')).toBe(true);
expect(prepared.captured_at_ms).toBeNull();
});
it('honors maxFrames by slicing the sorted image list', async () => {
const files = [
makeImageFile('1.png', [1]),
makeImageFile('2.png', [2]),
makeImageFile('3.png', [3]),
];
const prepared = await prepareGameplayFrames(files, 2);
expect(prepared.map(frame => frame.file_name)).toEqual(['1.png', '2.png']);
});
it('floors maxFrames at 1 even when callers pass 0', async () => {
const prepared = await prepareGameplayFrames([makeImageFile('only.png', [1])], 0);
expect(prepared).toHaveLength(1);
});
});
describe('RPC wrappers', () => {
it('registerGameplaySession forwards the payload', async () => {
mockCallCoreRpc.mockResolvedValueOnce(SESSION);
const payload = { game_id: 'Apex', session_title: 'Ranked', frames: [] };
const result = await registerGameplaySession(payload);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_register_session',
params: payload,
});
expect(result).toBe(SESSION);
});
it('analyzeGameplaySession forwards the payload', async () => {
mockCallCoreRpc.mockResolvedValueOnce(SESSION);
const payload = { session_id: 's1', max_highlights: 5, platforms: ['twitch'] };
const result = await analyzeGameplaySession(payload);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_analyze_session',
params: payload,
});
expect(result).toBe(SESSION);
});
it('listGameplaySessions sends game_id filter when provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce([SESSION]);
const result = await listGameplaySessions('Apex');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_list_sessions',
params: { game_id: 'Apex' },
});
expect(result).toEqual([SESSION]);
});
it('listGameplaySessions sends empty params when no game id', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
await listGameplaySessions();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_list_sessions',
params: {},
});
});
it('saveGameplayPreset forwards the payload', async () => {
mockCallCoreRpc.mockResolvedValueOnce({});
const payload = {
game_id: 'Apex',
display_name: 'Preset',
coaching_focus: ['aim'],
audio_feedback: true,
spoiler_mode: 'full' as const,
notes: null,
};
await saveGameplayPreset(payload);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_set_preset',
params: payload,
});
});
it('listGameplayPresets calls the presets RPC', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
const result = await listGameplayPresets();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_list_presets',
});
expect(result).toEqual([]);
});
it('askGameplaySession forwards the question payload', async () => {
const answer = { answer: 'A', matched_highlights: [], suggested_follow_up: [] };
mockCallCoreRpc.mockResolvedValueOnce(answer);
const payload = { session_id: 's1', question: 'best?' };
const result = await askGameplaySession(payload);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_ask_session',
params: payload,
});
expect(result).toBe(answer);
});
it('draftGameplayClipMetadata forwards the payload', async () => {
const drafts = [{ platform: 'twitch', title: 't', description: 'd', tags: [] }];
mockCallCoreRpc.mockResolvedValueOnce(drafts);
const payload = { session_id: 's1', platform: 'twitch', highlight_id: 'h1' };
const result = await draftGameplayClipMetadata(payload);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.gameplay_review_draft_clip_metadata',
params: payload,
});
expect(result).toBe(drafts);
});
});
describe('flatten helpers', () => {
it('flattenHighlights returns analysis highlights or [] when missing', () => {
expect(flattenHighlights(SESSION)).toBe(ANALYSIS.highlights);
expect(flattenHighlights(null)).toEqual([]);
expect(flattenHighlights({ ...SESSION, analysis: null })).toEqual([]);
});
it('flattenDrafts returns analysis drafts or [] when missing', () => {
expect(flattenDrafts(SESSION)).toBe(ANALYSIS.draft_metadata);
expect(flattenDrafts(null)).toEqual([]);
expect(flattenDrafts({ ...SESSION, analysis: null })).toEqual([]);
});
it('flattenClipCandidates returns analysis clips or [] when missing', () => {
expect(flattenClipCandidates(SESSION)).toBe(ANALYSIS.clip_candidates);
expect(flattenClipCandidates(null)).toEqual([]);
expect(flattenClipCandidates({ ...SESSION, analysis: null })).toEqual([]);
});
});
describe('formatSpoilerMode', () => {
it('maps each spoiler mode to its label', () => {
expect(formatSpoilerMode('off')).toBe('gameplay.spoiler.off');
expect(formatSpoilerMode('full')).toBe('gameplay.spoiler.full');
expect(formatSpoilerMode('light')).toBe('gameplay.spoiler.light');
});
});
describe('normalizeGameplayError', () => {
it('returns the message for Error instances', () => {
expect(normalizeGameplayError(new Error('boom'))).toBe('boom');
});
it('returns the string for string errors', () => {
expect(normalizeGameplayError('plain failure')).toBe('plain failure');
});
it('returns a default message for unknown error shapes', () => {
expect(normalizeGameplayError({ weird: true })).toBe('Gameplay review failed');
});
});
});
-231
View File
@@ -1,231 +0,0 @@
import debug from 'debug';
import { callCoreRpc } from './coreRpcClient';
const log = debug('gameplay-review');
export type SpoilerMode = 'off' | 'light' | 'full';
export interface GameplayFrameInput {
file_name: string;
image_ref: string;
captured_at_ms?: number | null;
}
export interface GameplayReviewSessionInput {
game_id: string;
session_title: string;
source_label?: string | null;
spoiler_mode?: SpoilerMode | null;
preset_id?: string | null;
frames: GameplayFrameInput[];
}
export interface GameplayReviewAnalysisInput {
session_id: string;
max_highlights?: number;
platforms?: string[];
}
export interface GameplayPresetInput {
game_id: string;
display_name: string;
coaching_focus: string[];
audio_feedback: boolean;
spoiler_mode: SpoilerMode;
notes?: string | null;
}
export interface GameplayReviewPreset extends GameplayPresetInput {
updated_at_ms: number;
}
export interface GameplayReviewQuestionInput {
session_id: string;
question: string;
}
export interface GameplayReviewClipInput {
session_id: string;
platform?: string | null;
highlight_id?: string | null;
}
export interface GameplayHighlight {
id: string;
frame_index: number;
captured_at_ms?: number | null;
title: string;
rationale: string;
confidence: number;
kind: 'highlight' | 'mistake' | 'coaching';
}
export interface GameplayClipCandidate {
id: string;
frame_index: number;
start_label: string;
end_label: string;
rationale: string;
confidence: number;
}
export interface GameplayPlatformDraft {
platform: string;
title: string;
description: string;
tags: string[];
}
export interface GameplayReviewAnalysis {
recap: string;
highlights: GameplayHighlight[];
clip_candidates: GameplayClipCandidate[];
draft_metadata: GameplayPlatformDraft[];
follow_up_questions: string[];
spoiler_note?: string | null;
}
export interface GameplayReviewSession {
session_id: string;
game_id: string;
session_title: string;
source_label?: string | null;
spoiler_mode: SpoilerMode;
preset_id?: string | null;
imported_at_ms: number;
analyzed_at_ms?: number | null;
frames: GameplayFrameInput[];
analysis?: GameplayReviewAnalysis | null;
}
export interface GameplayReviewQuestionResult {
answer: string;
matched_highlights: GameplayHighlight[];
suggested_follow_up: string[];
}
export interface PreparedGameplayFrame extends GameplayFrameInput {
source_name: string;
}
export async function prepareGameplayFrames(
files: File[],
maxFrames = 12
): Promise<PreparedGameplayFrame[]> {
const imageFiles = files
.filter(file => file.type.startsWith('image/'))
.sort((left, right) => {
const leftName = (left.webkitRelativePath || left.name).toLowerCase();
const rightName = (right.webkitRelativePath || right.name).toLowerCase();
return leftName.localeCompare(rightName);
})
.slice(0, Math.max(1, maxFrames));
log(
'prepareGameplayFrames: selected %d image(s) from %d file(s)',
imageFiles.length,
files.length
);
const prepared: PreparedGameplayFrame[] = [];
for (const file of imageFiles) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let base64 = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
base64 += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
prepared.push({
source_name: file.webkitRelativePath || file.name,
file_name: file.webkitRelativePath || file.name,
image_ref: `data:${file.type || 'image/png'};base64,${btoa(base64)}`,
captured_at_ms: file.lastModified || null,
});
}
return prepared;
}
export async function registerGameplaySession(
payload: GameplayReviewSessionInput
): Promise<GameplayReviewSession> {
const result = await callCoreRpc<GameplayReviewSession>({
method: 'openhuman.gameplay_review_register_session',
params: payload,
});
return result;
}
export async function analyzeGameplaySession(
payload: GameplayReviewAnalysisInput
): Promise<GameplayReviewSession> {
const result = await callCoreRpc<GameplayReviewSession>({
method: 'openhuman.gameplay_review_analyze_session',
params: payload,
});
return result;
}
export async function listGameplaySessions(gameId?: string): Promise<GameplayReviewSession[]> {
const result = await callCoreRpc<GameplayReviewSession[]>({
method: 'openhuman.gameplay_review_list_sessions',
params: gameId ? { game_id: gameId } : {},
});
return result;
}
export async function saveGameplayPreset(payload: GameplayPresetInput): Promise<unknown> {
return callCoreRpc({ method: 'openhuman.gameplay_review_set_preset', params: payload });
}
export async function listGameplayPresets(): Promise<GameplayReviewPreset[]> {
return callCoreRpc<GameplayReviewPreset[]>({ method: 'openhuman.gameplay_review_list_presets' });
}
export async function askGameplaySession(
payload: GameplayReviewQuestionInput
): Promise<GameplayReviewQuestionResult> {
return callCoreRpc<GameplayReviewQuestionResult>({
method: 'openhuman.gameplay_review_ask_session',
params: payload,
});
}
export async function draftGameplayClipMetadata(
payload: GameplayReviewClipInput
): Promise<GameplayPlatformDraft[]> {
return callCoreRpc<GameplayPlatformDraft[]>({
method: 'openhuman.gameplay_review_draft_clip_metadata',
params: payload,
});
}
export function flattenHighlights(session: GameplayReviewSession | null): GameplayHighlight[] {
return session?.analysis?.highlights ?? [];
}
export function flattenDrafts(session: GameplayReviewSession | null): GameplayPlatformDraft[] {
return session?.analysis?.draft_metadata ?? [];
}
export function flattenClipCandidates(
session: GameplayReviewSession | null
): GameplayClipCandidate[] {
return session?.analysis?.clip_candidates ?? [];
}
export function formatSpoilerMode(mode: SpoilerMode): string {
switch (mode) {
case 'off':
return 'gameplay.spoiler.off';
case 'full':
return 'gameplay.spoiler.full';
default:
return 'gameplay.spoiler.light';
}
}
export function normalizeGameplayError(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Gameplay review failed';
}
-4
View File
@@ -175,9 +175,6 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(
crate::openhuman::screen_intelligence::all_screen_intelligence_registered_controllers(),
);
// Desktop gameplay review workflow
controllers
.extend(crate::openhuman::gameplay_review::all_gameplay_review_registered_controllers());
// Backend Socket.IO bridge + related runtime plumbing
controllers.extend(crate::openhuman::socket::all_socket_registered_controllers());
// Managed Node.js runtime bridge (tool listing + dispatch)
@@ -315,7 +312,6 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas
.extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas());
schemas.extend(crate::openhuman::channels::controllers::all_channels_controller_schemas());
schemas.extend(crate::openhuman::gameplay_review::all_gameplay_review_controller_schemas());
schemas.extend(crate::openhuman::config::all_config_controller_schemas());
schemas.extend(crate::openhuman::connectivity::all_connectivity_controller_schemas());
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
-50
View File
@@ -877,56 +877,6 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.gameplay_review_import",
name: "Import Gameplay Review Sessions",
domain: "screen_intelligence",
category: CapabilityCategory::ScreenIntelligence,
description: "Import local gameplay keyframes or a recordings folder into a review session.",
how_to: "Intelligence > Gameplay > Import session",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.gameplay_review_analyze",
name: "Analyze Gameplay Sessions",
domain: "screen_intelligence",
category: CapabilityCategory::ScreenIntelligence,
description: "Generate a recap, highlight list, and review notes from imported gameplay frames.",
how_to: "Intelligence > Gameplay > Analyze session",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.gameplay_review_clip_metadata",
name: "Draft Clip Metadata",
domain: "screen_intelligence",
category: CapabilityCategory::ScreenIntelligence,
description: "Generate platform-ready titles, descriptions, and tags for gameplay clips.",
how_to: "Intelligence > Gameplay > Draft clips",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.gameplay_review_presets",
name: "Save Game Coaching Presets",
domain: "screen_intelligence",
category: CapabilityCategory::ScreenIntelligence,
description: "Store game-specific coaching focus, spoiler mode, and notes for repeat reviews.",
how_to: "Intelligence > Gameplay > Presets",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.gameplay_review_spoilers",
name: "Set Spoiler Controls",
domain: "screen_intelligence",
category: CapabilityCategory::ScreenIntelligence,
description: "Keep gameplay coaching spoiler-safe or story-aware per session.",
how_to: "Intelligence > Gameplay > Spoiler mode",
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "screen_intelligence.configure_capture_fps",
name: "Configure Capture FPS",
-14
View File
@@ -1,14 +0,0 @@
//! Gameplay session review, highlight detection, and clip drafting.
pub mod ops;
mod schemas;
mod store;
pub mod types;
pub use ops as rpc;
pub use ops::*;
pub use schemas::{
all_controller_schemas as all_gameplay_review_controller_schemas,
all_registered_controllers as all_gameplay_review_registered_controllers,
};
pub use types::*;
-875
View File
@@ -1,875 +0,0 @@
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::path::Path;
use chrono::Utc;
use log::{debug, trace, warn};
use uuid::Uuid;
use crate::openhuman::config::Config;
use crate::openhuman::screen_intelligence::{global_engine, CaptureFrame};
use crate::rpc::RpcOutcome;
use super::store;
use super::types::{
GameplayClipCandidate, GameplayFrameInput, GameplayHighlight, GameplayPlatformDraft,
GameplayPresetInput, GameplayReviewAnalysis, GameplayReviewAnalysisInput,
GameplayReviewClipInput, GameplayReviewPreset, GameplayReviewQuestionInput,
GameplayReviewQuestionResult, GameplayReviewSession, GameplayReviewSessionInput, HighlightKind,
SpoilerMode,
};
const DEFAULT_PLATFORMS: &[&str] = &["twitch", "kick", "youtube"];
pub async fn register_session(
payload: GameplayReviewSessionInput,
) -> Result<RpcOutcome<GameplayReviewSession>, String> {
debug!(
"[gameplay_review][rpc] register_session start game_id={} frames={} spoiler_mode={}",
payload.game_id,
payload.frames.len(),
payload.spoiler_mode.unwrap_or_default().as_str()
);
let session = build_session(payload)?;
let workspace_dir = workspace_dir().await?;
debug!(
"[gameplay_review][rpc] register_session workspace_dir={} session_id={}",
workspace_dir.display(),
session.session_id
);
store::save_session(&workspace_dir, &session)?;
debug!(
"[gameplay_review][rpc] register_session complete session_id={} game_id={}",
session.session_id, session.game_id
);
Ok(RpcOutcome::single_log(
session,
"gameplay review session registered",
))
}
pub async fn analyze_session(
payload: GameplayReviewAnalysisInput,
) -> Result<RpcOutcome<GameplayReviewSession>, String> {
debug!(
"[gameplay_review][rpc] analyze_session start session_id={} max_highlights={:?} platform_overrides={}",
payload.session_id,
payload.max_highlights,
payload.platforms.len()
);
let workspace_dir = workspace_dir().await?;
let mut session = store::load_session(&workspace_dir, &payload.session_id)?
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
debug!(
"[gameplay_review][rpc] analyze_session loaded session_id={} game_id={} frames={} preset_id={:?}",
session.session_id,
session.game_id,
session.frames.len(),
session.preset_id
);
let preset = match session.preset_id.as_deref() {
Some(game_id) => {
debug!(
"[gameplay_review][rpc] analyze_session loading preset game_id={}",
game_id
);
store::load_preset(&workspace_dir, game_id)?
}
None => {
trace!(
"[gameplay_review][rpc] analyze_session no preset configured session_id={}",
session.session_id
);
None
}
};
let analysis = analyze_session_frames(
&session,
preset.as_ref(),
payload.max_highlights,
&payload.platforms,
)
.await;
session.analyzed_at_ms = Some(Utc::now().timestamp_millis());
session.analysis = Some(analysis);
store::save_session(&workspace_dir, &session)?;
debug!(
"[gameplay_review][rpc] analyze_session complete session_id={} highlights={} clips={} drafts={}",
session.session_id,
session
.analysis
.as_ref()
.map(|analysis| analysis.highlights.len())
.unwrap_or(0),
session
.analysis
.as_ref()
.map(|analysis| analysis.clip_candidates.len())
.unwrap_or(0),
session
.analysis
.as_ref()
.map(|analysis| analysis.draft_metadata.len())
.unwrap_or(0)
);
Ok(RpcOutcome::single_log(
session,
"gameplay review session analyzed",
))
}
pub async fn get_session(session_id: String) -> Result<RpcOutcome<GameplayReviewSession>, String> {
debug!(
"[gameplay_review][rpc] get_session start session_id={}",
session_id
);
let workspace_dir = workspace_dir().await?;
let session = store::load_session(&workspace_dir, &session_id)?
.ok_or_else(|| format!("gameplay session not found: {session_id}"))?;
debug!(
"[gameplay_review][rpc] get_session complete session_id={} game_id={} analyzed={}",
session.session_id,
session.game_id,
session.analysis.is_some()
);
Ok(RpcOutcome::single_log(
session,
"gameplay review session fetched",
))
}
pub async fn list_sessions(
game_id: Option<String>,
) -> Result<RpcOutcome<Vec<GameplayReviewSession>>, String> {
debug!(
"[gameplay_review][rpc] list_sessions start game_id_filter={:?}",
game_id
);
let workspace_dir = workspace_dir().await?;
let mut sessions = store::list_sessions(&workspace_dir)?;
if let Some(filter) = game_id.as_deref() {
let before = sessions.len();
sessions.retain(|session| session.game_id == filter);
debug!(
"[gameplay_review][rpc] list_sessions filtered game_id={} before={} after={}",
filter,
before,
sessions.len()
);
}
debug!(
"[gameplay_review][rpc] list_sessions complete count={}",
sessions.len()
);
Ok(RpcOutcome::single_log(
sessions,
"gameplay review sessions listed",
))
}
pub async fn set_preset(
payload: GameplayPresetInput,
) -> Result<RpcOutcome<GameplayReviewPreset>, String> {
debug!(
"[gameplay_review][rpc] set_preset start game_id={} display_name={} focus_items={} spoiler_mode={}",
payload.game_id,
payload.display_name,
payload.coaching_focus.len(),
payload.spoiler_mode.as_str()
);
let workspace_dir = workspace_dir().await?;
let preset = store::preset_from_input(payload);
store::save_preset(&workspace_dir, &preset)?;
debug!(
"[gameplay_review][rpc] set_preset complete game_id={} path_hint={}",
preset.game_id,
store::preset_path(&workspace_dir, &preset.game_id).display()
);
Ok(RpcOutcome::single_log(
preset,
"gameplay review preset saved",
))
}
pub async fn list_presets() -> Result<RpcOutcome<Vec<GameplayReviewPreset>>, String> {
debug!("[gameplay_review][rpc] list_presets start");
let workspace_dir = workspace_dir().await?;
let presets = store::list_presets(&workspace_dir)?;
debug!(
"[gameplay_review][rpc] list_presets complete count={}",
presets.len()
);
Ok(RpcOutcome::single_log(
presets,
"gameplay review presets listed",
))
}
pub async fn ask_session(
payload: GameplayReviewQuestionInput,
) -> Result<RpcOutcome<GameplayReviewQuestionResult>, String> {
debug!(
"[gameplay_review][rpc] ask_session start session_id={} question_len={}",
payload.session_id,
payload.question.len()
);
let workspace_dir = workspace_dir().await?;
let session = store::load_session(&workspace_dir, &payload.session_id)?
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
let analysis = session
.analysis
.clone()
.unwrap_or_else(|| GameplayReviewAnalysis {
recap: build_recap(&session, &[], None),
highlights: Vec::new(),
clip_candidates: Vec::new(),
draft_metadata: Vec::new(),
follow_up_questions: default_follow_up_questions(&session),
spoiler_note: None,
});
let answer = answer_question(&session, analysis.clone(), &payload.question);
let matched_highlights = matched_highlights(&analysis.highlights, &payload.question);
let suggested_follow_up = if analysis.follow_up_questions.is_empty() {
default_follow_up_questions(&session)
} else {
analysis.follow_up_questions.clone()
};
debug!(
"[gameplay_review][rpc] ask_session complete session_id={} matched_highlights={} suggested_follow_up={}",
session.session_id,
matched_highlights.len(),
suggested_follow_up.len()
);
Ok(RpcOutcome::single_log(
GameplayReviewQuestionResult {
answer,
matched_highlights,
suggested_follow_up,
},
"gameplay review question answered",
))
}
pub async fn draft_clip_metadata(
payload: GameplayReviewClipInput,
) -> Result<RpcOutcome<Vec<GameplayPlatformDraft>>, String> {
debug!(
"[gameplay_review][rpc] draft_clip_metadata start session_id={} platform={:?} highlight_id={:?}",
payload.session_id,
payload.platform,
payload.highlight_id
);
let workspace_dir = workspace_dir().await?;
let session = store::load_session(&workspace_dir, &payload.session_id)?
.ok_or_else(|| format!("gameplay session not found: {}", payload.session_id))?;
let analysis = session
.analysis
.as_ref()
.ok_or_else(|| "session has not been analyzed yet".to_string())?;
let drafts = draft_metadata(
&session,
analysis,
payload.platform.as_deref(),
payload.highlight_id.as_deref(),
);
debug!(
"[gameplay_review][rpc] draft_clip_metadata complete session_id={} drafts={}",
session.session_id,
drafts.len()
);
Ok(RpcOutcome::single_log(
drafts,
"gameplay review clip metadata drafted",
))
}
async fn workspace_dir() -> Result<std::path::PathBuf, String> {
debug!("[gameplay_review][rpc] workspace_dir load start");
let config = Config::load_or_init()
.await
.map_err(|err| format!("gameplay review config load failed: {err}"))?;
let workspace_dir = store::workspace_dir_from_config(&config);
trace!(
"[gameplay_review][rpc] workspace_dir resolved path={}",
workspace_dir.display()
);
Ok(workspace_dir)
}
fn build_session(payload: GameplayReviewSessionInput) -> Result<GameplayReviewSession, String> {
debug!(
"[gameplay_review][rpc] build_session start game_id={} session_title={} frames={}",
payload.game_id,
payload.session_title,
payload.frames.len()
);
if payload.game_id.trim().is_empty() {
warn!("[gameplay_review][rpc] build_session rejected empty game_id");
return Err("game_id is required".to_string());
}
if payload.session_title.trim().is_empty() {
warn!("[gameplay_review][rpc] build_session rejected empty session_title");
return Err("session_title is required".to_string());
}
if payload.frames.is_empty() {
warn!("[gameplay_review][rpc] build_session rejected empty frames");
return Err("at least one frame is required".to_string());
}
let now = Utc::now().timestamp_millis();
let session_id = format!(
"gameplay-{}-{}",
store::slugify(&payload.game_id),
Uuid::new_v4().simple()
);
Ok(GameplayReviewSession {
session_id,
game_id: payload.game_id.trim().to_string(),
session_title: payload.session_title.trim().to_string(),
source_label: payload
.source_label
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
spoiler_mode: payload.spoiler_mode.unwrap_or_default(),
preset_id: payload
.preset_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
imported_at_ms: now,
analyzed_at_ms: None,
frames: payload.frames,
analysis: None,
})
}
async fn analyze_session_frames(
session: &GameplayReviewSession,
preset: Option<&GameplayReviewPreset>,
max_highlights: Option<usize>,
platform_overrides: &[String],
) -> GameplayReviewAnalysis {
let limit = max_highlights.unwrap_or(5).clamp(1, 8);
let platforms: Vec<String> = if platform_overrides.is_empty() {
DEFAULT_PLATFORMS
.iter()
.map(|platform| (*platform).to_string())
.collect()
} else {
platform_overrides.to_vec()
};
debug!(
"[gameplay_review][analysis] start session_id={} game_id={} frames={} limit={} platforms={}",
session.session_id,
session.game_id,
session.frames.len(),
limit,
platforms.len()
);
if let Some(preset) = preset {
trace!(
"[gameplay_review][analysis] using preset game_id={} coaching_focus={} audio_feedback={} spoiler_mode={}",
preset.game_id,
preset.coaching_focus.len(),
preset.audio_feedback,
preset.spoiler_mode.as_str()
);
} else {
trace!(
"[gameplay_review][analysis] no preset for session_id={}",
session.session_id
);
}
let mut highlights = Vec::new();
let mut clip_candidates = Vec::new();
for (index, frame) in session.frames.iter().enumerate() {
trace!(
"[gameplay_review][analysis] analyze frame session_id={} frame_index={} file_name={}",
session.session_id,
index,
frame.file_name
);
let capture = CaptureFrame {
captured_at_ms: frame
.captured_at_ms
.unwrap_or(session.imported_at_ms.saturating_add((index as i64) * 1000)),
reason: "gameplay_review_import".to_string(),
app_name: Some(session.game_id.clone()),
window_title: Some(session.session_title.clone()),
image_ref: Some(frame.image_ref.clone()),
};
let summary = match global_engine().analyze_and_persist_frame(capture).await {
Ok(summary) => summary,
Err(err) => {
warn!(
"[gameplay_review][analysis] frame analysis fallback session_id={} frame_index={} error={}",
session.session_id,
index,
err
);
fallback_summary(session, frame, index)
}
};
let kind = classify_kind(
&summary.key_text,
&summary.actionable_notes,
session.spoiler_mode,
);
let title = highlight_title(&summary.key_text, &frame.file_name, kind);
let rationale = if summary.actionable_notes.trim().is_empty() {
summary.key_text.clone()
} else {
summary.actionable_notes.clone()
};
let confidence = summary.confidence.clamp(0.0, 1.0);
let highlight = GameplayHighlight {
id: format!("{}-{}", session.session_id, index),
frame_index: index,
captured_at_ms: Some(summary.captured_at_ms),
title,
rationale,
confidence,
kind,
};
highlights.push(highlight);
}
highlights.sort_by(|left, right| {
right
.confidence
.partial_cmp(&left.confidence)
.unwrap_or(Ordering::Equal)
.then(left.frame_index.cmp(&right.frame_index))
});
highlights.truncate(limit);
for highlight in &highlights {
clip_candidates.push(clip_candidate_from_highlight(session, highlight));
}
let recap = build_recap(session, &highlights, preset);
let draft_metadata = draft_metadata_from_highlights(session, &highlights, preset, &platforms);
debug!(
"[gameplay_review][analysis] complete session_id={} highlights={} clips={} drafts={}",
session.session_id,
highlights.len(),
clip_candidates.len(),
draft_metadata.len()
);
GameplayReviewAnalysis {
recap,
highlights,
clip_candidates,
draft_metadata,
follow_up_questions: default_follow_up_questions(session),
spoiler_note: spoiler_note(session.spoiler_mode),
}
}
fn fallback_summary(
session: &GameplayReviewSession,
frame: &GameplayFrameInput,
index: usize,
) -> crate::openhuman::screen_intelligence::VisionSummary {
trace!(
"[gameplay_review][analysis] fallback_summary session_id={} frame_index={} file_name={}",
session.session_id,
index,
frame.file_name
);
let label = frame
.file_name
.rsplit_once('/')
.map(|(_, tail)| tail)
.unwrap_or(&frame.file_name)
.trim();
let captured_at_ms = frame
.captured_at_ms
.unwrap_or(session.imported_at_ms.saturating_add((index as i64) * 1000));
crate::openhuman::screen_intelligence::VisionSummary {
id: format!("gameplay-{}-{}", captured_at_ms, Uuid::new_v4()),
captured_at_ms,
app_name: Some(session.game_id.clone()),
window_title: Some(session.session_title.clone()),
ui_state: format!("Gameplay frame {index}: {label}"),
key_text: format!("Gameplay moment from {}", label),
actionable_notes: if session.spoiler_mode == SpoilerMode::Off {
"Keep the coaching spoiler-safe.".to_string()
} else {
"Review the clip for highlights and mistakes.".to_string()
},
confidence: 0.65,
}
}
fn classify_kind(key_text: &str, notes: &str, spoiler_mode: SpoilerMode) -> HighlightKind {
let text = format!("{} {}", key_text, notes).to_ascii_lowercase();
if text.contains("mistake") || text.contains("miss") || text.contains("throw") {
return HighlightKind::Mistake;
}
if text.contains("clutch")
|| text.contains("highlight")
|| text.contains("fight")
|| text.contains("kill")
{
return HighlightKind::Highlight;
}
if matches!(spoiler_mode, SpoilerMode::Off) && text.contains("story") {
return HighlightKind::Coaching;
}
HighlightKind::Coaching
}
fn highlight_title(key_text: &str, file_name: &str, kind: HighlightKind) -> String {
let source = if key_text.trim().is_empty() {
file_name
} else {
key_text
};
let base = source
.split(['.', '!', '?', '\n'])
.next()
.unwrap_or(source)
.trim();
let prefix = match kind {
HighlightKind::Highlight => "Highlight",
HighlightKind::Mistake => "Mistake",
HighlightKind::Coaching => "Review",
};
format!("{prefix}: {}", truncate(base, 72))
}
fn clip_candidate_from_highlight(
session: &GameplayReviewSession,
highlight: &GameplayHighlight,
) -> GameplayClipCandidate {
let start_index = highlight.frame_index.saturating_sub(1);
let end_index = (highlight.frame_index + 1).min(session.frames.len().saturating_sub(1));
GameplayClipCandidate {
id: format!("clip-{}", highlight.id),
frame_index: highlight.frame_index,
start_label: session
.frames
.get(start_index)
.map(|frame| frame.file_name.clone())
.unwrap_or_else(|| format!("frame-{start_index}")),
end_label: session
.frames
.get(end_index)
.map(|frame| frame.file_name.clone())
.unwrap_or_else(|| format!("frame-{end_index}")),
rationale: highlight.rationale.clone(),
confidence: highlight.confidence,
}
}
fn draft_metadata(
session: &GameplayReviewSession,
analysis: &GameplayReviewAnalysis,
platform: Option<&str>,
highlight_id: Option<&str>,
) -> Vec<GameplayPlatformDraft> {
let filtered = highlight_id
.and_then(|needle| {
analysis
.highlights
.iter()
.find(|highlight| highlight.id == needle)
})
.cloned()
.or_else(|| analysis.highlights.first().cloned());
let highlights = filtered.into_iter().collect::<Vec<_>>();
let platforms = match platform {
Some(value) if !value.trim().is_empty() => vec![value.trim().to_string()],
_ => DEFAULT_PLATFORMS
.iter()
.map(|platform| (*platform).to_string())
.collect(),
};
draft_metadata_from_highlights(session, &highlights, None, &platforms)
}
fn draft_metadata_from_highlights(
session: &GameplayReviewSession,
highlights: &[GameplayHighlight],
preset: Option<&GameplayReviewPreset>,
platforms: &[String],
) -> Vec<GameplayPlatformDraft> {
let best = highlights.first();
let best_title = best
.map(|highlight| highlight.title.clone())
.unwrap_or_else(|| format!("{} recap", session.session_title));
let focus = preset
.and_then(|preset| preset.coaching_focus.first())
.cloned()
.unwrap_or_else(|| "clean execution".to_string());
platforms
.iter()
.map(|platform| GameplayPlatformDraft {
platform: platform.clone(),
title: format!(
"{} — {} ({})",
session.game_id,
truncate(&best_title, 64),
platform
),
description: format!(
"Session: {}\nFocus: {}\nTop moment: {}\nSpoiler mode: {}",
session.session_title,
focus,
best.map(|highlight| highlight.rationale.clone())
.unwrap_or_else(|| "No highlight selected yet.".to_string()),
session.spoiler_mode.as_str(),
),
tags: build_tags(session, best, preset, platform),
})
.collect()
}
fn build_tags(
session: &GameplayReviewSession,
highlight: Option<&GameplayHighlight>,
preset: Option<&GameplayReviewPreset>,
platform: &str,
) -> Vec<String> {
let mut tags = BTreeSet::new();
tags.insert(store::slugify(&session.game_id));
tags.insert(store::slugify(platform));
if let Some(highlight) = highlight {
tags.insert(store::slugify(&highlight.title));
}
if let Some(preset) = preset {
for focus in &preset.coaching_focus {
tags.insert(store::slugify(focus));
}
}
tags.into_iter().take(6).collect()
}
fn build_recap(
session: &GameplayReviewSession,
highlights: &[GameplayHighlight],
preset: Option<&GameplayReviewPreset>,
) -> String {
let mut recap = format!(
"Gameplay recap for {} ({})\nSpoiler mode: {}\nFrames reviewed: {}",
session.session_title,
session.game_id,
session.spoiler_mode.as_str(),
session.frames.len()
);
if let Some(preset) = preset {
recap.push_str(&format!("\nPreset: {}", preset.display_name));
if !preset.coaching_focus.is_empty() {
recap.push_str(&format!(
"\nCoaching focus: {}",
preset.coaching_focus.join(", ")
));
}
}
if highlights.is_empty() {
recap.push_str("\nNo strong highlights were detected yet.");
} else {
recap.push_str("\nTop moments:");
for highlight in highlights.iter().take(5) {
recap.push_str(&format!(
"\n- [{}] {} — {}",
highlight.kind.as_str(),
highlight.title,
highlight.rationale
));
}
}
recap
}
fn default_follow_up_questions(session: &GameplayReviewSession) -> Vec<String> {
vec![
format!("What were my best moments in {}?", session.session_title),
format!("Where did I make mistakes in {}?", session.session_title),
format!("What should I post from this {} session?", session.game_id),
]
}
fn spoiler_note(mode: SpoilerMode) -> Option<String> {
match mode {
SpoilerMode::Off => Some("Spoiler-safe mode is enabled; avoid story reveals.".to_string()),
SpoilerMode::Light => {
Some("Light spoiler filtering is enabled; keep story beats vague.".to_string())
}
SpoilerMode::Full => None,
}
}
fn matched_highlights(highlights: &[GameplayHighlight], question: &str) -> Vec<GameplayHighlight> {
let lowered = question.to_ascii_lowercase();
let wants_mistakes =
lowered.contains("mistake") || lowered.contains("throw") || lowered.contains("bad");
let wants_highlights = lowered.contains("best")
|| lowered.contains("highlight")
|| lowered.contains("clip")
|| lowered.contains("post");
highlights
.iter()
.filter(|highlight| {
(wants_mistakes
&& matches!(
highlight.kind,
HighlightKind::Mistake | HighlightKind::Coaching
))
|| (wants_highlights && matches!(highlight.kind, HighlightKind::Highlight))
|| (!wants_mistakes && !wants_highlights)
})
.take(3)
.cloned()
.collect()
}
fn answer_question(
session: &GameplayReviewSession,
analysis: GameplayReviewAnalysis,
question: &str,
) -> String {
let lowered = question.to_ascii_lowercase();
if lowered.contains("best") || lowered.contains("highlight") || lowered.contains("clip") {
if let Some(highlight) = analysis.highlights.first() {
return format!(
"Best clip candidate for {}: {} — {}",
session.session_title, highlight.title, highlight.rationale
);
}
}
if lowered.contains("mistake") || lowered.contains("throw") || lowered.contains("miss") {
if let Some(highlight) = analysis.highlights.iter().find(|highlight| {
matches!(
highlight.kind,
HighlightKind::Mistake | HighlightKind::Coaching
)
}) {
return format!(
"Review point for {}: {} — {}",
session.session_title, highlight.title, highlight.rationale
);
}
}
if lowered.contains("post")
|| lowered.contains("title")
|| lowered.contains("description")
|| lowered.contains("tags")
{
if let Some(draft) = analysis.draft_metadata.first() {
return format!(
"{} draft for {}: {}. Tags: {}",
draft.platform,
session.session_title,
draft.title,
draft.tags.join(", ")
);
}
}
format!("{}\n\n{}", analysis.recap, question)
}
fn truncate(value: &str, max_chars: usize) -> String {
let chars: Vec<char> = value.chars().collect();
if chars.len() <= max_chars {
value.to_string()
} else {
chars[..max_chars].iter().collect()
}
}
impl HighlightKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Highlight => "highlight",
Self::Mistake => "mistake",
Self::Coaching => "coaching",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK;
use tempfile::tempdir;
fn mock_frame(name: &str, image_ref: &str, captured_at_ms: i64) -> GameplayFrameInput {
GameplayFrameInput {
file_name: name.to_string(),
image_ref: image_ref.to_string(),
captured_at_ms: Some(captured_at_ms),
}
}
#[tokio::test]
async fn register_and_list_sessions_round_trip() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir().unwrap();
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let registered = register_session(GameplayReviewSessionInput {
game_id: "Apex Legends".to_string(),
session_title: "Ranked climb".to_string(),
source_label: Some("/recordings/apex".to_string()),
spoiler_mode: Some(SpoilerMode::Light),
preset_id: None,
frames: vec![mock_frame("frame1.png", "data:image/png;base64,AAA", 1000)],
})
.await
.expect("register")
.value;
assert_eq!(registered.game_id, "Apex Legends");
let listed = list_sessions(None).await.expect("list").value;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].session_id, registered.session_id);
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
#[test]
fn question_matching_prefers_highlights_for_best_prompt() {
let session = GameplayReviewSession {
session_id: "session-1".to_string(),
game_id: "Game".to_string(),
session_title: "Session".to_string(),
source_label: None,
spoiler_mode: SpoilerMode::Light,
preset_id: None,
imported_at_ms: 1000,
analyzed_at_ms: None,
frames: vec![],
analysis: None,
};
let analysis = GameplayReviewAnalysis {
recap: "recap".to_string(),
highlights: vec![GameplayHighlight {
id: "h1".to_string(),
frame_index: 0,
captured_at_ms: Some(1),
title: "Clutch finish".to_string(),
rationale: "A clean finish".to_string(),
confidence: 0.95,
kind: HighlightKind::Highlight,
}],
clip_candidates: Vec::new(),
draft_metadata: Vec::new(),
follow_up_questions: vec!["What was the turning point?".to_string()],
spoiler_note: None,
};
let answer = answer_question(&session, analysis.clone(), "What were my best plays?");
assert!(answer.contains("Clutch finish"));
assert_eq!(matched_highlights(&analysis.highlights, "best").len(), 1);
}
}
-437
View File
@@ -1,437 +0,0 @@
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{Map, Value};
use log::debug;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
use super::types::{
GameplayPresetInput, GameplayReviewAnalysisInput, GameplayReviewClipInput,
GameplayReviewQuestionInput, GameplayReviewSessionInput,
};
#[derive(Deserialize)]
struct SessionIdParams {
session_id: String,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("register_session"),
schemas("analyze_session"),
schemas("get_session"),
schemas("list_sessions"),
schemas("set_preset"),
schemas("list_presets"),
schemas("ask_session"),
schemas("draft_clip_metadata"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("register_session"),
handler: handle_register_session,
},
RegisteredController {
schema: schemas("analyze_session"),
handler: handle_analyze_session,
},
RegisteredController {
schema: schemas("get_session"),
handler: handle_get_session,
},
RegisteredController {
schema: schemas("list_sessions"),
handler: handle_list_sessions,
},
RegisteredController {
schema: schemas("set_preset"),
handler: handle_set_preset,
},
RegisteredController {
schema: schemas("list_presets"),
handler: handle_list_presets,
},
RegisteredController {
schema: schemas("ask_session"),
handler: handle_ask_session,
},
RegisteredController {
schema: schemas("draft_clip_metadata"),
handler: handle_draft_clip_metadata,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"register_session" => ControllerSchema {
namespace: "gameplay_review",
function: "register_session",
description: "Register a gameplay session from imported keyframes.",
inputs: vec![
FieldSchema {
name: "game_id",
ty: TypeSchema::String,
comment: "Game identifier.",
required: true,
},
FieldSchema {
name: "session_title",
ty: TypeSchema::String,
comment: "Human-readable session title.",
required: true,
},
FieldSchema {
name: "source_label",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional label describing where the footage came from.",
required: false,
},
FieldSchema {
name: "spoiler_mode",
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("SpoilerMode"))),
comment: "Optional spoiler-mode override for this session.",
required: false,
},
FieldSchema {
name: "preset_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional coaching preset to apply.",
required: false,
},
FieldSchema {
name: "frames",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("GameplayFrameInput"))),
comment: "Captured keyframes for the session.",
required: true,
},
],
outputs: vec![json_output("session", "Stored gameplay review session.")],
},
"analyze_session" => ControllerSchema {
namespace: "gameplay_review",
function: "analyze_session",
description:
"Analyze a gameplay session, generate highlights, and draft clip metadata.",
inputs: vec![
FieldSchema {
name: "session_id",
ty: TypeSchema::String,
comment: "Session identifier to analyze.",
required: true,
},
FieldSchema {
name: "max_highlights",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Optional cap on the number of highlights to generate.",
required: false,
},
FieldSchema {
name: "platforms",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Target platforms for clip drafts (e.g. youtube, tiktok).",
required: false,
},
],
outputs: vec![json_output("session", "Analyzed gameplay review session.")],
},
"get_session" => ControllerSchema {
namespace: "gameplay_review",
function: "get_session",
description: "Fetch one gameplay review session by id.",
inputs: vec![FieldSchema {
name: "session_id",
ty: TypeSchema::String,
comment: "Session identifier.",
required: true,
}],
outputs: vec![json_output("session", "Gameplay review session.")],
},
"list_sessions" => ControllerSchema {
namespace: "gameplay_review",
function: "list_sessions",
description: "List gameplay review sessions stored in the workspace.",
inputs: vec![FieldSchema {
name: "game_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional game filter.",
required: false,
}],
outputs: vec![json_output("sessions", "Gameplay review sessions.")],
},
"set_preset" => ControllerSchema {
namespace: "gameplay_review",
function: "set_preset",
description: "Save a game-specific coaching preset.",
inputs: vec![
FieldSchema {
name: "game_id",
ty: TypeSchema::String,
comment: "Game identifier.",
required: true,
},
FieldSchema {
name: "display_name",
ty: TypeSchema::String,
comment: "Human-readable preset name.",
required: true,
},
FieldSchema {
name: "coaching_focus",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Areas of focus for coaching commentary.",
required: false,
},
FieldSchema {
name: "audio_feedback",
ty: TypeSchema::Bool,
comment: "Whether to surface audio cues in highlight summaries.",
required: false,
},
FieldSchema {
name: "spoiler_mode",
ty: TypeSchema::Ref("SpoilerMode"),
comment: "Spoiler-handling mode for this preset.",
required: false,
},
FieldSchema {
name: "notes",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional free-form preset notes.",
required: false,
},
],
outputs: vec![json_output("preset", "Saved gameplay review preset.")],
},
"list_presets" => ControllerSchema {
namespace: "gameplay_review",
function: "list_presets",
description: "List stored gameplay coaching presets.",
inputs: vec![],
outputs: vec![json_output("presets", "Gameplay review presets.")],
},
"ask_session" => ControllerSchema {
namespace: "gameplay_review",
function: "ask_session",
description: "Ask a question against a stored gameplay session.",
inputs: vec![
FieldSchema {
name: "session_id",
ty: TypeSchema::String,
comment: "Session identifier to query.",
required: true,
},
FieldSchema {
name: "question",
ty: TypeSchema::String,
comment: "Question text to ask against the session.",
required: true,
},
],
outputs: vec![json_output(
"answer",
"Question answer with matched highlights.",
)],
},
"draft_clip_metadata" => ControllerSchema {
namespace: "gameplay_review",
function: "draft_clip_metadata",
description: "Draft clip titles, descriptions, and tags for one gameplay highlight.",
inputs: vec![
FieldSchema {
name: "session_id",
ty: TypeSchema::String,
comment: "Session identifier holding the highlight.",
required: true,
},
FieldSchema {
name: "platform",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional target platform (defaults to all configured).",
required: false,
},
FieldSchema {
name: "highlight_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional highlight to focus on (defaults to all).",
required: false,
},
],
outputs: vec![json_output("drafts", "Draft metadata for clip publishing.")],
},
_ => ControllerSchema {
namespace: "gameplay_review",
function: "unknown",
description: "Unknown gameplay_review controller function.",
inputs: vec![],
outputs: vec![json_output("error", "Lookup error details.")],
},
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|err| err.to_string())
}
fn handle_register_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<GameplayReviewSessionInput>(params)?;
debug!(
"[gameplay_review][controller] register_session game_id={} frames={}",
payload.game_id,
payload.frames.len()
);
let result = crate::openhuman::gameplay_review::rpc::register_session(payload).await?;
debug!(
"[gameplay_review][controller] register_session complete session_id={} game_id={}",
result.value.session_id, result.value.game_id
);
to_json(result)
})
}
fn handle_analyze_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<GameplayReviewAnalysisInput>(params)?;
debug!(
"[gameplay_review][controller] analyze_session session_id={} max_highlights={:?} platforms={}",
payload.session_id,
payload.max_highlights,
payload.platforms.len()
);
let result = crate::openhuman::gameplay_review::rpc::analyze_session(payload).await?;
debug!(
"[gameplay_review][controller] analyze_session complete session_id={} analyzed={}",
result.value.session_id,
result.value.analyzed_at_ms.is_some()
);
to_json(result)
})
}
fn handle_get_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<SessionIdParams>(params)?;
debug!(
"[gameplay_review][controller] get_session session_id={}",
payload.session_id
);
let result =
crate::openhuman::gameplay_review::rpc::get_session(payload.session_id).await?;
debug!(
"[gameplay_review][controller] get_session complete session_id={} game_id={}",
result.value.session_id, result.value.game_id
);
to_json(result)
})
}
fn handle_list_sessions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
#[derive(Deserialize)]
struct Params {
#[serde(default)]
game_id: Option<String>,
}
let payload = deserialize_params::<Params>(params)?;
debug!(
"[gameplay_review][controller] list_sessions game_id_filter={:?}",
payload.game_id
);
let result = crate::openhuman::gameplay_review::rpc::list_sessions(payload.game_id).await?;
debug!(
"[gameplay_review][controller] list_sessions complete count={}",
result.value.len()
);
to_json(result)
})
}
fn handle_set_preset(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<GameplayPresetInput>(params)?;
debug!(
"[gameplay_review][controller] set_preset game_id={} display_name={} focus_items={}",
payload.game_id,
payload.display_name,
payload.coaching_focus.len()
);
let result = crate::openhuman::gameplay_review::rpc::set_preset(payload).await?;
debug!(
"[gameplay_review][controller] set_preset complete game_id={}",
result.value.game_id
);
to_json(result)
})
}
fn handle_list_presets(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
debug!("[gameplay_review][controller] list_presets start");
let result = crate::openhuman::gameplay_review::rpc::list_presets().await?;
debug!(
"[gameplay_review][controller] list_presets complete count={}",
result.value.len()
);
to_json(result)
})
}
fn handle_ask_session(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<GameplayReviewQuestionInput>(params)?;
let session_id = payload.session_id.clone();
debug!(
"[gameplay_review][controller] ask_session session_id={} question_len={}",
session_id,
payload.question.len()
);
let result = crate::openhuman::gameplay_review::rpc::ask_session(payload).await?;
debug!(
"[gameplay_review][controller] ask_session complete session_id={} matched_highlights={} suggested_follow_up={}",
session_id,
result.value.matched_highlights.len(),
result.value.suggested_follow_up.len()
);
to_json(result)
})
}
fn handle_draft_clip_metadata(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = deserialize_params::<GameplayReviewClipInput>(params)?;
let session_id = payload.session_id.clone();
debug!(
"[gameplay_review][controller] draft_clip_metadata session_id={} platform={:?} highlight_id={:?}",
session_id,
payload.platform,
payload.highlight_id
);
let result = crate::openhuman::gameplay_review::rpc::draft_clip_metadata(payload).await?;
debug!(
"[gameplay_review][controller] draft_clip_metadata complete session_id={} drafts={}",
session_id,
result.value.len()
);
to_json(result)
})
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
-289
View File
@@ -1,289 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use log::{debug, trace, warn};
use crate::openhuman::config::Config;
use super::types::{GameplayPresetInput, GameplayReviewPreset, GameplayReviewSession};
const REVIEW_DIR: &str = "gameplay_review";
const SESSIONS_DIR: &str = "sessions";
const PRESETS_DIR: &str = "presets";
pub fn review_root(workspace_dir: &Path) -> PathBuf {
workspace_dir.join(REVIEW_DIR)
}
pub fn sessions_dir(workspace_dir: &Path) -> PathBuf {
review_root(workspace_dir).join(SESSIONS_DIR)
}
pub fn presets_dir(workspace_dir: &Path) -> PathBuf {
review_root(workspace_dir).join(PRESETS_DIR)
}
pub fn slugify(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut last_was_dash = false;
for ch in value.trim().to_ascii_lowercase().chars() {
let next = if ch.is_ascii_alphanumeric() { ch } else { '-' };
if next == '-' {
if last_was_dash {
continue;
}
last_was_dash = true;
} else {
last_was_dash = false;
}
out.push(next);
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"untitled".to_string()
} else {
trimmed.to_string()
}
}
pub fn ensure_dirs(workspace_dir: &Path) -> Result<(), String> {
debug!(
"[gameplay_review][store] ensure_dirs workspace_dir={}",
workspace_dir.display()
);
fs::create_dir_all(sessions_dir(workspace_dir))
.map_err(|err| format!("failed to create gameplay review sessions dir: {err}"))?;
fs::create_dir_all(presets_dir(workspace_dir))
.map_err(|err| format!("failed to create gameplay review presets dir: {err}"))?;
Ok(())
}
fn write_json_atomic(path: &Path, entity: &str, payload: &str) -> Result<(), String> {
let tmp_path = path.with_extension("json.tmp");
trace!(
"[gameplay_review][store] write_json_atomic entity={} path={} tmp_path={}",
entity,
path.display(),
tmp_path.display()
);
fs::write(&tmp_path, payload).map_err(|err| format!("failed to write {entity} tmp: {err}"))?;
fs::rename(&tmp_path, path)
.map_err(|err| format!("failed to move {entity} into place: {err}"))?;
Ok(())
}
/// Returns an error if `session_id` contains path separators or is empty,
/// preventing path traversal when the id comes from an RPC caller.
fn validate_session_id(session_id: &str) -> Result<(), String> {
if session_id.is_empty() {
return Err("session_id must not be empty".to_string());
}
if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") {
return Err(format!(
"session_id contains invalid characters: {session_id}"
));
}
Ok(())
}
pub fn session_path(workspace_dir: &Path, session_id: &str) -> PathBuf {
sessions_dir(workspace_dir).join(format!("{session_id}.json"))
}
pub fn preset_path(workspace_dir: &Path, game_id: &str) -> PathBuf {
presets_dir(workspace_dir).join(format!("{}.json", slugify(game_id)))
}
pub fn save_session(workspace_dir: &Path, session: &GameplayReviewSession) -> Result<(), String> {
validate_session_id(&session.session_id)?;
ensure_dirs(workspace_dir)?;
let path = session_path(workspace_dir, &session.session_id);
debug!(
"[gameplay_review][store] save_session session_id={} game_id={} path={}",
session.session_id,
session.game_id,
path.display()
);
let payload = serde_json::to_string_pretty(session)
.map_err(|err| format!("failed to serialize session: {err}"))?;
write_json_atomic(&path, "session", &payload)?;
Ok(())
}
pub fn load_session(
workspace_dir: &Path,
session_id: &str,
) -> Result<Option<GameplayReviewSession>, String> {
validate_session_id(session_id)?;
let path = session_path(workspace_dir, session_id);
trace!(
"[gameplay_review][store] load_session session_id={} path={}",
session_id,
path.display()
);
if !path.exists() {
debug!(
"[gameplay_review][store] load_session missing session_id={} path={}",
session_id,
path.display()
);
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|err| format!("failed to read session: {err}"))?;
let session = serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse session {}: {err}", path.display()))?;
debug!(
"[gameplay_review][store] load_session loaded session_id={} path={}",
session_id,
path.display()
);
Ok(Some(session))
}
pub fn list_sessions(workspace_dir: &Path) -> Result<Vec<GameplayReviewSession>, String> {
let dir = sessions_dir(workspace_dir);
trace!(
"[gameplay_review][store] list_sessions dir={}",
dir.display()
);
if !dir.exists() {
debug!(
"[gameplay_review][store] list_sessions empty dir={}",
dir.display()
);
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in fs::read_dir(&dir).map_err(|err| format!("failed to list sessions: {err}"))? {
let entry = entry.map_err(|err| format!("failed to read session entry: {err}"))?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&path)
.map_err(|err| format!("failed to read session {}: {err}", path.display()))?;
match serde_json::from_str::<GameplayReviewSession>(&raw) {
Ok(session) => sessions.push(session),
Err(err) => warn!(
"[gameplay_review][store] list_sessions skipped invalid session path={} error={}",
path.display(),
err
),
}
}
sessions.sort_by(|left, right| {
right
.imported_at_ms
.cmp(&left.imported_at_ms)
.then(right.analyzed_at_ms.cmp(&left.analyzed_at_ms))
});
debug!(
"[gameplay_review][store] list_sessions complete dir={} count={}",
dir.display(),
sessions.len()
);
Ok(sessions)
}
pub fn save_preset(workspace_dir: &Path, preset: &GameplayReviewPreset) -> Result<(), String> {
ensure_dirs(workspace_dir)?;
let path = preset_path(workspace_dir, &preset.game_id);
debug!(
"[gameplay_review][store] save_preset game_id={} path={}",
preset.game_id,
path.display()
);
let payload = serde_json::to_string_pretty(preset)
.map_err(|err| format!("failed to serialize preset: {err}"))?;
write_json_atomic(&path, "preset", &payload)?;
Ok(())
}
pub fn load_preset(
workspace_dir: &Path,
game_id: &str,
) -> Result<Option<GameplayReviewPreset>, String> {
let path = preset_path(workspace_dir, game_id);
trace!(
"[gameplay_review][store] load_preset game_id={} path={}",
game_id,
path.display()
);
if !path.exists() {
debug!(
"[gameplay_review][store] load_preset missing game_id={} path={}",
game_id,
path.display()
);
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|err| format!("failed to read preset: {err}"))?;
let preset = serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse preset {}: {err}", path.display()))?;
debug!(
"[gameplay_review][store] load_preset loaded game_id={} path={}",
game_id,
path.display()
);
Ok(Some(preset))
}
pub fn list_presets(workspace_dir: &Path) -> Result<Vec<GameplayReviewPreset>, String> {
let dir = presets_dir(workspace_dir);
trace!(
"[gameplay_review][store] list_presets dir={}",
dir.display()
);
if !dir.exists() {
debug!(
"[gameplay_review][store] list_presets empty dir={}",
dir.display()
);
return Ok(Vec::new());
}
let mut presets = Vec::new();
for entry in fs::read_dir(&dir).map_err(|err| format!("failed to list presets: {err}"))? {
let entry = entry.map_err(|err| format!("failed to read preset entry: {err}"))?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&path)
.map_err(|err| format!("failed to read preset {}: {err}", path.display()))?;
match serde_json::from_str::<GameplayReviewPreset>(&raw) {
Ok(preset) => presets.push(preset),
Err(err) => warn!(
"[gameplay_review][store] list_presets skipped invalid preset path={} error={}",
path.display(),
err
),
}
}
presets.sort_by(|left, right| left.display_name.cmp(&right.display_name));
debug!(
"[gameplay_review][store] list_presets complete dir={} count={}",
dir.display(),
presets.len()
);
Ok(presets)
}
pub fn workspace_dir_from_config(config: &Config) -> PathBuf {
config.workspace_dir.clone()
}
pub fn preset_from_input(input: GameplayPresetInput) -> GameplayReviewPreset {
GameplayReviewPreset {
game_id: input.game_id,
display_name: input.display_name,
coaching_focus: input.coaching_focus,
audio_feedback: input.audio_feedback,
spoiler_mode: input.spoiler_mode,
notes: input.notes,
updated_at_ms: chrono::Utc::now().timestamp_millis(),
}
}
-173
View File
@@ -1,173 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpoilerMode {
Off,
Light,
Full,
}
impl Default for SpoilerMode {
fn default() -> Self {
Self::Light
}
}
impl SpoilerMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Light => "light",
Self::Full => "full",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HighlightKind {
Highlight,
Mistake,
Coaching,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayFrameInput {
pub file_name: String,
pub image_ref: String,
#[serde(default)]
pub captured_at_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayPresetInput {
pub game_id: String,
pub display_name: String,
#[serde(default)]
pub coaching_focus: Vec<String>,
#[serde(default)]
pub audio_feedback: bool,
#[serde(default)]
pub spoiler_mode: SpoilerMode,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewSessionInput {
pub game_id: String,
pub session_title: String,
#[serde(default)]
pub source_label: Option<String>,
#[serde(default)]
pub spoiler_mode: Option<SpoilerMode>,
#[serde(default)]
pub preset_id: Option<String>,
pub frames: Vec<GameplayFrameInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewQuestionInput {
pub session_id: String,
pub question: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewPreset {
pub game_id: String,
pub display_name: String,
#[serde(default)]
pub coaching_focus: Vec<String>,
#[serde(default)]
pub audio_feedback: bool,
#[serde(default)]
pub spoiler_mode: SpoilerMode,
#[serde(default)]
pub notes: Option<String>,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayHighlight {
pub id: String,
pub frame_index: usize,
#[serde(default)]
pub captured_at_ms: Option<i64>,
pub title: String,
pub rationale: String,
pub confidence: f32,
pub kind: HighlightKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayClipCandidate {
pub id: String,
pub frame_index: usize,
pub start_label: String,
pub end_label: String,
pub rationale: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayPlatformDraft {
pub platform: String,
pub title: String,
pub description: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewAnalysis {
pub recap: String,
pub highlights: Vec<GameplayHighlight>,
pub clip_candidates: Vec<GameplayClipCandidate>,
pub draft_metadata: Vec<GameplayPlatformDraft>,
pub follow_up_questions: Vec<String>,
#[serde(default)]
pub spoiler_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewSession {
pub session_id: String,
pub game_id: String,
pub session_title: String,
#[serde(default)]
pub source_label: Option<String>,
pub spoiler_mode: SpoilerMode,
#[serde(default)]
pub preset_id: Option<String>,
pub imported_at_ms: i64,
#[serde(default)]
pub analyzed_at_ms: Option<i64>,
pub frames: Vec<GameplayFrameInput>,
#[serde(default)]
pub analysis: Option<GameplayReviewAnalysis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewAnalysisInput {
pub session_id: String,
#[serde(default)]
pub max_highlights: Option<usize>,
#[serde(default)]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewClipInput {
pub session_id: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub highlight_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameplayReviewQuestionResult {
pub answer: String,
pub matched_highlights: Vec<GameplayHighlight>,
pub suggested_follow_up: Vec<String>,
}
-1
View File
@@ -42,7 +42,6 @@ pub mod devices;
pub mod doctor;
pub mod embeddings;
pub mod encryption;
pub mod gameplay_review;
pub mod health;
pub mod heartbeat;
pub mod http_host;