diff --git a/tauri-app/src/components/GameReview.vue b/tauri-app/src/components/GameReview.vue index d860474..3cb8dc3 100644 --- a/tauri-app/src/components/GameReview.vue +++ b/tauri-app/src/components/GameReview.vue @@ -1,8 +1,9 @@ + + + + diff --git a/tauri-app/src/types/timeline.ts b/tauri-app/src/types/timeline.ts index a10ec2b..4194157 100644 --- a/tauri-app/src/types/timeline.ts +++ b/tauri-app/src/types/timeline.ts @@ -556,16 +556,16 @@ export function formatNumber(num: number): string { export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string { if (!championName) { // Return placeholder for unknown champion - return 'https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/Aatrox.png'; + return 'https://ddragon.leagueoflegends.com/cdn/16.10.1/img/champion/Aatrox.png'; } // Data Dragon uses specific champion name formatting const formattedName = formatChampionName(championName); if (size === 'loading') { - return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}_0.jpg`; + return `https://ddragon.leagueoflegends.com/cdn/16.10.1/img/champion/${formattedName}_0.jpg`; } - return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}.png`; + return `https://ddragon.leagueoflegends.com/cdn/16.10.1/img/champion/${formattedName}.png`; } /** @@ -687,6 +687,364 @@ export type EventCategory = | "phase" | "unknown"; +// --------------------------------------------------------------------------- +// Highlights +// --------------------------------------------------------------------------- + +/** + * The type of highlight clip. + */ +export type HighlightType = "kill" | "death" | "assist" | "objective" | "multi_kill"; + +/** + * Configuration for highlight generation. + */ +export interface HighlightSettings { + /** Seconds before the event to include in the clip. */ + buffer_before: number; + /** Seconds after the event to include in the clip. */ + buffer_after: number; + /** Minimum clip duration in seconds. */ + min_duration: number; + /** Which highlight types to include. */ + included_types: HighlightType[]; + /** Whether to merge overlapping clips into one. */ + merge_overlapping: boolean; + /** Gap in seconds below which adjacent clips are merged (when merge_overlapping is true). */ + merge_gap_secs: number; +} + +/** + * Default highlight settings. + */ +export const DEFAULT_HIGHLIGHT_SETTINGS: HighlightSettings = { + buffer_before: 10, + buffer_after: 8, + min_duration: 5, + included_types: ["kill", "death", "objective", "multi_kill"], + merge_overlapping: true, + merge_gap_secs: 5, +}; + +/** + * A single highlight clip derived from one or more timeline events. + */ +export interface HighlightClip { + /** Unique ID for this clip (index-based). */ + id: number; + /** Start time in seconds (video time). */ + start_time: number; + /** End time in seconds (video time). */ + end_time: number; + /** The primary highlight type. */ + highlight_type: HighlightType; + /** All timeline events that contributed to this clip. */ + events: TimestampedEvent[]; + /** Human-readable title. */ + title: string; + /** Human-readable subtitle (e.g. "Double Kill"). */ + subtitle: string | null; +} + +/** + * Determine the highlight type of a kill event based on player involvement. + */ +export function getKillHighlightType( + event: TimestampedEvent, + localPlayerName: string | null, +): HighlightType | null { + const rawData = event.raw_data as { + KillerName?: string; + VictimName?: string; + Assisters?: string[]; + } | null; + + if (!rawData) return null; + + const killer = rawData.KillerName; + const victim = rawData.VictimName; + const assisters = rawData.Assisters || []; + + if (!localPlayerName) return null; + + if (killer === localPlayerName) return "kill"; + if (victim === localPlayerName) return "death"; + if (assisters.includes(localPlayerName)) return "assist"; + + return null; // Not involved — skip +} + +/** + * Check if an objective event is relevant (always include objectives). + */ +export function isObjectiveHighlight(event: TimestampedEvent): boolean { + return event.event_type === "objective"; +} + +/** + * Detect multi-kills from a sequence of kill events. + * Returns groups of kills that happened within `windowSecs` seconds. + */ +export function detectMultiKills( + killEvents: TimestampedEvent[], + windowSecs: number = 12, +): TimestampedEvent[][] { + if (killEvents.length === 0) return []; + + const sorted = [...killEvents].sort((a, b) => { + const aTime = a.video_timestamp[0] + a.video_timestamp[1] / 1e9; + const bTime = b.video_timestamp[0] + b.video_timestamp[1] / 1e9; + return aTime - bTime; + }); + + const groups: TimestampedEvent[][] = []; + let currentGroup: TimestampedEvent[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const prevTime = + currentGroup[currentGroup.length - 1].video_timestamp[0] + + currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9; + const currTime = sorted[i].video_timestamp[0] + sorted[i].video_timestamp[1] / 1e9; + + if (currTime - prevTime <= windowSecs) { + currentGroup.push(sorted[i]); + } else { + if (currentGroup.length >= 2) { + groups.push(currentGroup); + } + currentGroup = [sorted[i]]; + } + } + + if (currentGroup.length >= 2) { + groups.push(currentGroup); + } + + return groups; +} + +/** + * Get the multi-kill label based on number of kills. + */ +export function getMultiKillLabel(count: number): string | null { + switch (count) { + case 2: return "Double Kill"; + case 3: return "Triple Kill"; + case 4: return "Quadra Kill"; + case 5: return "Penta Kill"; + default: return count > 5 ? `${count}x Kill` : null; + } +} + +/** + * Get the highlight type color for display. + */ +export function getHighlightTypeColor(type: HighlightType): string { + switch (type) { + case "kill": return "#4ade80"; // green + case "death": return "#f87171"; // red + case "assist": return "#a78bfa"; // purple + case "objective": return "#fbbf24"; // yellow/gold + case "multi_kill": return "#f97316"; // orange + } +} + +/** + * Get a short icon/emoji for a highlight type. + */ +export function getHighlightTypeIcon(type: HighlightType): string { + switch (type) { + case "kill": return "⚔️"; + case "death": return "💀"; + case "assist": return "🤝"; + case "objective": return "🏰"; + case "multi_kill": return "🔥"; + } +} + +/** + * Compute highlight clips from a game's timeline events. + * + * @param events - All timeline events for the game. + * @param duration - Total video duration in seconds. + * @param localPlayerName - The local player's summoner name (for kill/death classification). + * @param settings - Highlight generation settings. + * @returns Array of HighlightClip sorted by start_time. + */ +export function computeHighlights( + events: TimestampedEvent[], + duration: number, + localPlayerName: string | null, + settings: HighlightSettings = DEFAULT_HIGHLIGHT_SETTINGS, +): HighlightClip[] { + const clips: HighlightClip[] = []; + let clipId = 0; + + // Collect individual kill/assist/death events + const playerKills: TimestampedEvent[] = []; + const playerDeaths: TimestampedEvent[] = []; + const playerAssists: TimestampedEvent[] = []; + const objectiveEvents: TimestampedEvent[] = []; + + for (const event of events) { + if (event.event_type === "kill") { + const type = getKillHighlightType(event, localPlayerName); + if (type === "kill" && settings.included_types.includes("kill")) { + playerKills.push(event); + } else if (type === "death" && settings.included_types.includes("death")) { + playerDeaths.push(event); + } else if (type === "assist" && settings.included_types.includes("assist")) { + playerAssists.push(event); + } + } else if (event.event_type === "objective" && settings.included_types.includes("objective")) { + objectiveEvents.push(event); + } + } + + // Create clips for individual kills + for (const event of playerKills) { + const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9; + const rawData = event.raw_data as { VictimName?: string } | null; + const victimName = rawData?.VictimName || "enemy"; + + clips.push({ + id: clipId++, + start_time: Math.max(0, eventTime - settings.buffer_before), + end_time: Math.min(duration, eventTime + settings.buffer_after), + highlight_type: "kill", + events: [event], + title: `Kill`, + subtitle: `Killed ${victimName}`, + }); + } + + // Create clips for deaths + for (const event of playerDeaths) { + const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9; + const rawData = event.raw_data as { KillerName?: string } | null; + const killerName = rawData?.KillerName || "enemy"; + + clips.push({ + id: clipId++, + start_time: Math.max(0, eventTime - settings.buffer_before), + end_time: Math.min(duration, eventTime + settings.buffer_after), + highlight_type: "death", + events: [event], + title: `Death`, + subtitle: `Killed by ${killerName}`, + }); + } + + // Create clips for assists + for (const event of playerAssists) { + const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9; + const rawData = event.raw_data as { KillerName?: string; VictimName?: string } | null; + const killerName = rawData?.KillerName || "ally"; + const victimName = rawData?.VictimName || "enemy"; + + clips.push({ + id: clipId++, + start_time: Math.max(0, eventTime - settings.buffer_before), + end_time: Math.min(duration, eventTime + settings.buffer_after), + highlight_type: "assist", + events: [event], + title: `Assist`, + subtitle: `Assisted ${killerName} vs ${victimName}`, + }); + } + + // Create clips for objectives + for (const event of objectiveEvents) { + const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9; + const rawData = event.raw_data as { EventName?: string; objectiveType?: string } | null; + const objName = rawData?.EventName || rawData?.objectiveType || "Objective"; + + clips.push({ + id: clipId++, + start_time: Math.max(0, eventTime - settings.buffer_before), + end_time: Math.min(duration, eventTime + settings.buffer_after), + highlight_type: "objective", + events: [event], + title: `Objective`, + subtitle: objName.replace(/([A-Z])/g, " $1").trim(), + }); + } + + // Detect multi-kills and create clips for them (only if enabled) + if (settings.included_types.includes("multi_kill")) { + const multiKillGroups = detectMultiKills(playerKills); + for (const group of multiKillGroups) { + const firstTime = group[0].video_timestamp[0] + group[0].video_timestamp[1] / 1e9; + const lastTime = + group[group.length - 1].video_timestamp[0] + + group[group.length - 1].video_timestamp[1] / 1e9; + const label = getMultiKillLabel(group.length); + + clips.push({ + id: clipId++, + start_time: Math.max(0, firstTime - settings.buffer_before), + end_time: Math.min(duration, lastTime + settings.buffer_after), + highlight_type: "multi_kill", + events: group, + title: label || `${group.length}x Kill`, + subtitle: `${group.length} kills in quick succession`, + }); + } + } + + // Sort by start time + clips.sort((a, b) => a.start_time - b.start_time); + + // Merge overlapping clips if enabled + if (settings.merge_overlapping && clips.length > 1) { + const merged: HighlightClip[] = []; + let current = { ...clips[0] }; + + for (let i = 1; i < clips.length; i++) { + const next = clips[i]; + // Merge if overlapping or within merge gap + if (next.start_time <= current.end_time + settings.merge_gap_secs) { + // Extend current clip + current.end_time = Math.max(current.end_time, next.end_time); + current.events = [...current.events, ...next.events]; + // Keep the "more important" highlight type + const typePriority: HighlightType[] = ["multi_kill", "kill", "objective", "death", "assist"]; + const currentIdx = typePriority.indexOf(current.highlight_type); + const nextIdx = typePriority.indexOf(next.highlight_type); + if (nextIdx < currentIdx) { + current.highlight_type = next.highlight_type; + current.title = next.title; + current.subtitle = next.subtitle; + } + // If both are kills, check if combined they form a multi-kill + const killEvents = current.events.filter(e => e.event_type === "kill"); + if (killEvents.length >= 2) { + const label = getMultiKillLabel(killEvents.length); + if (label) { + current.highlight_type = "multi_kill"; + current.title = label; + current.subtitle = `${killEvents.length} kills`; + } + } + } else { + merged.push(current); + current = { ...next }; + } + } + merged.push(current); + + // Re-enforce minimum duration and re-ID + return merged + .filter(c => c.end_time - c.start_time >= settings.min_duration) + .map((c, idx) => ({ ...c, id: idx })); + } + + // Filter by minimum duration and re-ID + return clips + .filter(c => c.end_time - c.start_time >= settings.min_duration) + .map((c, idx) => ({ ...c, id: idx })); +} + /** * Get the category of an event for styling purposes. */