Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 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
2dc2a47
Add useVideoPlayback hook with tests
horatiualmasan Jul 16, 2026
7dede03
Wire custom control bar into VideoPlayer
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
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
0f2b999
Add video event model (toVideoEvents, assignEventLanes)
horatiualmasan Jul 16, 2026
8e2426e
Add VideoEventTimeline component
horatiualmasan Jul 16, 2026
cdbf3e5
Wire event bar into VideoPlayer, VideoControls and VideoDetails
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
becf8d5
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan Jul 17, 2026
21d02b9
update
horatiualmasan Jul 17, 2026
c56018e
Merge branch 'horatiu-lig-9807-show-event-bars-on-the-timeline-3-even…
horatiualmasan Jul 17, 2026
78259f4
Edit video events in the timeline
horatiualmasan Jul 17, 2026
52da034
fix
horatiualmasan Jul 17, 2026
d80d259
Merge branch 'main' into horatiu-lig-10189-edit-events
LeonardoRosaa Jul 21, 2026
9196b5f
log and show error message
LeonardoRosaa Jul 21, 2026
6a7bdfa
raise value error if start_time_s or end_time_s are not provided
LeonardoRosaa Jul 21, 2026
3ed9e89
format files
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
Expand Up @@ -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(
Expand All @@ -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
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -73,4 +76,5 @@
"update_annotation_label",
"update_bounding_box",
"update_segmentation_mask",
"update_temporal_span",
]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -34,4 +37,5 @@
"update_annotation_label",
"update_annotations",
"update_segmentation_mask",
"update_temporal_span",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -55,6 +57,13 @@ 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 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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if result is None:
raise ValueError("No updates provided for the annotation.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
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 { routeHelpers } from '$lib/routes';
import VideoFrameAnnotationItem, {
Expand All @@ -34,6 +36,30 @@
// 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
}
]);
} finally {
// Refetch either way so the timeline reflects the persisted span
// (or reverts the optimistic preview if the update failed).
onVideoUpdate();
}
}
Comment thread
LeonardoRosaa marked this conversation as resolved.

const {
currentFrame,
frames: videoFrames,
Expand Down Expand Up @@ -168,6 +194,8 @@
{startTimeS}
events={videoEvents}
durationS={video.duration_s ?? undefined}
editableEvents={$isEditingMode}
onEventResize={handleEventResize}
videoProps={{
muted: true,
class: 'object-contain',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading