Files
mediaproc/ui/detection-app/src/components/FrameStrip.vue
2026-03-27 07:33:49 -03:00

264 lines
5.5 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
frames: Array<{ seq: number; timestamp: number; jpeg_b64: string }>
currentIndex: number
selectionStart: number
selectionEnd: number
}>()
const emit = defineEmits<{
'frame-click': [index: number]
'selection-change': [start: number, end: number]
}>()
const stripEl = ref<HTMLElement | null>(null)
// --- Drag handle logic ---
type DragTarget = 'start' | 'end' | null
const dragging = ref<DragTarget>(null)
function onHandleMousedown(target: DragTarget, e: MouseEvent) {
dragging.value = target
e.preventDefault()
}
function onMousemove(e: MouseEvent) {
if (!dragging.value || !stripEl.value) return
const rect = stripEl.value.getBoundingClientRect()
const x = e.clientX - rect.left
const ratio = Math.max(0, Math.min(1, x / rect.width))
const idx = Math.round(ratio * (props.frames.length - 1))
if (dragging.value === 'start') {
const newStart = Math.min(idx, props.selectionEnd)
emit('selection-change', newStart, props.selectionEnd)
} else {
const newEnd = Math.max(idx, props.selectionStart)
emit('selection-change', props.selectionStart, newEnd)
}
}
function onMouseup() {
dragging.value = null
}
onMounted(() => {
window.addEventListener('mousemove', onMousemove)
window.addEventListener('mouseup', onMouseup)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMousemove)
window.removeEventListener('mouseup', onMouseup)
})
// Handle positions as % of strip width
const startPct = computed(() => {
if (props.frames.length <= 1) return 0
return (props.selectionStart / (props.frames.length - 1)) * 100
})
const endPct = computed(() => {
if (props.frames.length <= 1) return 100
return (props.selectionEnd / (props.frames.length - 1)) * 100
})
function isInSelection(idx: number) {
return idx >= props.selectionStart && idx <= props.selectionEnd
}
// --- Scrub slider ---
function onScrub(e: Event) {
const val = Number((e.target as HTMLInputElement).value)
emit('frame-click', val)
}
</script>
<template>
<div class="frame-strip" ref="stripEl">
<!-- Thumbnails -->
<div class="thumbs">
<div
v-for="(frame, idx) in frames"
:key="frame.seq"
class="thumb"
:class="{
current: idx === currentIndex,
dimmed: !isInSelection(idx),
}"
@click="emit('frame-click', idx)"
:title="`Frame ${frame.seq} · ${frame.timestamp.toFixed(2)}s`"
>
<img :src="`data:image/jpeg;base64,${frame.jpeg_b64}`" />
</div>
</div>
<!-- Scrub slider -->
<div class="scrub-row">
<input
type="range"
class="scrub-slider"
:min="0"
:max="frames.length - 1"
:value="currentIndex"
@input="onScrub"
/>
<span class="scrub-label">{{ currentIndex + 1 }}/{{ frames.length }}</span>
</div>
<!-- Selection handles -->
<div class="handles">
<div
class="handle handle-start"
:style="{ left: startPct + '%' }"
@mousedown="onHandleMousedown('start', $event)"
title="Drag to set selection start"
/>
<div
class="handle handle-end"
:style="{ left: endPct + '%' }"
@mousedown="onHandleMousedown('end', $event)"
title="Drag to set selection end"
/>
<div
class="selection-range"
:style="{ left: startPct + '%', width: (endPct - startPct) + '%' }"
/>
</div>
</div>
</template>
<style scoped>
.frame-strip {
position: relative;
display: flex;
flex-direction: column;
background: var(--surface-1);
border-top: var(--panel-border);
user-select: none;
}
.thumbs {
display: flex;
flex-direction: row;
gap: 2px;
padding: 4px 6px 0;
overflow-x: auto;
scrollbar-width: none;
}
.thumbs::-webkit-scrollbar { display: none; }
.thumb {
flex-shrink: 0;
width: 80px;
height: 50px;
border: 2px solid transparent;
border-radius: 2px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.1s, opacity 0.15s;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb.current {
border-color: #00bcd4;
}
.thumb.dimmed {
opacity: 0.35;
}
.thumb:hover:not(.current) {
border-color: var(--surface-3);
}
/* Scrub slider row */
.scrub-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
}
.scrub-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
background: var(--surface-3);
border-radius: 2px;
outline: none;
}
.scrub-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #00bcd4;
cursor: pointer;
}
.scrub-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #00bcd4;
cursor: pointer;
border: none;
}
.scrub-label {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
min-width: 30px;
text-align: right;
}
/* Handle track area */
.handles {
position: relative;
height: 14px;
margin: 0 6px;
}
.selection-range {
position: absolute;
top: 5px;
height: 4px;
background: #00bcd4;
opacity: 0.35;
pointer-events: none;
}
.handle {
position: absolute;
top: 0;
width: 10px;
height: 14px;
transform: translateX(-50%);
cursor: ew-resize;
z-index: 2;
}
.handle::after {
content: '';
position: absolute;
left: 50%;
top: 2px;
transform: translateX(-50%);
width: 3px;
height: 10px;
background: #00bcd4;
border-radius: 2px;
}
</style>