@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/LICENSE +661 -0
- package/README.md +29 -0
- package/package.json +48 -0
- package/src/SubtitleBanner.vue +81 -0
- package/src/SubtitleFullscreen.vue +148 -0
- package/src/SubtitleScroller.ts +198 -0
- package/src/SubtitleWatermark.vue +71 -0
- package/src/index.ts +89 -0
- package/src/useSubtitleScroller.ts +66 -0
- package/src/useWatermarkCycle.ts +59 -0
- package/src/utils/parseWatermark.ts +39 -0
- package/src/utils/splitPartialSubtitles.ts +200 -0
- package/src/utils/test/parseWatermark.test.ts +104 -0
- package/src/utils/test/splitPartialSubtitles.test.ts +180 -0
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
|
+
}
|