Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
d211bce
Add TemporalSpan model
horatiualmasan Jul 7, 2026
0c66a38
update
horatiualmasan Jul 7, 2026
3e04899
fix
horatiualmasan Jul 7, 2026
bd48016
try eager load
horatiualmasan Jul 7, 2026
f668678
fix warning
horatiualmasan Jul 7, 2026
f3956d1
Add video annotation from activitynet
horatiualmasan Jul 8, 2026
07ddf00
remove not needed joins
horatiualmasan Jul 8, 2026
37441f5
Merge branch 'horatiu-lig-9805-import-activitynet-style-event-annotat…
horatiualmasan Jul 8, 2026
ebe86db
simplify
horatiualmasan Jul 8, 2026
84a4063
Merge branch 'main' into horatiu-lig-9805-import-activitynet-style-ev…
horatiualmasan Jul 15, 2026
47ec651
fix merge
horatiualmasan Jul 15, 2026
9f97a84
add example sctipt
horatiualmasan Jul 15, 2026
d95200c
format
horatiualmasan Jul 15, 2026
6a243bd
update
horatiualmasan Jul 15, 2026
1a24532
Merge branch 'main' into horatiu-lig-9805-import-activitynet-style-ev…
horatiualmasan Jul 15, 2026
d32cbe4
Consider also own annotations for video filter
horatiualmasan Jul 16, 2026
3acc186
Merge branch 'horatiu-lig-9805-import-activitynet-style-event-annotat…
horatiualmasan Jul 16, 2026
bdc02d2
fix merge error
horatiualmasan Jul 16, 2026
a9d31fd
Add useVideoPlayback hook with tests
horatiualmasan Jul 16, 2026
5a07a5b
Wire custom control bar into VideoPlayer
horatiualmasan Jul 16, 2026
ebad55a
Use custom player in VideoDetails with deep-link start time
horatiualmasan Jul 16, 2026
1d6d5ae
Add presentation-only VideoControls component
horatiualmasan Jul 16, 2026
2cdba41
format
horatiualmasan Jul 16, 2026
9f287db
Add Storybook stories for VideoControls
horatiualmasan Jul 16, 2026
ecef795
Use custom player in VideoDetails with deep-link start time
horatiualmasan Jul 16, 2026
e2a18ce
format
horatiualmasan Jul 16, 2026
7dede03
Wire custom control bar into VideoPlayer
horatiualmasan Jul 16, 2026
2dc2a47
Add useVideoPlayback hook with tests
horatiualmasan Jul 16, 2026
ab6acde
Add Storybook stories for VideoControls
horatiualmasan Jul 16, 2026
35328bb
update
horatiualmasan Jul 16, 2026
8395952
fix error in test stub
horatiualmasan Jul 16, 2026
f8ef40d
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.d' of …
horatiualmasan Jul 16, 2026
30b1ea2
skip test
horatiualmasan Jul 16, 2026
feae9e1
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.c' int…
horatiualmasan Jul 16, 2026
776baa3
reenable test
horatiualmasan Jul 16, 2026
0f2b999
Add video event model (toVideoEvents, assignEventLanes)
horatiualmasan Jul 16, 2026
8e2426e
Add VideoEventTimeline component
horatiualmasan Jul 16, 2026
717ebf2
add AnnotationType.CLASSIFICATION
horatiualmasan Jul 17, 2026
b8ada23
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan Jul 17, 2026
30718ba
update
horatiualmasan Jul 17, 2026
d467a37
Merge branch 'main' into horatiu-lig-9807-show-event-bars-on-the-time…
horatiualmasan Jul 17, 2026
cf0ee5e
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.a' int…
horatiualmasan Jul 17, 2026
a604ba9
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.b' int…
horatiualmasan Jul 17, 2026
9563c27
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.c' int…
horatiualmasan Jul 17, 2026
07ad39d
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline.d' int…
horatiualmasan Jul 17, 2026
5f70a87
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan Jul 17, 2026
bf3e35a
Merge branch 'main' into horatiu-lig-9807-show-event-bars-on-the-time…
LeonardoRosaa Jul 21, 2026
1d685ce
add storybook
LeonardoRosaa Jul 21, 2026
8a8f869
prevent short bars from overflowing the track boundary
LeonardoRosaa Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
}}
/>
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};`}
Comment thread
LeonardoRosaa marked this conversation as resolved.
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>
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();
});
});
1 change: 1 addition & 0 deletions lightly_studio_view/src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export { default as Image } from '$lib/components/Image/Image.svelte';
export { default as VideoPreview } from '$lib/components/VideoPreview/VideoPreview.svelte';
export { default as VideoPlayer } from '$lib/components/VideoPlayer/VideoPlayer.svelte';
export { default as VideoControls } from '$lib/components/VideoControls/VideoControls.svelte';
export { default as VideoEventTimeline } from '$lib/components/VideoEventTimeline/VideoEventTimeline.svelte';
export { default as VideoSampleMetadata } from '$lib/components/VideoSampleMetadata/VideoSampleMetadata.svelte';
export { default as GridHeader } from '$lib/components/GridHeader/GridHeader.svelte';
export { default as DatasetGridHeader } from '$lib/components/DatasetGridHeader/DatasetGridHeader.svelte';
Expand Down
Loading