diff --git a/lightly_studio/src/lightly_studio/api/routes/api/annotation.py b/lightly_studio/src/lightly_studio/api/routes/api/annotation.py index e1cfd6895e..d03c6dad0f 100644 --- a/lightly_studio/src/lightly_studio/api/routes/api/annotation.py +++ b/lightly_studio/src/lightly_studio/api/routes/api/annotation.py @@ -218,6 +218,8 @@ class AnnotationUpdateInput(BaseModel): label_name: str | None = None bounding_box: BoundingBoxCoordinates | None = None segmentation_mask: list[int] | None = None + start_time_s: float | None = None + end_time_s: float | None = None @annotations_router.put( @@ -241,6 +243,8 @@ def update_annotations( label_name=annotation_update_input.label_name, bounding_box=annotation_update_input.bounding_box, segmentation_mask=annotation_update_input.segmentation_mask, + start_time_s=annotation_update_input.start_time_s, + end_time_s=annotation_update_input.end_time_s, ) for annotation_update_input in annotation_update_inputs ], diff --git a/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/__init__.py b/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/__init__.py index e15f61c673..9b8615cf7c 100644 --- a/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/__init__.py +++ b/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/__init__.py @@ -50,6 +50,9 @@ from lightly_studio.resolvers.annotation_resolver.update_segmentation_mask import ( update_segmentation_mask, ) +from lightly_studio.resolvers.annotation_resolver.update_temporal_span import ( + update_temporal_span, +) __all__ = [ "AnnotationCrop", @@ -73,4 +76,5 @@ "update_annotation_label", "update_bounding_box", "update_segmentation_mask", + "update_temporal_span", ] diff --git a/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/update_temporal_span.py b/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/update_temporal_span.py new file mode 100644 index 0000000000..2cc3af3a9c --- /dev/null +++ b/lightly_studio/src/lightly_studio/resolvers/annotation_resolver/update_temporal_span.py @@ -0,0 +1,54 @@ +"""Module for updating the temporal span (start/end time) of an annotation.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session + +from lightly_studio.models.annotation.annotation_base import AnnotationBaseTable +from lightly_studio.resolvers import annotation_resolver + + +def update_temporal_span( + session: Session, + annotation_id: UUID, + start_time_s: float, + end_time_s: float, +) -> AnnotationBaseTable: + """Update the temporal span of an annotation. + + Args: + session: Database session for executing the operation. + annotation_id: UUID of the annotation to update. + start_time_s: New start time in seconds. + end_time_s: New end time in seconds. + + Returns: + The updated annotation with the new temporal span. + + Raises: + ValueError: If the annotation is not found, has no temporal span, or the + span is invalid. + """ + if start_time_s < 0: + raise ValueError("start_time_s must be non-negative.") + if start_time_s >= end_time_s: + raise ValueError("start_time_s must be less than end_time_s.") + + annotation = annotation_resolver.get_by_id(session=session, annotation_id=annotation_id) + if not annotation: + raise ValueError(f"Annotation with ID {annotation_id} not found.") + if annotation.temporal_span_details is None: + raise ValueError("Annotation does not have a temporal span to update.") + + try: + annotation.temporal_span_details.start_time_s = start_time_s + annotation.temporal_span_details.end_time_s = end_time_s + session.add(annotation.temporal_span_details) + session.commit() + session.refresh(annotation) + return annotation + except Exception: + session.rollback() + raise diff --git a/lightly_studio/src/lightly_studio/services/annotations_service/__init__.py b/lightly_studio/src/lightly_studio/services/annotations_service/__init__.py index b4af3bead0..afd849b594 100644 --- a/lightly_studio/src/lightly_studio/services/annotations_service/__init__.py +++ b/lightly_studio/src/lightly_studio/services/annotations_service/__init__.py @@ -24,6 +24,9 @@ from lightly_studio.services.annotations_service.update_segmentation_mask import ( update_segmentation_mask, ) +from lightly_studio.services.annotations_service.update_temporal_span import ( + update_temporal_span, +) __all__ = [ "create_annotation", @@ -34,4 +37,5 @@ "update_annotation_label", "update_annotations", "update_segmentation_mask", + "update_temporal_span", ] diff --git a/lightly_studio/src/lightly_studio/services/annotations_service/update_annotation.py b/lightly_studio/src/lightly_studio/services/annotations_service/update_annotation.py index cbc3024c15..470db83606 100644 --- a/lightly_studio/src/lightly_studio/services/annotations_service/update_annotation.py +++ b/lightly_studio/src/lightly_studio/services/annotations_service/update_annotation.py @@ -22,6 +22,8 @@ class AnnotationUpdate(BaseModel): label_name: str | None = None bounding_box: BoundingBoxCoordinates | None = None segmentation_mask: list[int] | None = None + start_time_s: float | None = None + end_time_s: float | None = None def update_annotation(session: Session, annotation_update: AnnotationUpdate) -> AnnotationBaseTable: @@ -55,6 +57,15 @@ def update_annotation(session: Session, annotation_update: AnnotationUpdate) -> annotation_id=annotation_update.annotation_id, segmentation_mask=annotation_update.segmentation_mask, ) + if (annotation_update.start_time_s is None) != (annotation_update.end_time_s is None): + raise ValueError("Both start_time_s and end_time_s must be provided together.") + if annotation_update.start_time_s is not None and annotation_update.end_time_s is not None: + result = annotations_service.update_temporal_span( + session=session, + annotation_id=annotation_update.annotation_id, + start_time_s=annotation_update.start_time_s, + end_time_s=annotation_update.end_time_s, + ) if result is None: raise ValueError("No updates provided for the annotation.") diff --git a/lightly_studio/src/lightly_studio/services/annotations_service/update_temporal_span.py b/lightly_studio/src/lightly_studio/services/annotations_service/update_temporal_span.py new file mode 100644 index 0000000000..f7cf2006df --- /dev/null +++ b/lightly_studio/src/lightly_studio/services/annotations_service/update_temporal_span.py @@ -0,0 +1,35 @@ +"""Update the temporal span (start/end time) of an annotation.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Session + +from lightly_studio.models.annotation.annotation_base import AnnotationBaseTable +from lightly_studio.resolvers import annotation_resolver + + +def update_temporal_span( + session: Session, + annotation_id: UUID, + start_time_s: float, + end_time_s: float, +) -> AnnotationBaseTable: + """Retrieve an annotation by its ID and update its temporal span. + + Args: + session: Database session. + annotation_id: The annotation ID to update. + start_time_s: The new start time in seconds. + end_time_s: The new end time in seconds. + + Returns: + The updated AnnotationBaseTable instance. + """ + return annotation_resolver.update_temporal_span( + session=session, + annotation_id=annotation_id, + start_time_s=start_time_s, + end_time_s=end_time_s, + ) diff --git a/lightly_studio/tests/resolvers/annotations/test_update_temporal_span.py b/lightly_studio/tests/resolvers/annotations/test_update_temporal_span.py new file mode 100644 index 0000000000..5f9258d441 --- /dev/null +++ b/lightly_studio/tests/resolvers/annotations/test_update_temporal_span.py @@ -0,0 +1,118 @@ +"""Tests for updating the temporal span of an annotation.""" + +from uuid import UUID + +import pytest +from sqlmodel import Session + +from lightly_studio.models.annotation.annotation_base import ( + AnnotationCreate, + AnnotationType, +) +from lightly_studio.models.collection import SampleType +from lightly_studio.resolvers import annotation_resolver +from tests.helpers_resolvers import create_annotation_label, create_collection, create_image + + +def _create_event_annotation( + db_session: Session, + collection_id: UUID, + start_time_s: float, + end_time_s: float, +) -> UUID: + label = create_annotation_label( + session=db_session, root_collection_id=collection_id, label_name="event" + ) + image = create_image( + session=db_session, + collection_id=collection_id, + file_path_abs="/path/to/sample.png", + ) + return annotation_resolver.create_many( + session=db_session, + parent_collection_id=collection_id, + annotations=[ + AnnotationCreate( + parent_sample_id=image.sample_id, + annotation_label_id=label.annotation_label_id, + annotation_type=AnnotationType.CLASSIFICATION, + start_time_s=start_time_s, + end_time_s=end_time_s, + ) + ], + )[0] + + +def test_update_temporal_span(db_session: Session) -> None: + collection = create_collection(session=db_session, sample_type=SampleType.IMAGE) + collection_id = collection.collection_id + + annotation_id = _create_event_annotation( + db_session, collection_id, start_time_s=1.0, end_time_s=5.0 + ) + + annotation = annotation_resolver.update_temporal_span( + session=db_session, + annotation_id=annotation_id, + start_time_s=2.5, + end_time_s=8.0, + ) + + assert annotation.sample_id == annotation_id + assert annotation.temporal_span_details is not None + assert annotation.temporal_span_details.start_time_s == 2.5 + assert annotation.temporal_span_details.end_time_s == 8.0 + + +def test_update_temporal_span__rejects_inverted_span(db_session: Session) -> None: + collection = create_collection(session=db_session, sample_type=SampleType.IMAGE) + collection_id = collection.collection_id + + annotation_id = _create_event_annotation( + db_session, collection_id, start_time_s=1.0, end_time_s=5.0 + ) + + with pytest.raises(ValueError, match=r"start_time_s must be less than end_time_s."): + annotation_resolver.update_temporal_span( + session=db_session, + annotation_id=annotation_id, + start_time_s=6.0, + end_time_s=5.0, + ) + + +def test_update_temporal_span__annotation_without_span(db_session: Session) -> None: + collection = create_collection(session=db_session, sample_type=SampleType.IMAGE) + collection_id = collection.collection_id + + label = create_annotation_label( + session=db_session, root_collection_id=collection_id, label_name="car" + ) + image = create_image( + session=db_session, + collection_id=collection_id, + file_path_abs="/path/to/sample.png", + ) + annotation_id = annotation_resolver.create_many( + session=db_session, + parent_collection_id=collection_id, + annotations=[ + AnnotationCreate( + parent_sample_id=image.sample_id, + annotation_label_id=label.annotation_label_id, + annotation_type=AnnotationType.OBJECT_DETECTION, + x=10, + y=10, + width=20, + height=20, + ) + ], + )[0] + + with pytest.raises(ValueError, match=r"does not have a temporal span"): + annotation_resolver.update_temporal_span( + session=db_session, + annotation_id=annotation_id, + start_time_s=1.0, + end_time_s=2.0, + ) diff --git a/lightly_studio/tests/services/annotations_service/test_update_annotation.py b/lightly_studio/tests/services/annotations_service/test_update_annotation.py index 8717a9e062..988d46e467 100644 --- a/lightly_studio/tests/services/annotations_service/test_update_annotation.py +++ b/lightly_studio/tests/services/annotations_service/test_update_annotation.py @@ -73,3 +73,68 @@ def test_update_annotation__raises_error_when_label_name_is_none( collection_id=collection_id, ), ) + + +def test_update_annotation__raises_error_when_only_start_time_s_is_provided( + db_session: Session, + collection_id: UUID, +) -> None: + annotation_id = UUID("12345678-1234-5678-1234-567812345678") + + with pytest.raises( + ValueError, match="Both start_time_s and end_time_s must be provided together" + ): + annotations_service.update_annotation( + db_session, + AnnotationUpdate( + annotation_id=annotation_id, + collection_id=collection_id, + start_time_s=1.0, + ), + ) + + +def test_update_annotation__raises_error_when_only_end_time_s_is_provided( + db_session: Session, + collection_id: UUID, +) -> None: + annotation_id = UUID("12345678-1234-5678-1234-567812345678") + + with pytest.raises( + ValueError, match="Both start_time_s and end_time_s must be provided together" + ): + annotations_service.update_annotation( + db_session, + AnnotationUpdate( + annotation_id=annotation_id, + collection_id=collection_id, + end_time_s=5.0, + ), + ) + + +def test_update_annotation__calls_update_temporal_span_when_both_times_provided( + db_session: Session, + collection_id: UUID, +) -> None: + annotation_id = UUID("12345678-1234-5678-1234-567812345678") + + with patch( + "lightly_studio.services.annotations_service.update_temporal_span" + ) as mock_update_temporal_span: + annotations_service.update_annotation( + db_session, + AnnotationUpdate( + annotation_id=annotation_id, + collection_id=collection_id, + start_time_s=1.0, + end_time_s=5.0, + ), + ) + + mock_update_temporal_span.assert_called_once_with( + session=db_session, + annotation_id=annotation_id, + start_time_s=1.0, + end_time_s=5.0, + ) diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte index 22efed2790..e86a72d3cd 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte @@ -9,12 +9,15 @@ VideoPlayer } from '$lib/components'; import { type FrameView, type SampleView, type VideoView } from '$lib/api/lightly_studio_local'; - import { getVideoURLById, toVideoEvents } from '$lib/utils'; + import { getVideoURLById, toVideoEvents, type VideoEvent } from '$lib/utils'; import VideoSampleMetadata from '../VideoSampleMetadata/VideoSampleMetadata.svelte'; import SampleDetailsCaptionSegment from '../SampleDetails/SampleDetailsCaptionsSegment/SampleDetailsCaptionSegment.svelte'; import { useVideoFrames } from '$lib/hooks/useVideoFrames/useVideoFrames'; import { useVideoFrameAnnotations } from '$lib/hooks/useVideoFrameAnnotations/useVideoFrameAnnotations'; + import { useGlobalStorage } from '$lib/hooks/useGlobalStorage'; + import { useUpdateAnnotationsMutation } from '$lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation'; import { onMount } from 'svelte'; + import { toast } from 'svelte-sonner'; import { routeHelpers } from '$lib/routes'; import VideoFrameAnnotationItem, { type PrerenderedAnnotation @@ -34,6 +37,33 @@ // Imported events: classification annotations on the video carrying a time span. const videoEvents = $derived(toVideoEvents(video.sample.annotations ?? [])); + // Reuse the global "Edit annotations" toggle to enable event editing. + const { isEditingMode } = useGlobalStorage(); + + // Events live on the video's own collection. + const eventCollectionId = (video.sample as SampleView)?.collection_id ?? datasetId; + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: eventCollectionId }); + + async function handleEventResize(event: VideoEvent, startTimeS: number, endTimeS: number) { + try { + await updateAnnotations([ + { + annotation_id: event.id, + collection_id: event.annotationCollectionId, + start_time_s: startTimeS, + end_time_s: endTimeS + } + ]); + } catch (error) { + console.error('Failed to save event changes:', error); + toast.error('Failed to save event changes. Please try again.'); + } finally { + // Refetch either way so the timeline reflects the persisted span + // (or reverts the optimistic preview if the update failed). + onVideoUpdate(); + } + } + const { currentFrame, frames: videoFrames, @@ -168,6 +198,8 @@ {startTimeS} events={videoEvents} durationS={video.duration_s ?? undefined} + editableEvents={$isEditingMode} + onEventResize={handleEventResize} videoProps={{ muted: true, class: 'object-contain', diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts index 8fea9b7489..4a5c5ec64f 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.test.ts @@ -43,6 +43,11 @@ vi.mock('$lib/hooks/useVideoFrames/useVideoFrames', () => ({ useVideoFrames: vi. vi.mock('$lib/hooks/useVideoFrameAnnotations/useVideoFrameAnnotations', () => ({ useVideoFrameAnnotations: vi.fn(() => new Map()) })); +// Event editing needs a QueryClient; stub the mutation hook so the test runs +// without a QueryClientProvider. +vi.mock('$lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation', () => ({ + useUpdateAnnotationsMutation: vi.fn(() => ({ updateAnnotations: vi.fn() })) +})); import VideoDetails from './VideoDetails.svelte'; diff --git a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.svelte b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.svelte index 6f3a16b553..dae5b4f107 100644 --- a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.svelte +++ b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.svelte @@ -7,6 +7,11 @@ * Clicking a bar seeks the associated player via {@link onSeek}, and an * optional playhead marks the current playback position. * + * When `editable` is set, each bar grows drag handles on its start/end edges + * so the event's time span can be adjusted; committed edits are reported via + * {@link onResize}. Edits are previewed optimistically until the parent feeds + * back updated `events`. + * * The component is presentation-only: pass in already-derived * {@link VideoEvent}s (see `toVideoEvents`) so it can be reused for any * temporal-annotation source. @@ -33,6 +38,10 @@ currentTimeS?: number; /** Called with the target time in seconds when a bar is clicked. */ onSeek?: (timeS: number) => void; + /** When true, event edges can be dragged to change their start/end time. */ + editable?: boolean; + /** Called with the new span when an event edge finishes being edited. */ + onResize?: (event: VideoEvent, startTimeS: number, endTimeS: number) => void; /** Heading shown above the timeline. */ title?: string; /** Whether to render the title/count header above the track. */ @@ -46,6 +55,8 @@ durationS, currentTimeS = 0, onSeek, + editable = false, + onResize, title = 'Events', showHeader = true, class: className @@ -55,6 +66,11 @@ 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; + // Smallest span an edit may collapse an event to. + const MIN_EVENT_DURATION_S = 0.1; + + type Span = { startTimeS: number; endTimeS: number }; + type Edge = 'start' | 'end'; const lanes = $derived(assignEventLanes(events)); const trackHeightPx = $derived( @@ -62,17 +78,87 @@ Math.max(0, lanes.laneCount - 1) * LANE_GAP_PX ); + let trackEl: HTMLDivElement | null = $state(null); + // Optimistic overrides while an edit is in flight, keyed by event id. + let pendingEdits = $state>({}); + let dragging = $state<{ id: string; edge: Edge } | null>(null); + + // Drop optimistic overrides once the parent feeds back fresh events. + $effect(() => { + void events; + pendingEdits = {}; + }); + const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); const toPercent = (timeS: number) => durationS > 0 ? clampPercent((timeS / durationS) * 100) : 0; + function effectiveSpan(event: VideoEvent): Span { + return pendingEdits[event.id] ?? { startTimeS: event.startTimeS, endTimeS: event.endTimeS }; + } + + function clampSpan(span: Span, edge: Edge, timeS: number): Span { + if (edge === 'start') { + return { + startTimeS: Math.max(0, Math.min(timeS, span.endTimeS - MIN_EVENT_DURATION_S)), + endTimeS: span.endTimeS + }; + } + return { + startTimeS: span.startTimeS, + endTimeS: Math.min(durationS, Math.max(timeS, span.startTimeS + MIN_EVENT_DURATION_S)) + }; + } + + function timeFromClientX(clientX: number): number { + if (!trackEl || durationS <= 0) return 0; + const rect = trackEl.getBoundingClientRect(); + const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; + return Math.min(durationS, Math.max(0, ratio * durationS)); + } + 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')}`; } + + const edgeTime = (span: Span, edge: Edge) => + edge === 'start' ? span.startTimeS : span.endTimeS; + + function startResize(pointerEvent: PointerEvent, event: VideoEvent, edge: Edge) { + if (durationS <= 0) return; + pointerEvent.stopPropagation(); + pointerEvent.preventDefault(); + dragging = { id: event.id, edge }; + (pointerEvent.currentTarget as HTMLElement).setPointerCapture(pointerEvent.pointerId); + const seed = effectiveSpan(event); + pendingEdits = { ...pendingEdits, [event.id]: seed }; + // Sync playback to the grabbed edge so the frame at that time is visible. + onSeek?.(edgeTime(seed, edge)); + } + + function moveResize(pointerEvent: PointerEvent, event: VideoEvent) { + if (!dragging || dragging.id !== event.id) return; + const next = clampSpan( + effectiveSpan(event), + dragging.edge, + timeFromClientX(pointerEvent.clientX) + ); + pendingEdits = { ...pendingEdits, [event.id]: next }; + // Follow the dragged edge with the play position for live visual feedback. + onSeek?.(edgeTime(next, dragging.edge)); + } + + function endResize(pointerEvent: PointerEvent, event: VideoEvent) { + if (!dragging || dragging.id !== event.id) return; + (pointerEvent.currentTarget as HTMLElement).releasePointerCapture(pointerEvent.pointerId); + dragging = null; + const span = pendingEdits[event.id]; + if (span) onResize?.(event, span.startTimeS, span.endTimeS); + }
@@ -87,18 +173,20 @@

No events for this video.

{:else}
{#each lanes.events as event (event.id)} + {@const span = effectiveSpan(event)} + {@const leftPercent = toPercent(span.startTimeS)} {@const widthPercent = Math.max( MIN_BAR_WIDTH_PERCENT, - toPercent(event.endTimeS) - toPercent(event.startTimeS) + toPercent(span.endTimeS) - leftPercent )} - {@const leftPercent = Math.min(toPercent(event.startTimeS), 100 - widthPercent)} - {@const timeRange = `${formatTime(event.startTimeS)}–${formatTime(event.endTimeS)}`} + {@const timeRange = `${formatTime(span.startTimeS)}–${formatTime(span.endTimeS)}`}
onSeek?.(event.startTimeS)} + onclick={() => onSeek?.(span.startTimeS)} > {event.label} + + {#if editable} + {#each ['start', 'end'] as const as edge (edge)} +
startResize(e, event, edge)} + onpointermove={(e) => moveResize(e, event)} + onpointerup={(e) => endResize(e, event)} + >
+ {/each} + {/if}
{/each} diff --git a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.test.ts b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.test.ts index 0eff355e60..f38c993ee0 100644 --- a/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.test.ts +++ b/lightly_studio_view/src/lib/components/VideoEventTimeline/VideoEventTimeline.test.ts @@ -79,4 +79,51 @@ describe('VideoEventTimeline', () => { expect(queryByText('Events')).toBeFalsy(); }); + + it('renders drag handles only when editable', () => { + const { queryAllByRole, rerender } = render(VideoEventTimeline, { + props: { events: [makeEvent({ id: 'a' })], durationS: 10 } + }); + expect(queryAllByRole('slider')).toHaveLength(0); + + rerender({ events: [makeEvent({ id: 'a' })], durationS: 10, editable: true }); + // A start and an end handle per event. + expect(queryAllByRole('slider')).toHaveLength(2); + }); + + it('reports a new span after dragging an edge handle', () => { + const onResize = vi.fn(); + const onSeek = vi.fn(); + const event = makeEvent({ id: 'a', startTimeS: 4, endTimeS: 8 }); + const { getByRole, getAllByRole } = render(VideoEventTimeline, { + props: { events: [event], durationS: 20, editable: true, onResize, onSeek } + }); + + // 200px-wide track over a 20s video → 10px per second. + vi.spyOn(getByRole('group'), 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 200, + top: 0, + height: 22, + right: 200, + bottom: 22, + x: 0, + y: 0, + toJSON: () => ({}) + } as DOMRect); + + const startHandle = getAllByRole('slider')[0]; + startHandle.setPointerCapture = vi.fn(); + startHandle.releasePointerCapture = vi.fn(); + + // jsdom drops clientX on synthetic PointerEvents, so use MouseEvent. + startHandle.dispatchEvent(new MouseEvent('pointerdown', { clientX: 40, bubbles: true })); + startHandle.dispatchEvent(new MouseEvent('pointermove', { clientX: 20, bubbles: true })); + startHandle.dispatchEvent(new MouseEvent('pointerup', { clientX: 20, bubbles: true })); + + // Dragged start handle to 20px → 2s; end stays at 8s. + expect(onResize).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }), 2, 8); + // Playback follows the dragged edge so the frame at 2s is visible. + expect(onSeek).toHaveBeenLastCalledWith(2); + }); }); diff --git a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte index 29826c21c7..ead746ffc3 100644 --- a/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/lightly_studio_view/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -69,6 +69,12 @@ * loads. */ durationS?: number; + + /** Allow dragging event edges on the event bar to edit their time span. */ + editableEvents?: boolean; + + /** Called with the new span when an event edge finishes being edited. */ + onEventResize?: (event: VideoEvent, startTimeS: number, endTimeS: number) => void; } let { @@ -78,7 +84,9 @@ hoverClass = 'outline outline-2 outline-blue-500', startTimeS = 0, events = [], - durationS + durationS, + editableEvents = false, + onEventResize }: VideoPlayerProps = $props(); const defaultVideoProps: HTMLVideoAttributes = { @@ -209,6 +217,8 @@ durationS={effectiveDurationS} currentTimeS={playback.currentTimeS} onSeek={playback.seekTo} + editable={editableEvents} + onResize={onEventResize} showHeader={false} /> {/if}