1import { Controller } from "@hotwired/stimulus" 2import { activeAnnotations, framePoint, rectangle, timeLabel, annotationError, cameraAt, clamp } from "src/video_annotations" 3import { VideoPresentation } from "src/video_presentation" 4 5export default class extends Controller { 6 connect() { 7 this.doc = JSON.parse(this.element.querySelector("[data-video-document]").textContent) 8 this.editor = this.element.dataset.editor === "true" 9 this.video = this.find("video"); this.frame = this.find("frame") 10 this.overlay = this.find("overlay"); this.labels = this.find("labels") 11 this.find("player").style.setProperty("--iv-ratio", this.doc.width / this.doc.height) 12 this.annotations = structuredClone(this.doc.annotations) 13 this.showMarkup = true; this.autoFocus = true; this.history = []; this.future = []; this.selected = null 14 this.saved = this.snapshot(); this.abort = new AbortController() 15 this.resizeObserver = new ResizeObserver(() => this.render(true)); this.resizeObserver.observe(this.frame) 16 this.presentation = new VideoPresentation({video: this.video, frame: this.frame, overlay: this.overlay, labels: this.labels, width: this.doc.width, height: this.doc.height, editor: this.editor}) 17 const on = (event, callback) => this.video.addEventListener(event, callback, { signal: this.abort.signal }) 18 for (const event of ["timeupdate", "seeked", "loadedmetadata", "pause", "ended", "volumechange"]) on(event, () => this.render()) 19 on("play", () => { this.render(); this.tick() }) 20 on("error", () => { this.find("player-status").textContent = "The video could not be loaded. Please try again." }) 21 this.find("mute").hidden = this.doc.has_audio === false 22 this.find("markup").hidden = !this.editor && !this.annotations.some(a => a.type !== "zoom") 23 this.find("focus").hidden = !this.editor && !this.annotations.some(a => a.type === "zoom") 24 this.find("controls").hidden = false; this.video.controls = false 25 if (this.editor) { 26 this.beforeUnload = event => { if (this.snapshot() !== this.saved) { event.preventDefault(); event.returnValue = "" } } 27 window.addEventListener("beforeunload", this.beforeUnload, { signal: this.abort.signal }) 28 document.addEventListener("turbo:before-visit", event => { 29 if (this.snapshot() !== this.saved && !window.confirm("Leave without saving your video annotations?")) event.preventDefault() 30 }, { signal: this.abort.signal }) 31 this.renderEditor() 32 } 33 this.render(true) 34 } 35 disconnect() { this.presentation?.destroy(); this.abort?.abort(); this.resizeObserver?.disconnect(); cancelAnimationFrame(this.raf); this.video?.pause() } 36 find(name) { return this.element.querySelector(`[data-${name}]`) } 37 snapshot() { return JSON.stringify({ annotations: this.annotations, title: this.find("title")?.value ?? this.doc.title, published: this.find("published")?.checked ?? this.doc.published }) } 38 remember() { this.history.push(this.snapshot()); if (this.history.length > 100) this.history.shift(); this.future = [] } 39 changed() { this.renderEditor(); this.render(true) } 40 metadataChanged() { this.find("save-state").textContent = this.snapshot() === this.saved ? "All changes saved" : "Unsaved changes" } 41 tick() { cancelAnimationFrame(this.raf); if (!this.video.paused && !this.video.ended) this.raf = requestAnimationFrame(() => { this.render(); this.tick() }) } 42 togglePlay() { this.video.paused ? this.video.play().catch(() => { this.find("player-status").textContent = "Press Play to start the video." }) : this.video.pause() } 43 seek(event) { this.video.currentTime = Number(event.target.value); this.render(true) } 44 speed(event) { this.video.playbackRate = Number(event.target.value) } 45 toggleMute() { this.video.muted = !this.video.muted } 46 toggleMarkup(event) { this.showMarkup = !this.showMarkup; event.currentTarget.setAttribute("aria-pressed", this.showMarkup); this.render(true) } 47 toggleFocus(event) { this.autoFocus = !this.autoFocus; event.currentTarget.setAttribute("aria-pressed", this.autoFocus); event.currentTarget.textContent = "Auto zoom"; this.render(true) } 48 async fullscreen() { 49 try { document.fullscreenElement ? await document.exitFullscreen() : await this.find("player").requestFullscreen() } 50 catch { this.find("player-status").textContent = "Fullscreen is not available in this browser." } 51 } 52 render(force = false) { 53 const time = this.video.currentTime 54 this.find("seek").value = time 55 this.find("time").textContent = `${timeLabel(time)} / ${timeLabel(this.doc.duration)}` 56 this.find("play").textContent = this.video.paused ? "Play" : "Pause" 57 this.find("play").setAttribute("aria-label", this.video.paused ? "Play video" : "Pause video") 58 this.find("mute").textContent = this.video.muted ? "Sound off" : "Sound on" 59 this.find("mute").setAttribute("aria-label", this.video.muted ? "Unmute video" : "Mute video") 60 const active = this.showMarkup ? activeAnnotations(this.annotations, time).filter(a => a.type !== "zoom") : [] 61 if (this.drawing?.annotation) active.push({...this.drawing.annotation, type: this.drawing.annotation.type === "zoom" ? "box" : this.drawing.annotation.type}) 62 const camera = cameraAt(this.autoFocus && !this.tool ? this.annotations : [], time, window.matchMedia("(prefers-reduced-motion: reduce)").matches) 63 const signature = JSON.stringify([active, this.selected, camera]) 64 if (force || signature !== this.signature) { this.signature = signature; this.presentation.render(active, camera, this.selected) } 65 if (this.editor) this.find("timeline")?.style.setProperty("--playhead", `${clamp(time / this.doc.duration) * 100}%`) 66 } 67 chooseTool(event) { this.tool = event.currentTarget.dataset.tool; this.replacing = false; this.video.pause(); this.updateTools(); this.render(true) } 68 cancelTool() { this.tool = null; this.replacing = false; this.drawing = null; this.updateTools(); this.render(true) } 69 updateTools() { 70 this.frame.classList.toggle("iv-drawing", !!this.tool) 71 this.element.querySelectorAll("[data-tool]").forEach(b => b.setAttribute("aria-pressed", b.dataset.tool === this.tool)) 72 } 73 drawStart(event) { 74 if (!this.editor || !this.tool || event.button !== 0) return 75 event.preventDefault(); this.video.pause(); this.frame.setPointerCapture(event.pointerId) 76 const point = framePoint(event.clientX, event.clientY, this.frame.getBoundingClientRect()) 77 const start = Math.min(this.video.currentTime, Math.max(0, this.doc.duration - 0.1)) 78 const previous = this.replacing && this.annotations.find(a => a.id === this.selected) 79 const annotation = previous ? structuredClone(previous) : { id: crypto.randomUUID(), type: this.tool, start, end: Math.min(this.doc.duration, start + 3), color: "#facc15", text: this.tool === "label" ? "Your instruction" : "" } 80 Object.assign(annotation, point) 81 if (this.tool === "arrow") Object.assign(annotation, { x2: point.x, y2: point.y }) 82 if (["box", "spotlight", "zoom"].includes(this.tool)) Object.assign(annotation, { width: 0, height: 0 }) 83 this.drawing = { point, annotation, pointerId: event.pointerId } 84 this.render(true) 85 } 86 drawMove(event) { 87 if (!this.drawing || event.pointerId !== this.drawing.pointerId) return 88 const point = framePoint(event.clientX, event.clientY, this.frame.getBoundingClientRect()), a = this.drawing.annotation 89 if (a.type === "arrow") Object.assign(a, { x2: point.x, y2: point.y }) 90 else if (a.type !== "label") Object.assign(a, rectangle(this.drawing.point, point)) 91 this.render(true) 92 } 93 drawEnd(event) { 94 if (!this.drawing || event.pointerId !== this.drawing.pointerId) return 95 this.drawMove(event) 96 const a = this.drawing.annotation, error = annotationError(a, this.doc.duration, this.annotations.filter(item => item.id !== a.id)) 97 this.frame.releasePointerCapture(event.pointerId); this.drawing = null 98 if (error) { this.find("error").textContent = error; this.render(true); return } 99 if (!this.replacing && this.annotations.length >= 200) { this.find("error").textContent = "Use at most 200 annotations."; return } 100 this.remember() 101 if (this.replacing) this.annotations = this.annotations.map(item => item.id === a.id ? a : item) 102 else this.annotations.push(a) 103 this.selected = a.id; this.cancelTool(); this.changed() 104 if (a.type === "label") this.element.querySelector('[data-field="text"]').focus() 105 } 106 drawCancel() { this.drawing = null; this.render(true) } 107 select(id) { this.element.querySelectorAll("[aria-invalid]").forEach(field => field.removeAttribute("aria-invalid")); this.selected = id; this.cancelTool(); const a = this.annotations.find(a => a.id === id); if (a) { this.video.pause(); this.video.currentTime = a.type === "zoom" ? (a.start + a.end) / 2 : a.start }; this.changed() } 108 renderEditor() { 109 const list = this.find("annotation-list"), timeline = this.find("timeline") 110 list.replaceChildren(); timeline.replaceChildren() 111 if (!this.annotations.length) { const p = document.createElement("p"); p.className = "iv-hint"; p.textContent = "Add an arrow, highlight or label at the current video time."; list.append(p) } 112 for (const a of [...this.annotations].sort((a, b) => a.start - b.start)) { 113 const button = document.createElement("button"); button.type = "button"; button.className = `iv-list-item ${a.id === this.selected ? "is-selected" : ""}` 114 button.textContent = `${timeLabel(a.start)}â${timeLabel(a.end)} · ${a.type} ${a.text || ""}` 115 button.addEventListener("click", () => this.select(a.id)); list.append(button) 116 const track = document.createElement("div"); track.className = "iv-track" 117 const clip = document.createElement("button"); clip.type = "button"; clip.className = "iv-clip"; clip.title = button.textContent 118 clip.textContent = a.text || a.type; clip.style.left = `${a.start / this.doc.duration * 100}%`;
118 clip.style.width = `${(a.end - a.start) / this.doc.duration * 100}%`; clip.style.background = a.color || "#facc15" 119 clip.addEventListener("click", () => this.select(a.id)); track.append(clip); timeline.append(track) 120 } 121 const a = this.annotations.find(a => a.id === this.selected) 122 this.find("properties").hidden = !a; this.find("selection-empty").hidden = !!a 123 if (!a) this.element.querySelectorAll("[aria-invalid]").forEach(field => field.removeAttribute("aria-invalid")) 124 this.element.querySelectorAll("[data-callout-only]").forEach(field => field.hidden = a?.type === "zoom") 125 if (a) for (const field of this.element.querySelectorAll("[data-field]")) { 126 const key = field.dataset.field, coordinate = ["x", "y", "width", "height", "x2", "y2", "label_x", "label_y"].includes(key) 127 if (document.activeElement !== field) field.value = coordinate ? Math.round((a[key] ?? a[key === "label_x" ? "x" : "y"] ?? 0) * 1000) / 10 : a[key] ?? "" 128 const row = field.closest("[data-coordinate]") 129 if (row) row.hidden = (a.type === "zoom" && key.startsWith("label_")) || (!(key in a) && !key.startsWith("label_")) 130 } 131 this.find("undo").disabled = !this.history.length; this.find("redo").disabled = !this.future.length 132 this.find("error").textContent = ""; this.metadataChanged() 133 } 134 changeProperty(event) { 135 const a = this.annotations.find(a => a.id === this.selected); if (!a) return 136 const key = event.target.dataset.field, updated = { ...a } 137 updated[key] = ["text", "color", "title"].includes(key) ? event.target.value : Number(event.target.value) / (["start", "end", "number"].includes(key) ? 1 : 100) 138 if (["number", "label_x", "label_y"].includes(key) && event.target.value === "") delete updated[key] 139 const error = annotationError(updated, this.doc.duration, this.annotations.filter(item => item.id !== a.id)) 140 if (error) { this.find("error").textContent = error; event.target.setAttribute("aria-invalid", "true"); return } 141 event.target.removeAttribute("aria-invalid"); if (JSON.stringify(a) === JSON.stringify(updated)) return 142 this.remember(); this.annotations = this.annotations.map(item => item.id === a.id ? updated : item); this.changed() 143 } 144 usePlayhead(event) { 145 const field = this.element.querySelector(`[data-field="${event.currentTarget.dataset.bound}"]`) 146 field.value = Math.min(this.video.currentTime, this.doc.duration).toFixed(2); this.changeProperty({ target: field }) 147 } 148 reposition() { const a = this.annotations.find(a => a.id === this.selected); if (a) { this.tool = a.type; this.replacing = true; this.video.pause(); this.updateTools(); this.render(true) } } 149 duplicate() { 150 const a = this.annotations.find(a => a.id === this.selected); if (!a || this.annotations.length >= 200) return 151 if (a.type === "zoom") { this.find("error").textContent = "Add a zoom at a different time; zoom regions cannot overlap."; return } 152 this.remember(); const copy = { ...a, id: crypto.randomUUID() }; this.annotations.push(copy); this.selected = copy.id; this.changed() 153 } 154 remove() { this.remember(); this.annotations = this.annotations.filter(a => a.id !== this.selected); this.selected = null; this.changed() } 155 restore(snapshot) { const state = JSON.parse(snapshot); this.annotations = state.annotations; this.find("title").value = state.title; this.find("published").checked = state.published; this.selected = null; this.changed() } 156 undo() { if (this.history.length) { this.future.push(this.snapshot()); this.restore(this.history.pop()) } } 157 redo() { if (this.future.length) { this.history.push(this.snapshot()); this.restore(this.future.pop()) } } 158 async save() { 159 if (this.saving) return 160 if (this.element.querySelector('[aria-invalid="true"]')) { this.find("error").textContent = "Correct the highlighted field before saving."; return } 161 const snapshot = this.snapshot(), payload = { ...JSON.parse(snapshot), lock_ver
161sion: this.doc.lock_version } 162 this.saving = true; this.find("save-state").textContent = "Savingâ¦" 163 try { 164 const response = await fetch(this.element.dataset.saveUrl, { method: "PATCH", headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || "" }, body: JSON.stringify(payload) }) 165 const result = await response.json() 166 if (!response.ok) throw new Error(result.error || "Could not save this video.") 167 this.doc.lock_version = result.lock_version; this.saved = snapshot; this.metadataChanged(); this.find("error").textContent = "" 168 } catch (error) { this.find("save-state").textContent = "Not saved"; this.find("error").textContent = error.message } 169 finally { this.saving = false } 170 } 171 exportAnnotations() { 172 const blob = new Blob([JSON.stringify({ public_id: this.doc.public_id, annotations: this.annotations }, null, 2)], { type: "application/json" }) 173 const url = URL.createObjectURL(blob), a = document.createElement("a"); a.href = url; a.download = `${this.doc.public_id}-annotations.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) 174 } 175}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.