diff --git a/docs/banner.png b/docs/banner.png index b8ee75b..3642e92 100644 Binary files a/docs/banner.png and b/docs/banner.png differ diff --git a/manifest.json b/manifest.json index 2590fbb..0ce8f84 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ [ { - "title": "Bulk labeling for text spans", - "description": "Assigns labels to all occurrences of the selected text at once", + "title": "Bulk labeling for text spans with keyboard shortcuts", + "description": "Assigns labels to all occurrences of the selected text at once and removes them", "path": "bulk-labeling", "private": false }, @@ -64,5 +64,17 @@ "description": "Checks that the introduced text is a valid JSON", "path": "validate-json-in-textarea", "private": false + }, + { + "title": "Simple content moderation", + "description": "Prevents saving annotations containing inappropriate content", + "path": "simple-content-moderation", + "private": false + }, + { + "title": "Multi-frame video view", + "description": "Synchronizes multiple video views to display a video with different frame offsets", + "path": "multi-frame-video-view", + "private": false } ] diff --git a/src/bulk-labeling/plugin.js b/src/bulk-labeling/plugin.js index dd20dd0..6bcff91 100644 --- a/src/bulk-labeling/plugin.js +++ b/src/bulk-labeling/plugin.js @@ -1,38 +1,101 @@ /** - * Automatically creates all the text regions containing all instances of the selected text. + * Automatically creates text regions for all instances of the selected text and deletes existing regions + * when the shift key is pressed. */ -// It will be triggered when a text selection happens -LSI.on("entityCreate", (region) => { +// Track the state of the shift key +let isShiftKeyPressed = false; + +window.addEventListener("keydown", (e) => { + if (e.key === "Shift") { + isShiftKeyPressed = true; + } +}); + +window.addEventListener("keyup", (e) => { + if (e.key === "Shift") { + isShiftKeyPressed = false; + } +}); + +LSI.on("entityDelete", (region) => { + if (!isShiftKeyPressed) return; // Only proceed if the shift key is pressed + if (window.BULK_REGIONS) return; + window.BULK_REGIONS = true; + setTimeout(() => { + window.BULK_REGIONS = false; + }, 1000); + + const existingEntities = Htx.annotationStore.selected.regions; + const regionsToDelete = existingEntities.filter((entity) => { + const deletedText = region.text.toLowerCase().replace("\\\\n", " "); + const otherText = entity.text.toLowerCase().replace("\\\\n", " "); + return deletedText === otherText && region.labels[0] === entity.labels[0]; + }); + for (const region of regionsToDelete) { + Htx.annotationStore.selected.deleteRegion(region); + } + + Htx.annotationStore.selected.updateObjects(); +}); + +LSI.on("entityCreate", (region) => { + if (!isShiftKeyPressed) return; + + if (window.BULK_REGIONS) return; window.BULK_REGIONS = true; setTimeout(() => { window.BULK_REGIONS = false; }, 1000); + const existingEntities = Htx.annotationStore.selected.regions; + setTimeout(() => { - // Find all the text regions matching the selection - const matches = Array.from( - region.object._value.matchAll(new RegExp(region.text, "gi")), + // Prevent tagging a single character + if (region.text.length < 2) return; + regexp = new RegExp( + region.text.replace("\\\\n", "\\\\s+").replace(" ", "\\\\s+"), + "gi", ); - for (const m of matches) { - if (m.index === region.startOffset) continue; - - // Include them in the results as new selections - Htx.annotationStore.selected.createResult( - { - text: region.text, - start: "/span[1]/text()[1]", - startOffset: m.index, - end: "/span[1]/text()[1]", - endOffset: m.index + region.text.length, - }, - { labels: [...region.labeling.value.labels] }, - region.labeling.from_name, - region.object, - ); + const matches = Array.from(region.object._value.matchAll(regexp)); + for (const match of matches) { + if (match.index === region.startOffset) continue; + + const startOffset = match.index; + const endOffset = match.index + region.text.length; + + // Check for existing entities with overlapping start and end offset + let isDuplicate = false; + for (const entity of existingEntities) { + if ( + startOffset <= entity.globalOffsets.end && + entity.globalOffsets.start <= endOffset + ) { + isDuplicate = true; + break; + } + } + + if (!isDuplicate) { + Htx.annotationStore.selected.createResult( + { + text: region.text, + start: "/span[1]/text()[1]", + startOffset: startOffset, + end: "/span[1]/text()[1]", + endOffset: endOffset, + }, + { + labels: [...region.labeling.value.labels], + }, + region.labeling.from_name, + region.object, + ); + } } + Htx.annotationStore.selected.updateObjects(); }, 100); }); diff --git a/src/multi-frame-video-view/data.json b/src/multi-frame-video-view/data.json new file mode 100644 index 0000000..8ff2d73 --- /dev/null +++ b/src/multi-frame-video-view/data.json @@ -0,0 +1,5 @@ +{ + "data": { + "video_url": "https://example.com/path/to/video.mp4" + } +} diff --git a/src/multi-frame-video-view/plugin.js b/src/multi-frame-video-view/plugin.js new file mode 100644 index 0000000..273594c --- /dev/null +++ b/src/multi-frame-video-view/plugin.js @@ -0,0 +1,76 @@ +/** + * Multi-frame video view plugin + * + * This plugin synchronizes three video views to display a video with three frames: + * -1 frame, 0 frame, and +1 frame. + * + * It also synchronizes the timeline labels to the 0 frame. + */ + +async function initMultiFrameVideoView() { + // Wait for the Label Studio Interface to be ready + await LSI; + + // Get references to the video objects by their names + const videoMinus1 = LSI.annotation.names.get("videoMinus1"); + const video0 = LSI.annotation.names.get("video0"); + const videoPlus1 = LSI.annotation.names.get("videoPlus1"); + + if (!videoMinus1 || !video0 || !videoPlus1) return; + + // Convert frameRate to a number and ensure it's valid + const frameRate = Number.parseFloat(video0.framerate) || 24; + const frameDuration = 1 / frameRate; + + // Function to adjust video sync with offset and guard against endless loops + function adjustVideoSync(video, offsetFrames) { + video.isSyncing = false; + + for (const event of ["seek", "play", "pause"]) { + video.syncHandlers.set(event, (data) => { + if (!video.isSyncing) { + video.isSyncing = true; + + if (video.ref.current && video !== video0) { + const videoElem = video.ref.current; + + adjustedTime = + (video0.ref.current.currentFrame + offsetFrames) * frameDuration; + adjustedTime = Math.max( + 0, + Math.min(adjustedTime, video.ref.current.duration), + ); + + if (data.playing) { + if (!videoElem.playing) videoElem.play(); + } else { + if (videoElem.playing) videoElem.pause(); + } + + if (data.speed) { + video.speed = data.speed; + } + + videoElem.currentTime = adjustedTime; + if ( + Math.abs(videoElem.currentTime - adjustedTime) > + frameDuration / 2 + ) { + videoElem.currentTime = adjustedTime; + } + } + + video.isSyncing = false; + } + }); + } + } + + // Adjust offsets for each video + adjustVideoSync(videoMinus1, -1); + adjustVideoSync(videoPlus1, 1); + adjustVideoSync(video0, 0); +} + +// Initialize the plugin +initMultiFrameVideoView(); diff --git a/src/multi-frame-video-view/view.xml b/src/multi-frame-video-view/view.xml new file mode 100644 index 0000000..df3be9a --- /dev/null +++ b/src/multi-frame-video-view/view.xml @@ -0,0 +1,31 @@ + + + +
+