Skip to content

Commit d0b1d6c

Browse files
committed
Merge remote-tracking branch 'origin/develop' into fb-fit-306
2 parents d4139c2 + fbcebd2 commit d0b1d6c

File tree

6 files changed

+249
-20
lines changed

6 files changed

+249
-20
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Generated by Django 5.1.10 on 2025-06-24 21:19
2+
import logging
3+
4+
from django.db import migrations
5+
from django.db.models import Count
6+
from core.redis import start_job_async_or_sync
7+
from core.models import AsyncMigrationStatus
8+
from data_manager.models import FilterGroup, Filter
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
migration_name = '0013_cleanup_inconsistent_filtergroup_20250624_2119'
14+
15+
16+
def cleanup_inconsistent_filtergroup():
17+
"""
18+
Grabs filter groups related to more than one view.
19+
Creates a new filter group and filters for each view based on the filter group, so filter groups aren't shared.
20+
"""
21+
22+
migration, created = AsyncMigrationStatus.objects.get_or_create(name=migration_name, defaults={'status': AsyncMigrationStatus.STATUS_STARTED})
23+
if not created:
24+
return # migration already done or in progress
25+
26+
migration.meta['project_ids'] = []
27+
28+
filter_groups = FilterGroup.objects.annotate(view_count=Count('view')).filter(view_count__gt=1)
29+
30+
for filter_group in filter_groups:
31+
first_view = filter_group.view_set.first()
32+
33+
for view in filter_group.view_set.all():
34+
migration.meta['project_ids'].append(view.project_id)
35+
migration.save(update_fields=['meta'])
36+
37+
if view == first_view: # skip first view
38+
continue
39+
40+
logger.info(f'Creating new filter group for view {view.id} for project {view.project_id}')
41+
# create new filter group and filters for the view
42+
new_filter_group = FilterGroup.objects.create(conjunction=filter_group.conjunction)
43+
for filter in filter_group.filters.all():
44+
new_filter = Filter.objects.create(
45+
index=filter.index,
46+
column=filter.column,
47+
type=filter.type,
48+
operator=filter.operator,
49+
value=filter.value,
50+
)
51+
new_filter_group.filters.add(new_filter)
52+
view.filter_group = new_filter_group
53+
view.save(update_fields=['filter_group'])
54+
55+
logger.info(f'Created new filter group {new_filter_group.id} for view {view.id} for project {view.project_id}')
56+
57+
migration.status = AsyncMigrationStatus.STATUS_FINISHED
58+
migration.save(update_fields=['status'])
59+
60+
def forward(apps, schema_editor):
61+
start_job_async_or_sync(cleanup_inconsistent_filtergroup, queue_name='low', job_timeout=60 * 60 * 24) # 24 hours
62+
63+
def backward(apps, schema_editor):
64+
AsyncMigrationStatus = apps.get_model('core', 'AsyncMigrationStatus')
65+
AsyncMigrationStatus.objects.filter(name=migration_name).delete()
66+
67+
68+
class Migration(migrations.Migration):
69+
atomic = False
70+
71+
dependencies = [
72+
("data_manager", "0012_alter_view_user"),
73+
]
74+
75+
operations = [
76+
migrations.RunPython(forward, backward),
77+
]

label_studio/data_manager/tests/migrations/__init__.py

Whitespace-only changes.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from django.test import TestCase
2+
from importlib import import_module, reload
3+
4+
from projects.tests.factories import ProjectFactory
5+
from data_manager.models import View, FilterGroup, Filter
6+
from core.models import AsyncMigrationStatus
7+
8+
9+
class TestCleanupInconsistentFiltergroupMigration(TestCase):
10+
11+
def setUp(self):
12+
# Filter group 1
13+
self.filter_group_1 = FilterGroup.objects.create(conjunction='and')
14+
self.filter_1 = Filter.objects.create(index=0, column='id', type='number', operator='eq', value=1)
15+
self.filter_2 = Filter.objects.create(index=1, column='id', type='number', operator='eq', value=2)
16+
self.filter_group_1.filters.add(self.filter_1)
17+
self.filter_group_1.filters.add(self.filter_2)
18+
19+
# Filter group 2
20+
self.filter_group_2 = FilterGroup.objects.create(conjunction='or')
21+
self.filter_3 = Filter.objects.create(index=0, column='id', type='number', operator='eq', value=1)
22+
self.filter_4 = Filter.objects.create(index=1, column='id', type='number', operator='eq', value=2)
23+
self.filter_group_2.filters.add(self.filter_3)
24+
self.filter_group_2.filters.add(self.filter_4)
25+
26+
27+
# Project 1
28+
self.project_1 = ProjectFactory()
29+
self.view_1 = View.objects.create(project=self.project_1, filter_group=self.filter_group_1)
30+
self.view_2 = View.objects.create(project=self.project_1, filter_group=self.filter_group_2)
31+
32+
# Project 2
33+
self.project_2 = ProjectFactory()
34+
# These views don't have their own filter group.
35+
self.view_3 = View.objects.create(project=self.project_2, filter_group=self.filter_group_1)
36+
self.view_4 = View.objects.create(project=self.project_2, filter_group=self.filter_group_2)
37+
38+
# Project 3
39+
self.project_3 = ProjectFactory()
40+
# This view doesn't have its own filter group.
41+
self.view_5 = View.objects.create(project=self.project_3, filter_group=self.filter_group_1)
42+
43+
44+
# Unaffected project, filters and views
45+
self.project_unaffected = ProjectFactory()
46+
self.filter_group_unaffected = FilterGroup.objects.create(conjunction='and')
47+
self.filter_unaffected = Filter.objects.create(index=0, column='id', type='number', operator='eq', value=1)
48+
self.filter_group_unaffected.filters.add(self.filter_unaffected)
49+
self.view_unaffected = View.objects.create(project=self.project_unaffected, filter_group=self.filter_group_unaffected)
50+
51+
def _assert_equivalent_filter_group(self, filter_group_1, filter_group_2):
52+
assert filter_group_1.conjunction == filter_group_2.conjunction
53+
54+
# Check both filter groups have same number of filters
55+
assert filter_group_1.filters.count() == filter_group_2.filters.count()
56+
57+
# Get filters sorted by index to compare in order
58+
filters_1 = filter_group_1.filters.order_by('index')
59+
filters_2 = filter_group_2.filters.order_by('index')
60+
61+
# Compare each filter's attributes
62+
for f1, f2 in zip(filters_1, filters_2):
63+
assert f1.column == f2.column
64+
assert f1.type == f2.type
65+
assert f1.operator == f2.operator
66+
assert f1.value == f2.value
67+
assert f1.index == f2.index
68+
69+
def test_migration(self):
70+
AsyncMigrationStatus.objects.all().delete() # cleanup migrations run when creating test database
71+
assert AsyncMigrationStatus.objects.filter(name='0013_cleanup_inconsistent_filtergroup_20250624_2119').count() == 0
72+
73+
# Assert initial state
74+
assert FilterGroup.objects.count() == 3
75+
assert Filter.objects.count() == 5
76+
77+
# Run migration
78+
module = import_module('data_manager.migrations.0013_cleanup_inconsistent_filtergroup_20250624_2119')
79+
reload(module)
80+
module.cleanup_inconsistent_filtergroup()
81+
82+
migration = AsyncMigrationStatus.objects.get(name='0013_cleanup_inconsistent_filtergroup_20250624_2119')
83+
assert migration.status == AsyncMigrationStatus.STATUS_FINISHED
84+
assert set(migration.meta['project_ids']) == set([self.project_1.id, self.project_2.id, self.project_3.id])
85+
86+
# Assert final state
87+
assert FilterGroup.objects.count() == 6
88+
assert Filter.objects.count() == 11
89+
90+
# Assert filter groups are equivalent for views that shared filter_group_1
91+
self.view_1.refresh_from_db()
92+
self.view_2.refresh_from_db()
93+
self.view_3.refresh_from_db()
94+
self.view_4.refresh_from_db()
95+
self.view_5.refresh_from_db()
96+
97+
# View 1 keeps original filter group
98+
assert self.view_1.filter_group == self.filter_group_1
99+
# View 2 keeps original filter group
100+
assert self.view_2.filter_group == self.filter_group_2
101+
# View 3 gets new equivalent filter group
102+
assert self.view_3.filter_group != self.filter_group_1
103+
self._assert_equivalent_filter_group(self.view_3.filter_group, self.filter_group_1)
104+
# View 4 gets new equivalent filter group
105+
assert self.view_4.filter_group != self.filter_group_2
106+
self._assert_equivalent_filter_group(self.view_4.filter_group, self.filter_group_2)
107+
# View 5 gets new equivalent filter group
108+
assert self.view_5.filter_group != self.filter_group_1
109+
self._assert_equivalent_filter_group(self.view_5.filter_group, self.filter_group_1)
110+
# Assert unaffected view/filter group remains unchanged
111+
self.view_unaffected.refresh_from_db()
112+
assert self.view_unaffected.filter_group == self.filter_group_unaffected
113+
114+
115+
116+
117+

web/libs/editor/src/lib/AudioUltra/Visual/Visualizer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,12 @@ export class Visualizer extends Events<VisualizerEvents> {
212212
// as a result of multichannel or differently configured waveHeight
213213
this.setContainerHeight();
214214

215+
// Update regions layer height to match the current visualizer height
216+
const regionsLayer = this.getLayer("regions");
217+
if (regionsLayer) {
218+
regionsLayer.height = this.height;
219+
}
220+
215221
// Set renderers array
216222
this.renderers = [this.waveformRenderer];
217223
if (isFF(FF_AUDIO_SPECTROGRAMS) && this.spectrogramRenderer) {
@@ -970,6 +976,10 @@ export class Visualizer extends Events<VisualizerEvents> {
970976
if (layer.name === "waveform" || layer.name === "spectrogram" || layer.name === "spectrogram-grid") {
971977
layer.height = this.waveformHeight;
972978
}
979+
// Update regions layer height to match the full visualizer height
980+
if (layer.name === "regions") {
981+
layer.height = this.height;
982+
}
973983
}
974984
});
975985

web/libs/editor/tests/e2e/plugins/errorsCollector.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ const IGNORE_ACTION = "ignore";
5050
const DISPLAY_ACTION = "display";
5151
const INTERRUPT_ACTION = "interrupt";
5252

53+
/**
54+
* Safely get the jsonValue of a JSHandle, handling "Target closed" and other errors.
55+
* @param {any} arg - The JSHandle to get the value from
56+
* @returns {Promise<any>} - The value, or undefined if target closed, or a fallback string on other errors
57+
*/
58+
async function safeJsonValue(arg) {
59+
try {
60+
return await arg.jsonValue();
61+
} catch (error) {
62+
if (error.message && error.message.includes("Target closed")) return;
63+
64+
console.error(error);
65+
}
66+
}
67+
5368
/**
5469
* This plugin can monitor three types of errors inside the browser. They are console errors, console warnings and uncaught errors.
5570
* Depending on the configuration it could show the problems during the tests and throw exceptions at the scenario level to make the test fail when it is necessary.
@@ -122,12 +137,14 @@ module.exports = (config) => {
122137
const args = msg.args();
123138

124139
for (let i = 0; i < args.length; i++) {
125-
args[i] = await args[i].jsonValue();
140+
args[i] = await safeJsonValue(args[i]);
141+
if (args[i] === undefined) return; // Target closed, skip processing
126142
}
127143

128144
this.handleMessage(messageType, format(...args));
129145
}
130146
});
147+
131148
page.on("pageerror", (exception) => {
132149
this.handleMessage(UNCAUGHT_ERROR, exception);
133150
});

web/libs/editor/tests/e2e/tests/regression-tests/bitmask.test.js

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -209,24 +209,39 @@ Scenario("Verify Bitmask pixel content", async ({ I, LabelStudio, AtImageView, A
209209
[20, 20],
210210
]);
211211

212-
// Wait for the drawing to be complete
213-
await I.wait(0.5);
214-
215212
I.say("Verify mask content and dimensions");
216213
const result = await LabelStudio.serialize();
217214
assert.strictEqual(result.length, 1);
218215
assert.ok(result[0].value.imageDataURL);
219216

220-
// Wait for the region to be fully processed
221-
await I.wait(0.5);
222-
223217
// Get all data we need before making assertions
224-
const bbox = await I.executeScript(() => {
225-
const region = Htx.annotationStore.selected.regions[0];
226-
if (!region) throw new Error("Region not found");
227-
if (!region.bboxCoordsCanvas) throw new Error("Bbox coordinates not available");
228-
return region.bboxCoordsCanvas;
229-
});
218+
// Retry mechanism to wait for bbox coordinates to be calculated
219+
let bbox = null;
220+
let attempts = 0;
221+
const maxAttempts = 10;
222+
223+
while (!bbox && attempts < maxAttempts) {
224+
try {
225+
bbox = await I.executeScript(() => {
226+
const region = Htx.annotationStore.selected.regions[0];
227+
if (!region) return null;
228+
if (!region.bboxCoordsCanvas) return null;
229+
return region.bboxCoordsCanvas;
230+
});
231+
232+
if (!bbox) {
233+
attempts++;
234+
await I.wait(100); // Wait 100ms before retrying
235+
}
236+
} catch (error) {
237+
attempts++;
238+
await I.wait(100); // Wait 100ms before retrying
239+
}
240+
}
241+
242+
if (!bbox) {
243+
throw new Error("Bbox coordinates not available after multiple attempts");
244+
}
230245

231246
// Define thresholds for assertions
232247
const THRESHOLD = 5;
@@ -282,21 +297,14 @@ Scenario("Verify Bitmask canvas fit", async ({ I, LabelStudio, AtImageView, AtLa
282297
[10, 0], // Close the path
283298
]);
284299

285-
// Wait for the drawing to be complete
286-
await I.wait(0.5);
287-
288300
I.say("Verify mask content and image scaling");
289301
const result = await LabelStudio.serialize();
290302
assert.strictEqual(result.length, 1);
291303
assert.ok(result[0].value.imageDataURL);
292304

293-
// Wait for the region to be fully processed
294-
await I.wait(0.5);
295-
296305
// Ensure the region is selected
297306
I.say("Ensure region is selected");
298307
AtImageView.clickAt(20, 50); // Click in the middle of our drawn region
299-
await I.wait(0.2);
300308

301309
// Get canvas dimensions and verify the underlying image scaling
302310
const canvasInfo = await I.executeScript(() => {

0 commit comments

Comments
 (0)