@linto-ai/transcript-ui-plugin-subtitle 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @linto-ai/transcript-ui-plugin-subtitle
2
+
3
+ Fullscreen or banner subtitle display driven by the transcript (live or not), with an optional cycling watermark (text and/or image tokens).
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { createSubtitlePlugin } from "@linto-ai/transcript-ui-plugin-subtitle"
9
+
10
+ core.use(
11
+ createSubtitlePlugin({
12
+ fontSize: 40,
13
+ isVisible: true,
14
+ watermark: {
15
+ content: "{logo} Recorded with LinTO",
16
+ tokens: { logo: { src: "/logo.svg", alt: "LinTO" } },
17
+ frequency: 30, // show every 30s
18
+ duration: 5, // for 5s
19
+ },
20
+ }),
21
+ )
22
+ ```
23
+
24
+ Toggle it from your own UI (a settings panel, a button, …) through the reactive state it exposes:
25
+
26
+ ```ts
27
+ core.subtitle!.isVisible.value = true
28
+ core.subtitle!.enterFullscreen()
29
+ ```
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@linto-ai/transcript-ui-plugin-subtitle",
3
+ "version": "0.9.0",
4
+ "description": "Subtitle display plugin for @linto-ai/transcript-ui — fullscreen/banner captions with an optional watermark",
5
+ "keywords": [
6
+ "vue",
7
+ "vue3",
8
+ "transcript",
9
+ "subtitle",
10
+ "captions",
11
+ "linto-plugin"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.vue"
15
+ ],
16
+ "homepage": "https://github.com/linto-ai/linto-studio",
17
+ "bugs": "https://github.com/linto-ai/linto-studio/issues",
18
+ "author": "Tom Darboux <tdarboux@linagora.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/linto-ai/linto-studio.git",
22
+ "directory": "studio-sdk/components/transcript-ui/packages/plugin-subtitle"
23
+ },
24
+ "type": "module",
25
+ "license": "AGPL-3.0",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "main": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "peerDependencies": {
38
+ "vue": "^3.5.0"
39
+ },
40
+ "dependencies": {
41
+ "@linto-ai/transcript-ui-core": "0.9.0",
42
+ "@linto-ai/transcript-ui-i18n": "0.9.0",
43
+ "diff": "^8.0.3"
44
+ },
45
+ "devDependencies": {
46
+ "@types/diff": "^8.0.0"
47
+ }
48
+ }
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue"
3
+ import { useCore } from "@linto-ai/transcript-ui-core"
4
+ import { useSubtitleScroller } from "./useSubtitleScroller"
5
+ import { useWatermarkCycle } from "./useWatermarkCycle"
6
+ import SubtitleWatermark from "./SubtitleWatermark.vue"
7
+
8
+ const core = useCore()
9
+ const canvasRef = useTemplateRef<HTMLCanvasElement>("canvas")
10
+
11
+ const fontSize = computed(() => core.subtitle?.fontSize.value ?? 40)
12
+ const lineHeight = computed(() => 1.2 * fontSize.value)
13
+ const canvasHeight = computed(() => 2.4 * fontSize.value)
14
+
15
+ useSubtitleScroller({
16
+ canvasRef,
17
+ fontSize,
18
+ lineHeight,
19
+ })
20
+
21
+ const { visible: watermarkVisible } = useWatermarkCycle(
22
+ core.subtitle?.watermark,
23
+ )
24
+
25
+ // The v-if in Layout mounts this component exactly when the fixed banner
26
+ // occupies the bottom of the viewport. Notify the consumer so it can reserve
27
+ // space (e.g. padding-bottom) and avoid content being hidden behind the banner.
28
+ onMounted(() => {
29
+ core.emit("subtitle:visible", { visible: true, height: canvasHeight.value })
30
+ })
31
+
32
+ watch(canvasHeight, (height) => {
33
+ core.emit("subtitle:visible", { visible: true, height })
34
+ })
35
+
36
+ onBeforeUnmount(() => {
37
+ core.emit("subtitle:visible", { visible: false, height: 0 })
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <div class="subtitle-banner" :style="{ height: canvasHeight + 'px' }">
43
+ <canvas
44
+ ref="canvas"
45
+ class="subtitle-canvas"
46
+ :class="{ 'subtitle-canvas--shrunk': watermarkVisible }"
47
+ :height="canvasHeight"></canvas>
48
+ <SubtitleWatermark :visible="watermarkVisible" />
49
+ </div>
50
+ </template>
51
+
52
+ <style scoped>
53
+ .subtitle-banner {
54
+ position: fixed;
55
+ bottom: 0;
56
+ left: 0;
57
+ right: 0;
58
+ flex-shrink: 0;
59
+ background-color: var(--color-black);
60
+ overflow: hidden;
61
+ z-index: 1001;
62
+ }
63
+
64
+ .subtitle-canvas {
65
+ display: block;
66
+ width: 100%;
67
+ height: 100%;
68
+ transition: transform 0.4s ease;
69
+ transform-origin: top center;
70
+ }
71
+
72
+ .subtitle-canvas--shrunk {
73
+ transform: scale(0.8) translateY(-8%);
74
+ }
75
+
76
+ @media (prefers-reduced-motion: reduce) {
77
+ .subtitle-canvas {
78
+ transition: none;
79
+ }
80
+ }
81
+ </style>
@@ -0,0 +1,148 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, useTemplateRef } from "vue"
3
+ import { X } from "lucide-vue-next"
4
+ import { useCore } from "@linto-ai/transcript-ui-core"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+ import { useSubtitleScroller } from "./useSubtitleScroller"
7
+ import { useWatermarkCycle } from "./useWatermarkCycle"
8
+ import SubtitleWatermark from "./SubtitleWatermark.vue"
9
+
10
+ const core = useCore()
11
+ const { t } = useI18n()
12
+ const containerRef = useTemplateRef<HTMLDivElement>("container")
13
+ const canvasRef = useTemplateRef<HTMLCanvasElement>("canvas")
14
+
15
+ const fontSize = computed(() => core.subtitle?.fontSize.value ?? 48)
16
+ const lineHeight = computed(() => 1.2 * fontSize.value)
17
+
18
+ useSubtitleScroller({
19
+ canvasRef,
20
+ fontSize,
21
+ lineHeight,
22
+ })
23
+
24
+ const { visible: watermarkVisible } = useWatermarkCycle(
25
+ core.subtitle?.watermark,
26
+ )
27
+
28
+ onMounted(async () => {
29
+ const el = containerRef.value
30
+ if (!el) return
31
+
32
+ try {
33
+ await el.requestFullscreen()
34
+ } catch (err) {
35
+ console.warn("Fullscreen API not supported:", err)
36
+ }
37
+
38
+ try {
39
+ await (screen.orientation as unknown as { lock(o: string): Promise<void> }).lock("landscape")
40
+ } catch {
41
+ // Not supported or not allowed
42
+ }
43
+ })
44
+
45
+ // Sync: browser exits fullscreen (Escape key) → update plugin state
46
+ function onFullscreenChange() {
47
+ if (!document.fullscreenElement) {
48
+ core.subtitle?.exitFullscreen()
49
+ }
50
+ }
51
+
52
+ onMounted(() => {
53
+ document.addEventListener("fullscreenchange", onFullscreenChange)
54
+ })
55
+
56
+ function close() {
57
+ if (document.fullscreenElement) {
58
+ document.exitFullscreen().catch(() => {})
59
+ }
60
+ core.subtitle?.exitFullscreen()
61
+ }
62
+
63
+ onUnmounted(() => {
64
+ document.removeEventListener("fullscreenchange", onFullscreenChange)
65
+
66
+ try {
67
+ ;(screen.orientation as unknown as { unlock(): void }).unlock()
68
+ } catch {
69
+ // Not supported
70
+ }
71
+
72
+ if (document.fullscreenElement) {
73
+ document.exitFullscreen().catch(() => {})
74
+ }
75
+ })
76
+ </script>
77
+
78
+ <template>
79
+ <div ref="container" class="subtitle-fullscreen">
80
+ <button
81
+ class="subtitle-fullscreen__close"
82
+ :aria-label="t('subtitle.exitFullscreen')"
83
+ @click="close">
84
+ <X :size="24" />
85
+ </button>
86
+ <canvas
87
+ ref="canvas"
88
+ class="subtitle-fullscreen__canvas"
89
+ :class="{ 'subtitle-fullscreen__canvas--shrunk': watermarkVisible }"></canvas>
90
+ <SubtitleWatermark :visible="watermarkVisible" />
91
+ </div>
92
+ </template>
93
+
94
+ <style scoped>
95
+ .subtitle-fullscreen {
96
+ position: relative;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ width: 100%;
101
+ height: 100%;
102
+ background-color: var(--color-black);
103
+ }
104
+
105
+ .subtitle-fullscreen__close {
106
+ position: absolute;
107
+ top: var(--spacing-md, 16px);
108
+ right: var(--spacing-md, 16px);
109
+ z-index: 1;
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 40px;
114
+ height: 40px;
115
+ border: none;
116
+ background: rgba(255, 255, 255, 0.1);
117
+ color: var(--color-white);
118
+ border-radius: var(--radius-md, 8px);
119
+ cursor: pointer;
120
+ transition: background-color var(--transition-duration) ease;
121
+ }
122
+
123
+ .subtitle-fullscreen__close:hover,
124
+ .subtitle-fullscreen__close:focus-visible {
125
+ background: rgba(255, 255, 255, 0.25);
126
+ outline: 2px solid rgba(255, 255, 255, 0.5);
127
+ outline-offset: 2px;
128
+ }
129
+
130
+ .subtitle-fullscreen__canvas {
131
+ display: block;
132
+ width: 100%;
133
+ height: 100%;
134
+ transition: transform 0.4s ease;
135
+ transform-origin: center;
136
+ }
137
+
138
+ .subtitle-fullscreen__canvas--shrunk {
139
+ transform: scale(0.85) translateY(-4%);
140
+ }
141
+
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .subtitle-fullscreen__close,
144
+ .subtitle-fullscreen__canvas {
145
+ transition: none;
146
+ }
147
+ }
148
+ </style>
@@ -0,0 +1,198 @@
1
+ import splitPartialSubtitles from "./utils/splitPartialSubtitles"
2
+
3
+ interface DrawerOptions {
4
+ fontSize?: number
5
+ lineHeight?: number
6
+ color?: string
7
+ font?: string
8
+ paddingInline?: number
9
+ }
10
+
11
+ interface ScrollerState {
12
+ previousText: string
13
+ previousIndexes: number[]
14
+ }
15
+
16
+ export class SubtitleDrawer {
17
+ protected canvas: HTMLCanvasElement
18
+ protected fontSize: number
19
+ protected lineHeight: number
20
+ protected color: string
21
+ protected font: string
22
+ protected paddingInline: number
23
+ protected isResizing = false
24
+ private resizeObserver: ResizeObserver
25
+
26
+ constructor(
27
+ canvas: HTMLCanvasElement,
28
+ {
29
+ fontSize = 40,
30
+ lineHeight = 50,
31
+ color = "white",
32
+ font = "Arial",
33
+ paddingInline = 100,
34
+ }: DrawerOptions = {},
35
+ ) {
36
+ this.canvas = canvas
37
+ this.fontSize = fontSize
38
+ this.lineHeight = lineHeight
39
+ this.color = color
40
+ this.font = font
41
+ this.paddingInline = paddingInline
42
+
43
+ this.canvas.width = this.canvas.clientWidth
44
+ this.canvas.height = this.canvas.clientHeight
45
+
46
+ this.resizeObserver = new ResizeObserver(() => {
47
+ this.isResizing = true
48
+ this.canvas.width = this.canvas.clientWidth
49
+ this.canvas.height = this.canvas.clientHeight
50
+ this.onResize()
51
+ this.isResizing = false
52
+ })
53
+ this.resizeObserver.observe(this.canvas)
54
+ }
55
+
56
+ dispose(): void {
57
+ this.resizeObserver.disconnect()
58
+ }
59
+
60
+ setFontSize(fontSize: number, lineHeight: number): void {
61
+ this.fontSize = fontSize
62
+ this.lineHeight = lineHeight
63
+ this.resetDrawing()
64
+ this.onResize()
65
+ }
66
+
67
+ resetDrawing(): void {
68
+ const ctx = this.canvas.getContext("2d")!
69
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
70
+ }
71
+
72
+ protected drawText(text: string, x: number, y: number): void {
73
+ const ctx = this.canvas.getContext("2d")!
74
+ ctx.font = `${this.fontSize}px ${this.font}`
75
+ ctx.fillStyle = this.color
76
+ ctx.fillText(text, x + this.paddingInline, y)
77
+ }
78
+
79
+ protected drawFirstLine(text: string): void {
80
+ this.drawText(text, 0, this.fontSize)
81
+ }
82
+
83
+ protected drawSecondLine(text: string): void {
84
+ this.drawText(text, 0, this.fontSize + this.lineHeight)
85
+ }
86
+
87
+ protected onResize(): void {
88
+ // Override in subclass
89
+ }
90
+ }
91
+
92
+ export class SubtitleScroller extends SubtitleDrawer {
93
+ private currentState: ScrollerState = { previousText: "", previousIndexes: [] }
94
+ private previousState: ScrollerState = { previousText: "", previousIndexes: [] }
95
+
96
+ constructor(canvas: HTMLCanvasElement, options?: DrawerOptions) {
97
+ super(canvas, options)
98
+ }
99
+
100
+ resetAll(): void {
101
+ this.currentState = { previousText: "", previousIndexes: [] }
102
+ this.previousState = { previousText: "", previousIndexes: [] }
103
+ }
104
+
105
+ protected override onResize(): void {
106
+ const currentText = this.currentState.previousText
107
+ this.resetAll()
108
+ this.currentState = splitPartialSubtitles(
109
+ this.currentState,
110
+ currentText.trim(),
111
+ this.computeIfTextIsTooLong.bind(this),
112
+ this.computeIfTextOverflows.bind(this),
113
+ )
114
+ this.draw()
115
+ }
116
+
117
+ newPartial(text: string): void {
118
+ if (this.isResizing) return
119
+ this.currentState = splitPartialSubtitles(
120
+ this.currentState,
121
+ text.trim(),
122
+ this.computeIfTextIsTooLong.bind(this),
123
+ this.computeIfTextOverflows.bind(this),
124
+ )
125
+ this.draw()
126
+ }
127
+
128
+ newFinal(text: string): void {
129
+ if (this.isResizing) return
130
+ this.currentState = splitPartialSubtitles(
131
+ this.currentState,
132
+ text.trim(),
133
+ this.computeIfTextIsTooLong.bind(this),
134
+ this.computeIfTextOverflows.bind(this),
135
+ )
136
+ this.draw()
137
+ this.resetState()
138
+ }
139
+
140
+ private resetState(): void {
141
+ this.previousState = this.currentState
142
+ this.currentState = { previousText: "", previousIndexes: [] }
143
+ }
144
+
145
+ private draw(): void {
146
+ this.resetDrawing()
147
+ let firstLine = ""
148
+ let secondLine = ""
149
+
150
+ switch (this.currentState.previousIndexes.length) {
151
+ case 0:
152
+ firstLine = this.getLastLineOfState(this.previousState)
153
+ secondLine = this.currentState.previousText
154
+ break
155
+ default:
156
+ firstLine = this.getSecondLastLineOfState(this.currentState)
157
+ secondLine = this.getLastLineOfState(this.currentState)
158
+ break
159
+ }
160
+
161
+ this.drawFirstLine(firstLine)
162
+ this.drawSecondLine(secondLine)
163
+ }
164
+
165
+ private getLastLineOfState(state: ScrollerState): string {
166
+ if (state.previousIndexes.length === 0) return state.previousText
167
+ const lastIndex = state.previousIndexes[state.previousIndexes.length - 1]!
168
+ return state.previousText.split(" ").slice(lastIndex).join(" ")
169
+ }
170
+
171
+ private getSecondLastLineOfState(state: ScrollerState): string {
172
+ if (state.previousIndexes.length === 0) return ""
173
+ const lastIndex = state.previousIndexes[state.previousIndexes.length - 1]!
174
+ let beforeLastIndex = 0
175
+ if (state.previousIndexes.length > 1) {
176
+ beforeLastIndex = state.previousIndexes[state.previousIndexes.length - 2]!
177
+ }
178
+ return state.previousText.split(" ").slice(beforeLastIndex, lastIndex).join(" ")
179
+ }
180
+
181
+ private textWidth(text: string): number {
182
+ const ctx = this.canvas.getContext("2d")!
183
+ ctx.font = `${this.fontSize}px ${this.font}`
184
+ return ctx.measureText(text).width
185
+ }
186
+
187
+ // Normal cut threshold: keeps both inline margins free.
188
+ private computeIfTextIsTooLong(text: string): boolean {
189
+ return this.textWidth(text) > this.canvas.width - 2 * this.paddingInline
190
+ }
191
+
192
+ // Overflow threshold: the text is drawn at x = paddingInline, so it reaches the
193
+ // canvas edge once its width exceeds canvas.width - paddingInline. Spilling into
194
+ // the inline margin is tolerated; going past this re-cuts an already-shown line.
195
+ private computeIfTextOverflows(text: string): boolean {
196
+ return this.textWidth(text) > this.canvas.width - this.paddingInline
197
+ }
198
+ }
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue"
3
+ import { useCore } from "@linto-ai/transcript-ui-core"
4
+ import { parseWatermark } from "./utils/parseWatermark"
5
+
6
+ defineProps<{ visible: boolean }>()
7
+
8
+ const core = useCore()
9
+ const watermark = core.subtitle?.watermark
10
+
11
+ const parts = computed(() => {
12
+ if (!watermark) return []
13
+ return parseWatermark(watermark.content.value, watermark.tokens.value)
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <Transition name="watermark">
19
+ <div v-if="visible && watermark" class="watermark" aria-hidden="true">
20
+ <template v-for="(part, i) in parts" :key="i">
21
+ <img
22
+ v-if="part.type === 'token'"
23
+ :src="part.src"
24
+ :alt="part.alt"
25
+ class="watermark__img" />
26
+ <span v-else>{{ part.value }}</span>
27
+ </template>
28
+ </div>
29
+ </Transition>
30
+ </template>
31
+
32
+ <style scoped>
33
+ .watermark {
34
+ position: absolute;
35
+ right: var(--spacing-md, 16px);
36
+ bottom: 4px;
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 0.25em;
40
+ font-size: 1.2rem;
41
+ color: var(--color-white, #fff);
42
+ pointer-events: none;
43
+ line-height: 1;
44
+ }
45
+
46
+ .watermark__img {
47
+ height: 1em;
48
+ vertical-align: middle;
49
+ }
50
+
51
+ .watermark-enter-active,
52
+ .watermark-leave-active {
53
+ transition:
54
+ opacity 0.4s ease,
55
+ transform 0.4s ease;
56
+ }
57
+
58
+ .watermark-enter-from,
59
+ .watermark-leave-to {
60
+ opacity: 0;
61
+ transform: translate(6px, 6px);
62
+ }
63
+
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .watermark-enter-active,
66
+ .watermark-leave-active {
67
+ transition: opacity 0.01s;
68
+ transform: none;
69
+ }
70
+ }
71
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { ref, watch } from "vue"
2
+ import type {
3
+ Core,
4
+ CorePlugin,
5
+ SubtitlePluginApi,
6
+ WatermarkPluginApi,
7
+ WatermarkToken,
8
+ } from "@linto-ai/transcript-ui-core"
9
+ import SubtitleBanner from "./SubtitleBanner.vue"
10
+ import SubtitleFullscreen from "./SubtitleFullscreen.vue"
11
+
12
+ export type { SubtitlePluginApi, WatermarkPluginApi, WatermarkToken }
13
+ export { SubtitleBanner, SubtitleFullscreen }
14
+
15
+ export interface WatermarkOptions {
16
+ display?: boolean
17
+ pinned?: boolean
18
+ content?: string
19
+ frequency?: number
20
+ duration?: number
21
+ tokens?: Record<string, WatermarkToken>
22
+ readonly?: boolean
23
+ }
24
+
25
+ export interface SubtitlePluginOptions {
26
+ fontSize?: number
27
+ isVisible?: boolean
28
+ watermark?: WatermarkOptions
29
+ }
30
+
31
+ export function createSubtitlePlugin(
32
+ options: SubtitlePluginOptions = {},
33
+ ): CorePlugin {
34
+ return {
35
+ name: "subtitle",
36
+ components: { subtitleBanner: SubtitleBanner, subtitleFullscreen: SubtitleFullscreen },
37
+
38
+ install(core: Core) {
39
+ const fontSize = ref(options.fontSize ?? 40)
40
+ const isVisible = ref(options.isVisible ?? false)
41
+ const isFullscreen = ref(false)
42
+
43
+ let watermark: WatermarkPluginApi | undefined
44
+ const unwatchers: Array<() => void> = []
45
+ if (options.watermark) {
46
+ const w = options.watermark
47
+ watermark = {
48
+ display: ref(w.display ?? false),
49
+ pinned: ref(w.pinned ?? false),
50
+ content: ref(w.content ?? ""),
51
+ frequency: ref(w.frequency ?? 30),
52
+ duration: ref(w.duration ?? 5),
53
+ tokens: ref(w.tokens ?? {}),
54
+ readonly: w.readonly ?? false,
55
+ }
56
+ unwatchers.push(
57
+ watch(watermark.display, (display) =>
58
+ core.emit("watermark:display", { display }),
59
+ ),
60
+ watch(watermark.pinned, (pinned) =>
61
+ core.emit("watermark:pin", { pinned }),
62
+ ),
63
+ )
64
+ }
65
+
66
+ const api: SubtitlePluginApi = {
67
+ fontSize,
68
+ isVisible,
69
+ isFullscreen,
70
+ enterFullscreen() {
71
+ isFullscreen.value = true
72
+ },
73
+ exitFullscreen() {
74
+ isFullscreen.value = false
75
+ },
76
+ watermark,
77
+ }
78
+
79
+ core.subtitle = api
80
+
81
+ return () => {
82
+ isVisible.value = false
83
+ isFullscreen.value = false
84
+ unwatchers.forEach((stop) => stop())
85
+ core.subtitle = undefined
86
+ }
87
+ },
88
+ }
89
+ }