Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions client/dive-common/components/Review/ReviewCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
activateOnHover: {
type: Boolean,
default: false,
},
cycleIntervalMs: {
type: Number,
default: 400,
Expand Down Expand Up @@ -152,6 +156,8 @@ export default defineComponent({
setup(props, { emit }) {
/** Chips currently in edit mode, to outline the whole entry. */
const editingCount = ref(0);
const hovered = ref(false);
const cycling = computed(() => props.animate && (!props.activateOnHover || hovered.value));

const viewList = computed<ReviewCellView[]>(() => props.views ?? [{
key: 'single',
Expand Down Expand Up @@ -196,7 +202,7 @@ export default defineComponent({
}

function syncSharedTimer() {
const shouldRun = shared.value && props.animate && sequenceLength.value > 1
const shouldRun = shared.value && cycling.value && sequenceLength.value > 1
&& !sharedPaused.value && editingCount.value === 0;
if (shouldRun && timer === null) {
timer = window.setInterval(() => advanceShared(1), props.cycleIntervalMs);
Expand All @@ -206,7 +212,7 @@ export default defineComponent({
}
}
watch(
[shared, () => props.animate, sequenceLength, sharedPaused, editingCount, () => props.cycleIntervalMs],
[shared, cycling, sequenceLength, sharedPaused, editingCount, () => props.cycleIntervalMs],
() => {
if (timer !== null) {
window.clearInterval(timer);
Expand All @@ -233,9 +239,16 @@ export default defineComponent({
if (next !== props.type) emit('assign', next);
}

function onHoverChange(active: boolean) {
hovered.value = active;
emit('hover-change', active);
}

return {
viewList,
editingCount,
hovered,
onHoverChange,
commitType,
shared,
sharedSlot,
Expand All @@ -258,6 +271,8 @@ export default defineComponent({
'cell-negative': highlight === 'negative',
}"
:style="{ '--cell-scale': scale }"
@mouseenter="onHoverChange(true)"
@mouseleave="onHoverChange(false)"
>
<div class="cell-views">
<ReviewChip
Expand All @@ -272,6 +287,7 @@ export default defineComponent({
:frame-count="view.frameCount"
:label="view.label"
:animate="animate"
:activate-on-hover="activateOnHover"
:cycle-interval-ms="cycleIntervalMs"
:type="type"
:confidence="index === 0 ? confidence : null"
Expand Down
26 changes: 20 additions & 6 deletions client/dive-common/components/Review/ReviewChip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export default defineComponent({
type: Boolean,
default: true,
},
/** When true with {@link animate}, cycling runs only while the pointer is over the chip. */
activateOnHover: {
type: Boolean,
default: false,
},
cycleIntervalMs: {
type: Number,
default: 400,
Expand Down Expand Up @@ -196,6 +201,8 @@ export default defineComponent({
const ownView = ref<ChipView>({ scale: 1, x: 0, y: 0 });
const view = computed<ChipView>(() => props.controlledView ?? ownView.value);
const wrap = ref<HTMLElement | null>(null);
const hovered = ref(false);
const cycling = computed(() => props.animate && (!props.activateOnHover || hovered.value));
let pan: {
startX: number; startY: number; originX: number; originY: number; pointerId: number; capture: Element | null;
} | null = null;
Expand All @@ -210,18 +217,18 @@ export default defineComponent({
}, { immediate: true });

/** Sequence slot on screen; 0 while only the primary chip is available. */
const currentSlot = computed(() => (hasSequence.value && props.animate ? cycleIndex.value : 0));
const currentSlot = computed(() => (hasSequence.value && cycling.value ? cycleIndex.value : 0));

const displaySrc = computed(() => {
if (hasSequence.value && props.animate) {
if (hasSequence.value && cycling.value) {
return props.srcs?.[cycleIndex.value] ?? props.src;
}
return props.src;
});

/** Transform of the chip on screen (the primary's when a slot is still loading). */
const displayTransform = computed<ChipTransform | null>(() => {
if (hasSequence.value && props.animate) {
if (hasSequence.value && cycling.value) {
const slotSrc = props.srcs?.[cycleIndex.value];
if (slotSrc) return props.transforms?.[cycleIndex.value] ?? null;
}
Expand Down Expand Up @@ -253,7 +260,7 @@ export default defineComponent({
}

function syncTimer() {
const shouldRun = props.animate && hasSequence.value && !editing.value && !paused.value
const shouldRun = cycling.value && hasSequence.value && !editing.value && !paused.value
&& !controlled.value;
if (shouldRun && timer === null) {
timer = window.setInterval(advance, props.cycleIntervalMs);
Expand All @@ -263,7 +270,7 @@ export default defineComponent({
if (!editing.value && !paused.value) cycleIndex.value = 0;
}
}
watch([() => props.animate, () => props.srcs, () => props.cycleIntervalMs, editing, paused], () => {
watch([cycling, () => props.srcs, () => props.cycleIntervalMs, editing, paused], () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
Expand Down Expand Up @@ -722,6 +729,7 @@ export default defineComponent({
onWrapPointerUp,
missing,
addBox,
hovered,
};
},
});
Expand All @@ -731,8 +739,10 @@ export default defineComponent({
<div
ref="wrap"
class="cell-image-wrap"
:class="{ 'chip-editing': editing }"
:class="{ 'chip-editing': editing, 'chip-hovered': hovered }"
:aria-label="title"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@dblclick="onImageDoubleClick"
@contextmenu="onContextMenu"
@wheel="onWheel"
Expand Down Expand Up @@ -1109,6 +1119,10 @@ export default defineComponent({
cursor: pointer;
min-width: 0;
flex: 1 1 0;

&.chip-hovered {
box-shadow: inset 0 0 0 2px #90caf9;
}
}

.cell-image-wrap.chip-editing {
Expand Down
25 changes: 23 additions & 2 deletions client/dive-common/components/Review/ReviewPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { createReviewService, provideReview } from 'dive-common/use/useReview';
import { useReviewGrid } from 'dive-common/review/useReviewGrid';
import { cellScaleFor } from 'dive-common/review/gridSettings';
import { cycleIntervalFor } from 'dive-common/review/reviewItems';
import { chipCycleIntervalMs } from 'dive-common/review/reviewItems';
import {
holdReviewSession, sessionKey, shouldResume, takeReviewSession,
} from 'dive-common/review/reviewSession';
Expand Down Expand Up @@ -90,6 +90,7 @@
const opening = ref(!hasReady && initialIds.length > 0);
const pageTypeInput = ref('');
const showSettings = ref(false);
const hoveredEntryKeys = ref(new Set<string>());
const typeField = ref<{ isMenuActive: boolean; activateMenu(): void; blur(): void } | null>(null);

/** The type field's arrow opens its list, and closes it again on a second press. */
Expand Down Expand Up @@ -117,7 +118,18 @@
outline: '',
// Deleting or editing an entry keeps the page; only a new query resets it.
retainPage: true,
activateOnHover: computed(() => review.settings.activateOnHover),
hoverEntryKeys: hoveredEntryKeys,
entryKeyOf: (entry) => entry.key,
});

function setEntryHovered(key: string, hovered: boolean) {
const next = new Set(hoveredEntryKeys.value);
if (hovered) next.add(key);
else next.delete(key);
hoveredEntryKeys.value = next;
grid.ensureVisible();
}
watch(review.queryGeneration, () => grid.goToPage(0));

const showDatasetNames = computed(() => review.datasets.value.length > 1);
Expand Down Expand Up @@ -163,7 +175,12 @@
title: `${review.datasetName(parent)} · track ${item.trackId} · frame ${item.primary.frame}`,
subtitle: subtitleBits.join(' · '),
attributeText,
cycleIntervalMs: cycleIntervalFor(item.frames, fps, review.grid.cycleIntervalMs),
cycleIntervalMs: chipCycleIntervalMs(
item.frames,
fps,
review.settings.playbackFps,
review.grid.cycleIntervalMs,
),
};
});
});
Expand Down Expand Up @@ -424,6 +441,7 @@
countLabel,
pageTypeInput,
showSettings,
setEntryHovered,
setView,
openItem,
openDataset,
Expand Down Expand Up @@ -806,16 +824,18 @@
:key="cell.entry.key"
:views="cell.views"
:animate="true"
:activate-on-hover="review.settings.activateOnHover"
:cycle-interval-ms="cell.cycleIntervalMs"
@hover-change="setEntryHovered(cell.entry.key, $event)"
:scale="cellScale"

Check warning on line 830 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":scale" should go before "@hover-change"

Check warning on line 830 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":scale" should go before "@hover-change"
:color="review.colorFor(cell.type)"

Check warning on line 831 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":color" should go before "@hover-change"

Check warning on line 831 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":color" should go before "@hover-change"
:type="cell.type"

Check warning on line 832 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":type" should go before "@hover-change"

Check warning on line 832 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":type" should go before "@hover-change"
:confidence="cell.confidence"

Check warning on line 833 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":confidence" should go before "@hover-change"

Check warning on line 833 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":confidence" should go before "@hover-change"
:pending="cell.pending"

Check warning on line 834 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":pending" should go before "@hover-change"

Check warning on line 834 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":pending" should go before "@hover-change"
:title="cell.title"

Check warning on line 835 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":title" should go before "@hover-change"

Check warning on line 835 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":title" should go before "@hover-change"
:subtitle="cell.subtitle"

Check warning on line 836 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":subtitle" should go before "@hover-change"

Check warning on line 836 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":subtitle" should go before "@hover-change"
:attribute-text="cell.attributeText"

Check warning on line 837 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":attribute-text" should go before "@hover-change"

Check warning on line 837 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":attribute-text" should go before "@hover-change"
:type-options="review.knownTypes.value"

Check warning on line 838 in client/dive-common/components/Review/ReviewPage.vue

View workflow job for this annotation

GitHub Actions / Client Tests (web)

Attribute ":type-options" should go before "@hover-change"
@assign="assignEntryType(cell.entry, $event)"
@accept="acceptEntry(cell.entry)"
@delete="deleteEntry(cell.entry)"
Expand All @@ -829,6 +849,7 @@

<UserSettingsDialog
:value="showSettings"
:review-settings="review.settings"
@input="showSettings = $event"
/>

Expand Down
37 changes: 36 additions & 1 deletion client/dive-common/components/UserSettingsDialog.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, PropType } from 'vue';
import { clientSettings } from 'dive-common/store/settings';
import isDesktopRuntime from 'dive-common/isDesktopRuntime';
import type { GlobalStyleSettings } from 'dive-common/apispec';
import type { ReviewSettings } from 'dive-common/review/types';
import SavedStylesEditor from './Types/SavedStylesEditor.vue';

export default defineComponent({
Expand All @@ -13,6 +14,11 @@ export default defineComponent({
type: Boolean,
required: true,
},
/** When set, shows review chip-grid options at the top of the dialog. */
reviewSettings: {
type: Object as PropType<ReviewSettings | null>,
default: null,
},
},
setup(_, { emit }) {
const colorScopeItems = [
Expand Down Expand Up @@ -41,6 +47,35 @@ export default defineComponent({
<v-card>
<v-card-title>User Settings</v-card-title>
<v-card-text>
<template v-if="reviewSettings">
<div class="text-subtitle-2 mb-2">
Review
</div>
<v-switch
v-model="reviewSettings.activateOnHover"
color="primary"
class="my-0"
label="Load and animate extra frames on hover"
hint="When on, the first frame of every chip still loads; additional track frames download and cycle only while the pointer is over the entry."
persistent-hint
/>
<v-text-field
v-model.number="reviewSettings.playbackFps"
color="primary"
class="my-0 mt-3"
type="number"
min="0"
max="60"
step="1"
label="Chip playback (frames per second)"
hint="0 = real-time from each dataset's frame rate and the spacing between sampled keyframes."
persistent-hint
dense
outlined
/>
<v-divider class="my-4" />
</template>

<v-select
v-model="clientSettings.typeSettings.colorScope"
:items="colorScopeItems"
Expand Down
19 changes: 19 additions & 0 deletions client/dive-common/review/reviewItems.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildReviewItems,
collectAttributeKeys,
collectTypes,
chipCycleIntervalMs,
cycleIntervalFor,
frameGeometry,
groupReviewItems,
Expand Down Expand Up @@ -201,6 +202,24 @@ describe('frameGeometry', () => {
});
});

describe('chipCycleIntervalMs', () => {
const frames = [0, 1, 2, 3].map((frame) => ({ frame, bounds: [0, 0, 1, 1] as [number, number, number, number] }));

it('uses a fixed rate when playback fps is set', () => {
expect(chipCycleIntervalMs(frames, 10, 10, 400)).toBe(100);
expect(chipCycleIntervalMs(frames, 10, 4, 400)).toBe(250);
});

it('falls back to real-time when playback fps is zero', () => {
expect(chipCycleIntervalMs(frames, 10, 0, 400)).toBe(100);
});

it('clamps playback fps before computing the interval', () => {
expect(chipCycleIntervalMs(frames, 10, 120, 400)).toBe(33);
expect(chipCycleIntervalMs(frames, 10, -3, 400)).toBe(100);
});
});

describe('cycleIntervalFor', () => {
it('plays consecutive frames at the dataset rate and sparse samples proportionally slower', () => {
const consecutive = [0, 1, 2, 3].map((frame) => ({ frame, bounds: [0, 0, 1, 1] as [number, number, number, number] }));
Expand Down
23 changes: 21 additions & 2 deletions client/dive-common/review/reviewItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type { StringKeyObject } from 'vue-media-annotator/BaseAnnotation';
import type { Attribute } from 'vue-media-annotator/use/AttributeTypes';
import { compareTypeNames } from 'dive-common/typeHierarchy';
import type { RectBounds } from 'vue-media-annotator/utils';
import type {
ReviewEntry, ReviewFrameGeometry, ReviewFrameRef, ReviewItem, ReviewPolygon, ReviewQuery, ReviewSortOrder,
import {
REVIEW_PLAYBACK_FPS_LIMITS,
type ReviewEntry, type ReviewFrameGeometry, type ReviewFrameRef, type ReviewItem, type ReviewPolygon, type ReviewQuery, type ReviewSortOrder,
} from './types';

function isPoint(value: unknown): value is [number, number] {
Expand Down Expand Up @@ -87,6 +88,24 @@ export function cycleIntervalFor(frames: readonly ReviewFrameRef[], fps: number,
return Math.min(2000, Math.max(33, Math.round((stride / fps) * 1000)));
}

/** Milliseconds between cycled chip frames from review playback settings. */
export function chipCycleIntervalMs(
frames: readonly ReviewFrameRef[],
datasetFps: number,
playbackFps: number,
fallbackMs: number,
): number {
const [minFps, maxFps] = REVIEW_PLAYBACK_FPS_LIMITS.fps;
const rawFps = Number(playbackFps);
const fps = Number.isFinite(rawFps)
? Math.min(maxFps, Math.max(minFps, Math.round(rawFps)))
: 0;
if (fps > 0) {
return Math.min(2000, Math.max(33, Math.round(1000 / fps)));
}
return cycleIntervalFor(frames, datasetFps, fallbackMs);
}

/** The pair a type query matches on, or null when the track does not qualify. */
export function matchTypePair(
pairs: readonly (readonly [string, number])[],
Expand Down
23 changes: 23 additions & 0 deletions client/dive-common/review/reviewSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { normalizeReviewSettings } from './reviewSettings';

describe('normalizeReviewSettings', () => {
it('defaults activate on hover and clamps playback fps', () => {
expect(normalizeReviewSettings({ activateOnHover: true, playbackFps: 1 })).toEqual({
activateOnHover: true,
playbackFps: 1,
});
expect(normalizeReviewSettings({ activateOnHover: false, playbackFps: 4.6 })).toEqual({
activateOnHover: false,
playbackFps: 5,
});
expect(normalizeReviewSettings({ activateOnHover: undefined as unknown as boolean, playbackFps: 120 })).toEqual({
activateOnHover: true,
playbackFps: 60,
});
expect(normalizeReviewSettings({ activateOnHover: true, playbackFps: Number.NaN })).toEqual({
activateOnHover: true,
playbackFps: 1,
});
});
});
Loading
Loading