diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11f6d26..1357ebb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -191,6 +191,7 @@ published manifests/media and send playback events to Core. Technical reference, as needed: - [Flow engine](docs/flow_engine.md): templates, execution, retries and debugging. +- [Audio description](docs/audio_description.md): steps, outputs, providers and host policy. - [Prepared analytics](docs/analytics_sessions.md): setup and data lifecycle. - [Webhook delivery](docs/webhook_delivery_outbox.md): signed lifecycle events. - [Encoding boosters](deploy/encoding-booster/README.md): optional remote compute; diff --git a/README.md b/README.md index 1329327..b67fc01 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ and publish videos — with your media in S3-compatible storage that you control - **Manage:** organize videos into folders, invite your team, and replace a video without changing its public embed. -- **Publish:** resumable uploads, adaptive HLS, posters, subtitles, and - customizable player settings. +- **Publish:** resumable uploads, adaptive HLS, posters, subtitles, + [audio description](docs/audio_description.md), and customizable player settings. - **Integrate:** embed with web components, iframe, React, or Vue; automate with the video API and signed webhooks. - **Understand:** see views, drop-off, and viewing sources in your dashboard, diff --git a/assets/js/app.js b/assets/js/app.js index 14d3eaf..2b93a2f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -28,6 +28,7 @@ import topbar from "../vendor/topbar" import "@lottiefiles/lottie-player" import { ChartHook } from "./hooks/chart" import { cdn } from "./upload" +import { FileMenuHook } from "./hooks/file_menu" let selectedEmbed = null @@ -717,6 +718,7 @@ const liveSocket = new LiveSocket("/live", Socket, { draggable: DraggableEmbedHook, thumbnail_preview: ThumbnailPreviewHook, auto_open_file_picker: AutoOpenFilePickerHook, + file_menu: FileMenuHook, preserve_scroll: PreserveScrollHook, }, uploaders: { cdn }, diff --git a/assets/js/hooks/file_menu.js b/assets/js/hooks/file_menu.js new file mode 100644 index 0000000..1b74394 --- /dev/null +++ b/assets/js/hooks/file_menu.js @@ -0,0 +1,89 @@ +export const FileMenuHook = { + mounted() { + this.onClick = (event) => { + const trigger = event.target.closest("[data-file-menu-trigger='true']") + if (trigger && this.el.contains(trigger)) { + this.isOpen ? this.closeMenu() : this.openMenu(event.detail === 0) + } else if (event.target.closest("[role='menuitem']")) { + this.closeMenu() + } + } + this.onOutside = (event) => { + if (!this.el.contains(event.target)) this.closeMenu() + } + this.onKey = (event) => { + if (!this.isOpen) return + if (event.key === "Escape") { + event.preventDefault() + this.closeMenu(true) + } else if (event.key === "Tab") { + this.closeMenu() + } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { + event.preventDefault() + const items = [...this.panel.querySelectorAll("[role='menuitem']:not(:disabled)")] + const current = items.indexOf(document.activeElement) + const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : + current < 0 ? (event.key === "ArrowDown" ? 0 : items.length - 1) : + (current + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length + items[next]?.focus() + } + } + this.onScroll = (event) => { + if (this.isOpen && !this.panel?.contains(event.target)) this.positionMenu() + } + this.onResize = () => this.closeMenu() + this.el.addEventListener("click", this.onClick) + this.el.addEventListener("keydown", this.onKey) + document.addEventListener("pointerdown", this.onOutside) + document.addEventListener("scroll", this.onScroll, true) + window.addEventListener("resize", this.onResize) + }, + + updated() { + this.closeMenu() + }, + + openMenu(focusFirst = false) { + this.trigger = this.el.querySelector("[data-file-menu-trigger='true']") + this.panel = this.el.querySelector("[data-file-menu-panel]") + if (!this.trigger || !this.panel) return + + // Keep the menu clear of the narrow sidebar's scroll clipping and viewport edges. + this.panel.style.display = "block" + this.positionMenu() + this.trigger.setAttribute("aria-expanded", "true") + this.isOpen = true + if (focusFirst) this.panel.querySelector("[role='menuitem']:not(:disabled)")?.focus({ preventScroll: true }) + }, + + positionMenu() { + const anchor = this.trigger.getBoundingClientRect() + if (anchor.bottom <= 0 || anchor.top >= window.innerHeight) { + this.closeMenu() + return + } + const menu = this.panel.getBoundingClientRect() + const inset = 8 + const rowRight = this.el.parentElement.getBoundingClientRect().right + const left = Math.max(inset, Math.min(rowRight - menu.width, window.innerWidth - menu.width - inset)) + const top = anchor.bottom + menu.height + inset <= window.innerHeight ? + anchor.bottom + inset : Math.max(inset, anchor.top - menu.height - inset) + this.panel.style.left = `${left}px` + this.panel.style.top = `${top}px` + }, + + closeMenu(returnFocus = false) { + if (this.panel) this.panel.style.display = "none" + this.trigger?.setAttribute("aria-expanded", "false") + this.isOpen = false + if (returnFocus) this.trigger?.focus({ preventScroll: true }) + }, + + destroyed() { + this.el.removeEventListener("click", this.onClick) + this.el.removeEventListener("keydown", this.onKey) + document.removeEventListener("pointerdown", this.onOutside) + document.removeEventListener("scroll", this.onScroll, true) + window.removeEventListener("resize", this.onResize) + }, +} diff --git a/docs/analytics_sessions.md b/docs/analytics_sessions.md index 8724863..def67e4 100644 --- a/docs/analytics_sessions.md +++ b/docs/analytics_sessions.md @@ -93,7 +93,12 @@ mix analytics.sessions.backfill --materialize-inventory --materialize-session-re Source deletion, copying or restoration must invalidate affected prepared data with `Sessions.invalidate/1` under the caller's existing authorization, then rebuild it. Do not reuse a retired space identifier before invalidation succeeds. -Normal event arrival needs no explicit invalidation. +Invalidation atomically hides the published generations in PostgreSQL and records +them for cleanup; it does not wait for ClickHouse deletion. New readers cannot +select the old generations, while reads already in progress retain the reader +grace period. The background processor rebuilds from current source events, and +cleanup reclaims retired generations independently. Normal event arrival needs +no explicit invalidation. ## Query semantics diff --git a/docs/audio_description.md b/docs/audio_description.md new file mode 100644 index 0000000..31f708d --- /dev/null +++ b/docs/audio_description.md @@ -0,0 +1,163 @@ +# Audio description + +Audio description makes a video followable for blind and low-vision viewers: a +voice describes what happens on screen. A space opts in under **Settings → +Features → Audio description**. From then on, every new upload and reprocessing +run of a video of at most 10 minutes gets one description in its detected +spoken language (English when no language is known): + +- `audio_description_.mp3`: a full mix of the video's own audio with the + spoken descriptions. It is also an HLS alternate audio rendition, so players + can switch to it. +- `descriptions_.vtt`: a WebVTT with `Kind: descriptions`, containing exactly + the spoken descriptions, timed to when they are spoken. + +Existing videos only get audio descriptions when they are processed again. + +The flow input can set `audio_description.fallback_language` (default `en`). +A `fallback_language` step parameter overrides that default. These values are +used only when the original language is unknown; hosts can snapshot a space's +preferred fallback in the flow input without coupling the pipeline to space settings. + +## How descriptions are placed + +- **Original language.** The picture is described directly in the language + detected by transcription. If no language is known, English is used. No second + language or translation is generated by default. +- **In the pauses, otherwise ducked.** Where speech is, is measured with + speech-to-text (the `ai.transcribe_audio` segments, or a transcription made for + this purpose), never guessed by the description model. A description that fits a + pause near its moment (up to 1 s earlier or 2 s later) plays there and leaves the + original audio untouched. When none fits, it plays at its moment and the original + audio fades to 20% underneath it (0.3 s fades). Descriptions are not shortened to + make room. +- **In sync with the picture.** Cuts are detected with FFmpeg `scdet` and grouped + into scenes of at least 3 s, so a fast montage is still described. A description + starts while its scene is on screen and at least half of it is spoken during the + scene; a pause after the cut never counts as room for it. One that can no longer + start in its scene, because earlier descriptions ran long, is left out. +- **Labelled stills.** The image model gets one 360p still per ~3 s per shot + (at least one per cut), labelled `S at