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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pausing-annotator/plugin.js b/src/pausing-annotator/plugin.js
index ef5e250..5943231 100644
--- a/src/pausing-annotator/plugin.js
+++ b/src/pausing-annotator/plugin.js
@@ -131,9 +131,15 @@ LSI.on("submitAnnotation", async (_store, annotation) => {
for (const rule of RULES.global) {
const result = rule(stats);
+
if (result) {
localStorage.setItem(key, "[]");
- await pause(result);
+
+ try {
+ await pause(result);
+ } catch (error) {
+ Htx.showModal(error.message, "error");
+ }
return;
}
}
@@ -164,7 +170,7 @@ LSI.on("submitAnnotation", async (_store, annotation) => {
*/
async function pause(verbose_reason) {
const body = {
- reason: "PLUGIN",
+ reason: "CUSTOM_SCRIPT",
verbose_reason,
};
const options = {
diff --git a/src/simple-content-moderation/data.json b/src/simple-content-moderation/data.json
new file mode 100644
index 0000000..0e68b50
--- /dev/null
+++ b/src/simple-content-moderation/data.json
@@ -0,0 +1,5 @@
+[
+ {
+ "audio": "https://data.heartex.net/librispeech/dev-clean/3536/8226/3536-8226-0024.flac.wav"
+ }
+]
diff --git a/src/simple-content-moderation/plugin.js b/src/simple-content-moderation/plugin.js
new file mode 100644
index 0000000..1ec45ee
--- /dev/null
+++ b/src/simple-content-moderation/plugin.js
@@ -0,0 +1,31 @@
+/**
+ * Simple content moderation plugin that prevents saving annotations containing hate speech
+ *
+ * This plugin monitors text entered into TextArea regions and checks for the word "hate"
+ * before allowing the annotation to be saved. If found, it shows an error message and
+ * prevents submission. This would happen only once, if user clicks Submit again it would
+ * work with no errors.
+ *
+ * The plugin uses Label Studio's beforeSaveAnnotation event which is triggered before
+ * an annotation is saved. Returning false from this event handler prevents the save
+ * operation from completing.
+ */
+
+let dismissed = false;
+
+LSI.on("beforeSaveAnnotation", (store, ann) => {
+ // text in TextArea is always an array
+ const obscene = ann.results.find(
+ (r) =>
+ r.type === "textarea" && r.value.text.some((t) => t.includes("hate")),
+ );
+ if (!obscene || dismissed) return true;
+
+ // select region to see textarea
+ if (!obscene.area.classification) ann.selectArea(obscene.area);
+
+ Htx.showModal("The word 'hate' is disallowed", "error");
+ dismissed = true;
+
+ return false;
+});
diff --git a/src/simple-content-moderation/view.xml b/src/simple-content-moderation/view.xml
new file mode 100644
index 0000000..8776950
--- /dev/null
+++ b/src/simple-content-moderation/view.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+