-
Notifications
You must be signed in to change notification settings - Fork 31
Add VideoEventTimeline component #1672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
LeonardoRosaa
merged 49 commits into
main
from
horatiu-lig-9807-show-event-bars-on-the-timeline-3-events-timeline
Jul 21, 2026
Merged
Changes from all commits
Commits
Show all changes
49 commits
Select commit
Hold shift + click to select a range
d211bce
Add TemporalSpan model
horatiualmasan 0c66a38
update
horatiualmasan 3e04899
fix
horatiualmasan bd48016
try eager load
horatiualmasan f668678
fix warning
horatiualmasan f3956d1
Add video annotation from activitynet
horatiualmasan 07ddf00
remove not needed joins
horatiualmasan 37441f5
Merge branch 'horatiu-lig-9805-import-activitynet-style-event-annotat…
horatiualmasan ebe86db
simplify
horatiualmasan 84a4063
Merge branch 'main' into horatiu-lig-9805-import-activitynet-style-ev…
horatiualmasan 47ec651
fix merge
horatiualmasan 9f97a84
add example sctipt
horatiualmasan d95200c
format
horatiualmasan 6a243bd
update
horatiualmasan 1a24532
Merge branch 'main' into horatiu-lig-9805-import-activitynet-style-ev…
horatiualmasan d32cbe4
Consider also own annotations for video filter
horatiualmasan 3acc186
Merge branch 'horatiu-lig-9805-import-activitynet-style-event-annotat…
horatiualmasan bdc02d2
fix merge error
horatiualmasan a9d31fd
Add useVideoPlayback hook with tests
horatiualmasan 5a07a5b
Wire custom control bar into VideoPlayer
horatiualmasan ebad55a
Use custom player in VideoDetails with deep-link start time
horatiualmasan 1d6d5ae
Add presentation-only VideoControls component
horatiualmasan 2cdba41
format
horatiualmasan 9f287db
Add Storybook stories for VideoControls
horatiualmasan ecef795
Use custom player in VideoDetails with deep-link start time
horatiualmasan e2a18ce
format
horatiualmasan 7dede03
Wire custom control bar into VideoPlayer
horatiualmasan 2dc2a47
Add useVideoPlayback hook with tests
horatiualmasan ab6acde
Add Storybook stories for VideoControls
horatiualmasan 35328bb
update
horatiualmasan 8395952
fix error in test stub
horatiualmasan f8ef40d
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.d' of …
horatiualmasan 30b1ea2
skip test
horatiualmasan feae9e1
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.c' int…
horatiualmasan 776baa3
reenable test
horatiualmasan 0f2b999
Add video event model (toVideoEvents, assignEventLanes)
horatiualmasan 8e2426e
Add VideoEventTimeline component
horatiualmasan 717ebf2
add AnnotationType.CLASSIFICATION
horatiualmasan b8ada23
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan 30718ba
update
horatiualmasan d467a37
Merge branch 'main' into horatiu-lig-9807-show-event-bars-on-the-time…
horatiualmasan cf0ee5e
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.a' int…
horatiualmasan a604ba9
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.b' int…
horatiualmasan 9563c27
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.c' int…
horatiualmasan 07ad39d
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.d' int…
horatiualmasan 5f70a87
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan bf3e35a
Merge branch 'main' into horatiu-lig-9807-show-event-bars-on-the-time…
LeonardoRosaa 1d685ce
add storybook
LeonardoRosaa 8a8f869
prevent short bars from overflowing the track boundary
LeonardoRosaa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
104 changes: 104 additions & 0 deletions
104
lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.stories.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| <script module lang="ts"> | ||
| import { defineMeta } from '@storybook/addon-svelte-csf'; | ||
| import VideoEventTimeline from './VideoEventTimeline.svelte'; | ||
| import { toVideoEvents } from '$lib/utils'; | ||
|
|
||
| const { Story } = defineMeta({ | ||
| title: 'Components/VideoEventTimeline', | ||
| component: VideoEventTimeline, | ||
| tags: ['autodocs'] | ||
| }); | ||
|
|
||
| /** Build events without needing real annotation payloads. */ | ||
| function makeEvents(spans: [string, number, number][]) { | ||
| return toVideoEvents( | ||
| spans.map(([label, start, end], index) => ({ | ||
| sample_id: `evt-${index}`, | ||
| parent_sample_id: 'video-1', | ||
| annotation_collection_id: 'coll-1', | ||
| annotation_type: 'classification' as const, | ||
| annotation_label: { annotation_label_name: label }, | ||
| created_at: new Date(), | ||
| temporal_span_details: { start_time_s: start, end_time_s: end } | ||
| })) | ||
| ); | ||
| } | ||
|
|
||
| const DURATION_S = 20; | ||
|
|
||
| const defaultEvents = makeEvents([ | ||
| ['Long Jump', 2, 9], | ||
| ['Run-up', 0, 3], | ||
| ['Landing', 8.5, 11], | ||
| ['Celebration', 12, 16] | ||
| ]); | ||
|
|
||
| const overlappingEvents = makeEvents([ | ||
| ['A', 0, 10], | ||
| ['B', 1, 4], | ||
| ['C', 2, 6], | ||
| ['D', 5, 9] | ||
| ]); | ||
|
|
||
| const singleEvent = makeEvents([['Sprint', 3, 14]]); | ||
| </script> | ||
|
|
||
| <script> | ||
| let playheadTimeS = $state(6); | ||
| </script> | ||
|
|
||
| <!-- | ||
| Click any event bar to seek: the playhead jumps to the bar's start time. | ||
| --> | ||
| <Story name="Playground" asChild> | ||
| <div class="w-[600px]"> | ||
| <VideoEventTimeline | ||
| events={defaultEvents} | ||
| durationS={DURATION_S} | ||
| currentTimeS={playheadTimeS} | ||
| onSeek={(t) => (playheadTimeS = t)} | ||
| /> | ||
| </div> | ||
| </Story> | ||
|
|
||
| <Story name="Default" args={{ events: defaultEvents, durationS: DURATION_S, currentTimeS: 6 }} /> | ||
|
|
||
| <Story name="Empty" args={{ events: [], durationS: DURATION_S }} /> | ||
|
|
||
| <Story | ||
| name="OverlappingLanes" | ||
| args={{ | ||
| events: overlappingEvents, | ||
| durationS: 12, | ||
| currentTimeS: 3 | ||
| }} | ||
| /> | ||
|
|
||
| <Story | ||
| name="SingleEvent" | ||
| args={{ | ||
| events: singleEvent, | ||
| durationS: DURATION_S, | ||
| currentTimeS: 5 | ||
| }} | ||
| /> | ||
|
|
||
| <Story | ||
| name="NoHeader" | ||
| args={{ | ||
| events: defaultEvents, | ||
| durationS: DURATION_S, | ||
| currentTimeS: 6, | ||
| showHeader: false | ||
| }} | ||
| /> | ||
|
|
||
| <Story | ||
| name="CustomTitle" | ||
| args={{ | ||
| events: defaultEvents, | ||
| durationS: DURATION_S, | ||
| currentTimeS: 6, | ||
| title: 'Activity annotations' | ||
| }} | ||
| /> |
130 changes: 130 additions & 0 deletions
130
lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| <script lang="ts"> | ||
| /** | ||
| * Renders time-bounded video events as clickable bars positioned by their | ||
| * start and end time. | ||
| * | ||
| * Overlapping events are stacked into separate lanes so none is hidden. | ||
| * Clicking a bar seeks the associated player via {@link onSeek}, and an | ||
| * optional playhead marks the current playback position. | ||
| * | ||
| * The component is presentation-only: pass in already-derived | ||
| * {@link VideoEvent}s (see `toVideoEvents`) so it can be reused for any | ||
| * temporal-annotation source. | ||
| * | ||
| * @example | ||
| * ```svelte | ||
| * <VideoEventTimeline | ||
| * events={toVideoEvents(video.sample.annotations)} | ||
| * durationS={video.duration_s ?? 0} | ||
| * currentTimeS={playbackTime} | ||
| * onSeek={(t) => (videoEl.currentTime = t)} | ||
| * /> | ||
| * ``` | ||
| */ | ||
| import { cn } from '$lib/utils/shadcn.js'; | ||
| import { assignEventLanes, type VideoEvent } from '$lib/utils'; | ||
|
|
||
| interface VideoEventTimelineProps { | ||
| /** Events to render, e.g. from `toVideoEvents`. */ | ||
| events: VideoEvent[]; | ||
| /** Total video duration in seconds; used to position bars. */ | ||
| durationS: number; | ||
| /** Current playback time in seconds for the playhead indicator. */ | ||
| currentTimeS?: number; | ||
| /** Called with the target time in seconds when a bar is clicked. */ | ||
| onSeek?: (timeS: number) => void; | ||
| /** Heading shown above the timeline. */ | ||
| title?: string; | ||
| /** Whether to render the title/count header above the track. */ | ||
| showHeader?: boolean; | ||
| /** Extra classes for the outer container. */ | ||
| class?: string; | ||
| } | ||
|
|
||
| let { | ||
| events, | ||
| durationS, | ||
| currentTimeS = 0, | ||
| onSeek, | ||
| title = 'Events', | ||
| showHeader = true, | ||
| class: className | ||
| }: VideoEventTimelineProps = $props(); | ||
|
|
||
| const LANE_HEIGHT_PX = 22; | ||
| const LANE_GAP_PX = 4; | ||
| // Keep very short events clickable even when they occupy a sliver of the track. | ||
| const MIN_BAR_WIDTH_PERCENT = 0.75; | ||
|
|
||
| const lanes = $derived(assignEventLanes(events)); | ||
| const trackHeightPx = $derived( | ||
| Math.max(1, lanes.laneCount) * LANE_HEIGHT_PX + | ||
| Math.max(0, lanes.laneCount - 1) * LANE_GAP_PX | ||
| ); | ||
|
|
||
| const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); | ||
|
|
||
| const toPercent = (timeS: number) => | ||
| durationS > 0 ? clampPercent((timeS / durationS) * 100) : 0; | ||
|
|
||
| function formatTime(timeS: number): string { | ||
| const totalSeconds = Math.max(0, Math.round(timeS)); | ||
| const minutes = Math.floor(totalSeconds / 60); | ||
| const seconds = totalSeconds % 60; | ||
| return `${minutes}:${seconds.toString().padStart(2, '0')}`; | ||
| } | ||
| </script> | ||
|
|
||
| <div class={cn('flex flex-col gap-2', className)} data-testid="video-event-timeline"> | ||
| {#if showHeader} | ||
| <div class="flex items-center justify-between text-xs text-muted-foreground"> | ||
| <span class="font-medium">{title}</span> | ||
| <span>{events.length}</span> | ||
| </div> | ||
| {/if} | ||
|
|
||
| {#if events.length === 0} | ||
| <p class="text-xs text-muted-foreground">No events for this video.</p> | ||
| {:else} | ||
| <div | ||
| class="relative w-full rounded-md bg-muted/40" | ||
| style={`height: ${trackHeightPx}px;`} | ||
| role="group" | ||
| aria-label={title} | ||
| > | ||
| {#each lanes.events as event (event.id)} | ||
| {@const widthPercent = Math.max( | ||
| MIN_BAR_WIDTH_PERCENT, | ||
| toPercent(event.endTimeS) - toPercent(event.startTimeS) | ||
| )} | ||
| {@const leftPercent = Math.min(toPercent(event.startTimeS), 100 - widthPercent)} | ||
| {@const timeRange = `${formatTime(event.startTimeS)}–${formatTime(event.endTimeS)}`} | ||
| <div | ||
| class="absolute" | ||
| style={`left: ${leftPercent}%; width: ${widthPercent}%; top: ${ | ||
| event.lane * (LANE_HEIGHT_PX + LANE_GAP_PX) | ||
| }px; height: ${LANE_HEIGHT_PX}px;`} | ||
| > | ||
| <button | ||
| type="button" | ||
| class="flex h-full w-full items-center overflow-hidden rounded-sm border px-1.5 text-left text-[11px] leading-none transition-[filter] hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| style={`background-color: ${event.color}; border-color: ${event.contrastColor};`} | ||
| title={`${event.label} · ${timeRange}`} | ||
| aria-label={`Seek to ${event.label} at ${timeRange}`} | ||
| onclick={() => onSeek?.(event.startTimeS)} | ||
| > | ||
| <span class="truncate">{event.label}</span> | ||
| </button> | ||
| </div> | ||
| {/each} | ||
|
|
||
| {#if durationS > 0} | ||
| <div | ||
| class="pointer-events-none absolute top-0 z-10 h-full w-0.5 bg-primary" | ||
| style={`left: ${toPercent(currentTimeS)}%;`} | ||
| aria-hidden="true" | ||
| ></div> | ||
| {/if} | ||
| </div> | ||
| {/if} | ||
| </div> | ||
82 changes: 82 additions & 0 deletions
82
lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { render, fireEvent } from '@testing-library/svelte'; | ||
| import VideoEventTimeline from './VideoEventTimeline.svelte'; | ||
| import type { VideoEvent } from '$lib/utils'; | ||
|
|
||
| function makeEvent(overrides: Partial<VideoEvent>): VideoEvent { | ||
| return { | ||
| id: 'e', | ||
| annotationCollectionId: 'coll-1', | ||
| label: 'Jump', | ||
| startTimeS: 0, | ||
| endTimeS: 2, | ||
| color: 'rgba(10, 20, 30, 0.7)', | ||
| contrastColor: 'rgba(245, 235, 225, 0.7)', | ||
| ...overrides | ||
| }; | ||
| } | ||
|
|
||
| describe('VideoEventTimeline', () => { | ||
| it('renders a bar per event', () => { | ||
| const { getAllByRole } = render(VideoEventTimeline, { | ||
| props: { | ||
| events: [ | ||
| makeEvent({ id: 'a', label: 'A' }), | ||
| makeEvent({ id: 'b', label: 'B', startTimeS: 3, endTimeS: 5 }) | ||
| ], | ||
| durationS: 10 | ||
| } | ||
| }); | ||
|
|
||
| expect(getAllByRole('button')).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('positions bars by start and end time', () => { | ||
| const { getByText } = render(VideoEventTimeline, { | ||
| props: { | ||
| events: [makeEvent({ id: 'a', label: 'Mid', startTimeS: 2, endTimeS: 4 })], | ||
| durationS: 10 | ||
| } | ||
| }); | ||
|
|
||
| const barContainer = getByText('Mid').closest('button')?.parentElement as HTMLElement; | ||
| expect(barContainer.style.left).toBe('20%'); | ||
| expect(barContainer.style.width).toBe('20%'); | ||
| }); | ||
|
|
||
| it('calls onSeek with the event start time when clicked', async () => { | ||
| const onSeek = vi.fn(); | ||
| const { getByText } = render(VideoEventTimeline, { | ||
| props: { | ||
| events: [makeEvent({ id: 'a', label: 'Clip', startTimeS: 7, endTimeS: 9 })], | ||
| durationS: 10, | ||
| onSeek | ||
| } | ||
| }); | ||
|
|
||
| await fireEvent.click(getByText('Clip')); | ||
| expect(onSeek).toHaveBeenCalledWith(7); | ||
| }); | ||
|
|
||
| it('shows a placeholder when there are no events', () => { | ||
| const { getByText, queryAllByRole } = render(VideoEventTimeline, { | ||
| props: { events: [], durationS: 10 } | ||
| }); | ||
|
|
||
| expect(getByText('No events for this video.')).toBeTruthy(); | ||
| expect(queryAllByRole('button')).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('hides the header when showHeader is false', () => { | ||
| const { queryByText } = render(VideoEventTimeline, { | ||
| props: { | ||
| events: [makeEvent({ id: 'a' })], | ||
| durationS: 10, | ||
| title: 'Events', | ||
| showHeader: false | ||
| } | ||
| }); | ||
|
|
||
| expect(queryByText('Events')).toBeFalsy(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.