tauri-app: add highlights mode
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m4s
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m4s
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import type { TimestampedEvent, GameHistoryItem } from "../types/timeline";
|
||||
import HighlightsPanel from "./HighlightsPanel.vue";
|
||||
import type { TimestampedEvent, GameHistoryItem, HighlightClip, HighlightSettings } from "../types/timeline";
|
||||
import {
|
||||
getGameResult,
|
||||
formatDuration,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
getLpChange,
|
||||
formatLpDelta,
|
||||
formatRank,
|
||||
computeHighlights,
|
||||
DEFAULT_HIGHLIGHT_SETTINGS,
|
||||
getHighlightTypeColor,
|
||||
} from "../types/timeline";
|
||||
|
||||
// Props
|
||||
@@ -51,6 +55,129 @@ const isExporting = ref(false);
|
||||
const exportProgress = ref(0);
|
||||
const exportMessage = ref<string | null>(null);
|
||||
|
||||
// Highlights mode state
|
||||
const highlightsMode = ref(false);
|
||||
const highlightSettings = ref<HighlightSettings>({ ...DEFAULT_HIGHLIGHT_SETTINGS });
|
||||
const playingHighlightId = ref<number | null>(null);
|
||||
const exportingHighlightId = ref<number | null>(null);
|
||||
|
||||
// Computed highlights from timeline events
|
||||
const highlights = computed(() => {
|
||||
return computeHighlights(
|
||||
props.game.events,
|
||||
duration.value,
|
||||
localPlayer.value?.riotIdGameName || null,
|
||||
highlightSettings.value,
|
||||
);
|
||||
});
|
||||
|
||||
// Handle highlight settings changes (re-compute is automatic via computed)
|
||||
function onHighlightSettingsChanged(settings: HighlightSettings) {
|
||||
highlightSettings.value = { ...settings };
|
||||
}
|
||||
|
||||
// Seek to a highlight clip
|
||||
function seekToHighlight(time: number) {
|
||||
seekTo(time);
|
||||
}
|
||||
|
||||
// Play a specific highlight clip
|
||||
function playHighlightClip(clip: HighlightClip) {
|
||||
playingHighlightId.value = clip.id;
|
||||
seekTo(clip.start_time);
|
||||
if (videoRef.value) {
|
||||
videoRef.value.play();
|
||||
}
|
||||
}
|
||||
|
||||
// Play all highlights sequentially
|
||||
function playAllHighlights() {
|
||||
if (highlights.value.length === 0) return;
|
||||
playingHighlightId.value = highlights.value[0].id;
|
||||
seekTo(highlights.value[0].start_time);
|
||||
if (videoRef.value) {
|
||||
videoRef.value.play();
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single highlight clip
|
||||
async function exportHighlightClip(clip: HighlightClip) {
|
||||
if (!videoPath.value) return;
|
||||
|
||||
exportingHighlightId.value = clip.id;
|
||||
isExporting.value = true;
|
||||
exportMessage.value = "Exporting highlight...";
|
||||
|
||||
try {
|
||||
const result = await invoke<string>("export_clip", {
|
||||
videoPath: videoPath.value,
|
||||
startTime: clip.start_time,
|
||||
endTime: clip.end_time,
|
||||
outputName: `highlight_${props.game.recording_id}_${clip.highlight_type}_${clip.id}`,
|
||||
});
|
||||
|
||||
exportMessage.value = `Exported: ${result}`;
|
||||
} catch (error) {
|
||||
exportMessage.value = `Export failed: ${error}`;
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
exportingHighlightId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export all highlight clips
|
||||
async function exportAllHighlights() {
|
||||
if (!videoPath.value || highlights.value.length === 0) return;
|
||||
|
||||
isExporting.value = true;
|
||||
exportMessage.value = `Exporting ${highlights.value.length} highlights...`;
|
||||
|
||||
let exported = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const clip of highlights.value) {
|
||||
exportingHighlightId.value = clip.id;
|
||||
try {
|
||||
await invoke<string>("export_clip", {
|
||||
videoPath: videoPath.value,
|
||||
startTime: clip.start_time,
|
||||
endTime: clip.end_time,
|
||||
outputName: `highlight_${props.game.recording_id}_${clip.highlight_type}_${clip.id}`,
|
||||
});
|
||||
exported++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
exportMessage.value = `Exported ${exported} highlights${failed > 0 ? ` (${failed} failed)` : ""}`;
|
||||
isExporting.value = false;
|
||||
exportingHighlightId.value = null;
|
||||
}
|
||||
|
||||
// Watch video time to auto-advance through highlights
|
||||
watch(() => currentTime.value, (time) => {
|
||||
if (playingHighlightId.value === null || !highlightsMode.value) return;
|
||||
|
||||
const currentClip = highlights.value.find(h => h.id === playingHighlightId.value);
|
||||
if (!currentClip) return;
|
||||
|
||||
// If we've passed the end of the current clip, jump to the next one
|
||||
if (time > currentClip.end_time + 0.5) {
|
||||
const currentIndex = highlights.value.findIndex(h => h.id === playingHighlightId.value);
|
||||
if (currentIndex < highlights.value.length - 1) {
|
||||
const nextClip = highlights.value[currentIndex + 1];
|
||||
playingHighlightId.value = nextClip.id;
|
||||
seekTo(nextClip.start_time);
|
||||
} else {
|
||||
// Last clip finished
|
||||
playingHighlightId.value = null;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get video URL for playback using Tauri's convertFileSrc
|
||||
const videoUrl = computed(() => {
|
||||
@@ -436,9 +563,38 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
case "o":
|
||||
setClipEndPoint();
|
||||
break;
|
||||
case "h":
|
||||
highlightsMode.value = !highlightsMode.value;
|
||||
break;
|
||||
case "n":
|
||||
if (highlightsMode.value && highlights.value.length > 0) {
|
||||
// Jump to next highlight
|
||||
const nextHl = highlights.value.find(h => h.start_time > currentTime.value + 1);
|
||||
if (nextHl) {
|
||||
seekToHighlight(nextHl.start_time);
|
||||
} else {
|
||||
// Wrap to first
|
||||
seekToHighlight(highlights.value[0].start_time);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "p":
|
||||
if (highlightsMode.value && highlights.value.length > 0) {
|
||||
// Jump to previous highlight
|
||||
const prevHl = [...highlights.value].reverse().find(h => h.end_time < currentTime.value - 1);
|
||||
if (prevHl) {
|
||||
seekToHighlight(prevHl.start_time);
|
||||
} else {
|
||||
// Wrap to last
|
||||
seekToHighlight(highlights.value[highlights.value.length - 1].start_time);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (clipStart.value !== null || clipEnd.value !== null) {
|
||||
clearClipPoints();
|
||||
} else if (highlightsMode.value) {
|
||||
highlightsMode.value = false;
|
||||
} else {
|
||||
emit("back");
|
||||
}
|
||||
@@ -486,6 +642,18 @@ onUnmounted(() => {
|
||||
<span>Back to History</span>
|
||||
</button>
|
||||
|
||||
<!-- Highlights Mode Toggle -->
|
||||
<button
|
||||
class="highlights-toggle"
|
||||
:class="{ active: highlightsMode }"
|
||||
@click="highlightsMode = !highlightsMode"
|
||||
title="Toggle Highlights mode"
|
||||
>
|
||||
<span class="toggle-icon">🎬</span>
|
||||
<span class="toggle-label">Highlights</span>
|
||||
<span v-if="highlights.length > 0" class="toggle-badge">{{ highlights.length }}</span>
|
||||
</button>
|
||||
|
||||
<div class="header-info">
|
||||
<div class="header-champion">
|
||||
<img
|
||||
@@ -581,6 +749,22 @@ onUnmounted(() => {
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Highlight regions (shown in highlights mode) -->
|
||||
<div
|
||||
v-if="highlightsMode"
|
||||
v-for="clip in highlights"
|
||||
:key="'hl-' + clip.id"
|
||||
class="highlight-region"
|
||||
:class="{ active: playingHighlightId === clip.id }"
|
||||
:style="{
|
||||
left: `${(clip.start_time / duration) * 100}%`,
|
||||
width: `${((clip.end_time - clip.start_time) / duration) * 100}%`,
|
||||
backgroundColor: getHighlightTypeColor(clip.highlight_type),
|
||||
}"
|
||||
@click.stop="seekToHighlight(clip.start_time)"
|
||||
:title="`${clip.title} (${formatDuration(clip.end_time - clip.start_time)})`"
|
||||
></div>
|
||||
|
||||
<!-- Event markers -->
|
||||
<div
|
||||
v-for="(event, idx) in sortedEvents"
|
||||
@@ -724,8 +908,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Sidebar -->
|
||||
<div class="stats-sidebar">
|
||||
<!-- Stats Sidebar (shown when NOT in highlights mode) -->
|
||||
<div v-if="!highlightsMode" class="stats-sidebar">
|
||||
<!-- Player Stats Panel -->
|
||||
<div class="stats-panel">
|
||||
<h3>Your Stats</h3>
|
||||
@@ -839,6 +1023,24 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlights Sidebar (shown when in highlights mode) -->
|
||||
<div v-else class="stats-sidebar highlights-sidebar">
|
||||
<HighlightsPanel
|
||||
:highlights="highlights"
|
||||
:current-video-time="currentTime"
|
||||
:video-duration="duration"
|
||||
:is-playing="isPlaying"
|
||||
:is-exporting="isExporting"
|
||||
:exporting-clip-id="exportingHighlightId"
|
||||
@seek-to="seekToHighlight"
|
||||
@play-clip="playHighlightClip"
|
||||
@play-all="playAllHighlights"
|
||||
@export-clip="exportHighlightClip"
|
||||
@export-all="exportAllHighlights"
|
||||
@settings-changed="onHighlightSettingsChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -885,6 +1087,57 @@ onUnmounted(() => {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Highlights Mode Toggle */
|
||||
.highlights-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: #94a3b8;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.highlights-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.highlights-toggle.active {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
border-color: rgba(249, 115, 22, 0.4);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.highlights-toggle .toggle-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.highlights-toggle .toggle-label {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.highlights-toggle .toggle-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: rgba(249, 115, 22, 0.3);
|
||||
color: #f97316;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
min-width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlights-toggle.active .toggle-badge {
|
||||
background: rgba(249, 115, 22, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1106,6 +1359,26 @@ onUnmounted(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Highlight regions on timeline */
|
||||
.highlight-region {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
opacity: 0.2;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.highlight-region:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.highlight-region.active {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.event-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
721
tauri-app/src/components/HighlightsPanel.vue
Normal file
721
tauri-app/src/components/HighlightsPanel.vue
Normal file
@@ -0,0 +1,721 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import type {
|
||||
HighlightClip,
|
||||
HighlightSettings,
|
||||
HighlightType,
|
||||
} from "../types/timeline";
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_SETTINGS,
|
||||
getHighlightTypeColor,
|
||||
getHighlightTypeIcon,
|
||||
formatDuration,
|
||||
} from "../types/timeline";
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
highlights: HighlightClip[];
|
||||
currentVideoTime: number;
|
||||
videoDuration: number;
|
||||
isPlaying: boolean;
|
||||
isExporting: boolean;
|
||||
exportingClipId: number | null;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "seek-to", time: number): void;
|
||||
(e: "play-clip", clip: HighlightClip): void;
|
||||
(e: "play-all"): void;
|
||||
(e: "export-clip", clip: HighlightClip): void;
|
||||
(e: "export-all"): void;
|
||||
(e: "settings-changed", settings: HighlightSettings): void;
|
||||
}>();
|
||||
|
||||
// Settings panel state
|
||||
const showSettings = ref(false);
|
||||
const settings = ref<HighlightSettings>({ ...DEFAULT_HIGHLIGHT_SETTINGS });
|
||||
|
||||
// Type filter toggles
|
||||
const typeToggles = ref<Record<HighlightType, boolean>>({
|
||||
kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("kill"),
|
||||
death: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("death"),
|
||||
assist: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("assist"),
|
||||
objective: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("objective"),
|
||||
multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"),
|
||||
});
|
||||
|
||||
// Watch type toggles and update settings
|
||||
watch(
|
||||
typeToggles,
|
||||
(toggles) => {
|
||||
const included_types = (Object.entries(toggles) as [HighlightType, boolean][])
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([type]) => type);
|
||||
settings.value = { ...settings.value, included_types };
|
||||
emit("settings-changed", settings.value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Watch buffer settings and emit
|
||||
watch(
|
||||
() => [settings.value.buffer_before, settings.value.buffer_after, settings.value.merge_overlapping, settings.value.merge_gap_secs],
|
||||
() => {
|
||||
emit("settings-changed", settings.value);
|
||||
},
|
||||
);
|
||||
|
||||
// Currently active clip (the one whose time range contains the current video time)
|
||||
const activeClipId = computed(() => {
|
||||
for (const clip of props.highlights) {
|
||||
if (
|
||||
props.currentVideoTime >= clip.start_time &&
|
||||
props.currentVideoTime <= clip.end_time
|
||||
) {
|
||||
return clip.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Stats
|
||||
const killCount = computed(() => props.highlights.filter(h => h.highlight_type === "kill").length);
|
||||
const deathCount = computed(() => props.highlights.filter(h => h.highlight_type === "death").length);
|
||||
const objectiveCount = computed(() => props.highlights.filter(h => h.highlight_type === "objective").length);
|
||||
const multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length);
|
||||
const assistCount = computed(() => props.highlights.filter(h => h.highlight_type === "assist").length);
|
||||
const totalDuration = computed(() => {
|
||||
const total = props.highlights.reduce((sum, h) => sum + (h.end_time - h.start_time), 0);
|
||||
return total;
|
||||
});
|
||||
|
||||
// Seek to the start of a clip
|
||||
function seekToClip(clip: HighlightClip) {
|
||||
emit("seek-to", clip.start_time);
|
||||
}
|
||||
|
||||
// Play a specific clip
|
||||
function playClip(clip: HighlightClip) {
|
||||
emit("play-clip", clip);
|
||||
}
|
||||
|
||||
// Export a single clip
|
||||
function exportClip(clip: HighlightClip) {
|
||||
emit("export-clip", clip);
|
||||
}
|
||||
|
||||
// Format clip time range
|
||||
function formatClipTime(clip: HighlightClip): string {
|
||||
return `${formatDuration(clip.start_time)} - ${formatDuration(clip.end_time)}`;
|
||||
}
|
||||
|
||||
// Format clip duration
|
||||
function formatClipDuration(clip: HighlightClip): string {
|
||||
return formatDuration(clip.end_time - clip.start_time);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="highlights-panel">
|
||||
<!-- Header -->
|
||||
<div class="highlights-header">
|
||||
<h3>
|
||||
<span class="header-icon">🎬</span>
|
||||
Highlights
|
||||
</h3>
|
||||
<button
|
||||
class="settings-toggle"
|
||||
:class="{ active: showSettings }"
|
||||
@click="showSettings = !showSettings"
|
||||
title="Highlight settings"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel (collapsible) -->
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<div class="settings-group">
|
||||
<label class="settings-label">Buffer Before</label>
|
||||
<div class="slider-row">
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="30"
|
||||
step="1"
|
||||
v-model.number="settings.buffer_before"
|
||||
class="settings-slider"
|
||||
/>
|
||||
<span class="slider-value">{{ settings.buffer_before }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label class="settings-label">Buffer After</label>
|
||||
<div class="slider-row">
|
||||
<input
|
||||
type="range"
|
||||
min="2"
|
||||
max="30"
|
||||
step="1"
|
||||
v-model.number="settings.buffer_after"
|
||||
class="settings-slider"
|
||||
/>
|
||||
<span class="slider-value">{{ settings.buffer_after }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label class="settings-label">Include Types</label>
|
||||
<div class="type-toggles">
|
||||
<label
|
||||
v-for="typeInfo in [
|
||||
{ type: 'kill' as HighlightType, label: 'Kills', icon: '⚔️' },
|
||||
{ type: 'death' as HighlightType, label: 'Deaths', icon: '💀' },
|
||||
{ type: 'assist' as HighlightType, label: 'Assists', icon: '🤝' },
|
||||
{ type: 'objective' as HighlightType, label: 'Objectives', icon: '🏰' },
|
||||
{ type: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' },
|
||||
]"
|
||||
:key="typeInfo.type"
|
||||
class="type-toggle"
|
||||
:style="{ borderColor: typeToggles[typeInfo.type] ? getHighlightTypeColor(typeInfo.type) : 'rgba(255,255,255,0.1)' }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="typeToggles[typeInfo.type]"
|
||||
class="toggle-checkbox"
|
||||
/>
|
||||
<span class="toggle-icon">{{ typeInfo.icon }}</span>
|
||||
<span class="toggle-label">{{ typeInfo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label class="settings-row-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="settings.merge_overlapping"
|
||||
class="toggle-checkbox"
|
||||
/>
|
||||
Merge overlapping clips
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.merge_overlapping" class="settings-group">
|
||||
<label class="settings-label">Merge Gap</label>
|
||||
<div class="slider-row">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
v-model.number="settings.merge_gap_secs"
|
||||
class="settings-slider"
|
||||
/>
|
||||
<span class="slider-value">{{ settings.merge_gap_secs }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="highlights-stats">
|
||||
<div class="stat-pill" v-if="killCount > 0">
|
||||
<span>⚔️</span> {{ killCount }}
|
||||
</div>
|
||||
<div class="stat-pill" v-if="deathCount > 0">
|
||||
<span>💀</span> {{ deathCount }}
|
||||
</div>
|
||||
<div class="stat-pill" v-if="assistCount > 0">
|
||||
<span>🤝</span> {{ assistCount }}
|
||||
</div>
|
||||
<div class="stat-pill" v-if="objectiveCount > 0">
|
||||
<span>🏰</span> {{ objectiveCount }}
|
||||
</div>
|
||||
<div class="stat-pill" v-if="multiKillCount > 0">
|
||||
<span>🔥</span> {{ multiKillCount }}
|
||||
</div>
|
||||
<div class="stat-pill total">
|
||||
<span>⏱️</span> {{ formatDuration(totalDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="highlights-actions">
|
||||
<button
|
||||
class="action-btn play-all"
|
||||
@click="emit('play-all')"
|
||||
:disabled="highlights.length === 0"
|
||||
>
|
||||
▶ Play All
|
||||
</button>
|
||||
<button
|
||||
class="action-btn export-all"
|
||||
@click="emit('export-all')"
|
||||
:disabled="highlights.length === 0 || isExporting"
|
||||
>
|
||||
{{ isExporting ? "Exporting..." : "📦 Export All" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="highlights.length === 0" class="empty-highlights">
|
||||
<div class="empty-icon">🎬</div>
|
||||
<p>No highlights detected</p>
|
||||
<p class="empty-hint">Highlights are generated from kills, deaths, and objectives during the game.</p>
|
||||
</div>
|
||||
|
||||
<!-- Highlights List -->
|
||||
<div v-else class="highlights-list">
|
||||
<div
|
||||
v-for="clip in highlights"
|
||||
:key="clip.id"
|
||||
class="highlight-card"
|
||||
:class="{
|
||||
active: activeClipId === clip.id,
|
||||
[clip.highlight_type]: true,
|
||||
}"
|
||||
@click="seekToClip(clip)"
|
||||
>
|
||||
<div class="highlight-indicator" :style="{ backgroundColor: getHighlightTypeColor(clip.highlight_type) }"></div>
|
||||
|
||||
<div class="highlight-content">
|
||||
<div class="highlight-title-row">
|
||||
<span class="highlight-icon">{{ getHighlightTypeIcon(clip.highlight_type) }}</span>
|
||||
<span class="highlight-title">{{ clip.title }}</span>
|
||||
<span class="highlight-duration">{{ formatClipDuration(clip) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="clip.subtitle" class="highlight-subtitle">
|
||||
{{ clip.subtitle }}
|
||||
</div>
|
||||
|
||||
<div class="highlight-time-row">
|
||||
<span class="highlight-time">{{ formatClipTime(clip) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="highlight-actions">
|
||||
<button
|
||||
class="mini-btn play"
|
||||
@click.stop="playClip(clip)"
|
||||
title="Play this clip"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
class="mini-btn export"
|
||||
@click.stop="exportClip(clip)"
|
||||
:disabled="isExporting && exportingClipId === clip.id"
|
||||
title="Export this clip"
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.highlights-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.highlights-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.highlights-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #c8aa6e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
background: rgba(200, 170, 110, 0.15);
|
||||
border-color: #c8aa6e;
|
||||
}
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.settings-row-label {
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #c8aa6e;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #c8aa6e;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.type-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.type-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.type-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.toggle-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Quick Stats */
|
||||
.highlights-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.stat-pill.total {
|
||||
color: #c8aa6e;
|
||||
background: rgba(200, 170, 110, 0.1);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.highlights-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.play-all {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.action-btn.play-all:hover:not(:disabled) {
|
||||
background: rgba(74, 222, 128, 0.25);
|
||||
}
|
||||
|
||||
.action-btn.export-all {
|
||||
background: rgba(200, 170, 110, 0.15);
|
||||
border-color: rgba(200, 170, 110, 0.3);
|
||||
color: #c8aa6e;
|
||||
}
|
||||
|
||||
.action-btn.export-all:hover:not(:disabled) {
|
||||
background: rgba(200, 170, 110, 0.25);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-highlights {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-highlights p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.75rem !important;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Highlights List */
|
||||
.highlights-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.highlight-card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.highlight-card.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.highlight-card.active.kill {
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.highlight-card.active.death {
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
.highlight-card.active.assist {
|
||||
border-color: rgba(167, 139, 250, 0.4);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
}
|
||||
|
||||
.highlight-card.active.objective {
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.highlight-card.active.multi_kill {
|
||||
border-color: rgba(249, 115, 22, 0.4);
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
}
|
||||
|
||||
.highlight-indicator {
|
||||
width: 4px;
|
||||
min-height: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.highlight-content {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.highlight-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.highlight-icon {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.highlight-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.highlight-duration {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.highlight-subtitle {
|
||||
font-size: 0.72rem;
|
||||
color: #999;
|
||||
margin-top: 0.15rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.highlight-time-row {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.highlight-time {
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.highlight-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.highlight-card:hover .highlight-actions,
|
||||
.highlight-card.active .highlight-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mini-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.mini-btn.play:hover {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.mini-btn.export:hover {
|
||||
background: rgba(200, 170, 110, 0.2);
|
||||
border-color: rgba(200, 170, 110, 0.3);
|
||||
}
|
||||
|
||||
.mini-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user