@bycrux/editor 0.8.8 → 0.8.9
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/package.json
CHANGED
package/src/schema.ts
CHANGED
|
@@ -43,13 +43,15 @@ export interface CaptionSegment {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Captions {
|
|
46
|
-
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle'
|
|
46
|
+
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
|
|
47
47
|
segments: CaptionSegment[]
|
|
48
48
|
// ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
|
|
49
49
|
position?: 'center' | 'top-left' | 'bottom-left'
|
|
50
50
|
color?: string
|
|
51
51
|
fontsize?: number
|
|
52
52
|
bgColor?: string
|
|
53
|
+
accentColor?: string // active-word/box accent color — highlight-box, outline
|
|
54
|
+
googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface VisualItem {
|
|
@@ -58,8 +58,17 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
58
58
|
|
|
59
59
|
const frame = Math.round(currentTime * fps)
|
|
60
60
|
const lastSeg = track.segments[track.segments.length - 1]
|
|
61
|
+
|
|
62
|
+
// Theme props for the template: everything on the track except style/segments
|
|
63
|
+
// (handled separately) and googleFonts (a render-time font-loading hint, not
|
|
64
|
+
// a template prop). Normalize the legacy lowercase `fontsize` key to the
|
|
65
|
+
// camelCase `fontSize` templates actually read.
|
|
66
|
+
const { style: _style, segments: _segments, googleFonts: _googleFonts, fontsize, ...theme } = track
|
|
67
|
+
const themeProps: Record<string, unknown> = { ...theme }
|
|
68
|
+
if (fontsize != null) themeProps.fontSize = fontsize
|
|
69
|
+
|
|
61
70
|
const element = (factory && scale !== null)
|
|
62
|
-
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
|
|
71
|
+
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments, ...themeProps })
|
|
63
72
|
: null
|
|
64
73
|
|
|
65
74
|
return (
|
|
@@ -138,7 +138,12 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
138
138
|
const fps = project.settings?.fps ?? 30
|
|
139
139
|
const frame = 1 / fps
|
|
140
140
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
141
|
+
// While the transcript modal is open, or the caret is in editable text
|
|
142
|
+
// (caption segments are contentEditable), arrows move the text cursor —
|
|
143
|
+
// never the playhead.
|
|
144
|
+
if (transcriptModalOpen) return
|
|
141
145
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
146
|
+
if ((e.target as HTMLElement).isContentEditable) return
|
|
142
147
|
if (e.key === 'Escape') { setMarkers([null, null]); return }
|
|
143
148
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
144
149
|
e.preventDefault()
|
|
@@ -152,7 +157,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
152
157
|
}
|
|
153
158
|
document.addEventListener('keydown', onKey)
|
|
154
159
|
return () => document.removeEventListener('keydown', onKey)
|
|
155
|
-
}, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps])
|
|
160
|
+
}, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
|
|
156
161
|
|
|
157
162
|
// Derive selection from two placed markers
|
|
158
163
|
const selection = markers[0] !== null && markers[1] !== null
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
1
2
|
import type { Project } from '../../types'
|
|
2
3
|
import { formatTime } from './utils'
|
|
3
4
|
import { EditableSegment } from './EditableSegment'
|
|
@@ -17,6 +18,21 @@ interface TranscriptPanelProps {
|
|
|
17
18
|
|
|
18
19
|
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
|
|
19
20
|
const segs = captionTrack?.segments ?? []
|
|
21
|
+
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
22
|
+
const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
23
|
+
// Live slider value during a drag — only committed (persisted) on
|
|
24
|
+
// pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
|
|
25
|
+
const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setFontsize(captionTrack?.fontsize ?? 46)
|
|
29
|
+
}, [captionTrack?.fontsize])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return () => {
|
|
33
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
34
|
+
}
|
|
35
|
+
}, [])
|
|
20
36
|
// Find active segment index
|
|
21
37
|
const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
|
|
22
38
|
const nearIdx = activeIdx !== -1 ? activeIdx
|
|
@@ -31,8 +47,8 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
31
47
|
<div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
|
|
32
48
|
<div className="flex items-center justify-between mb-2">
|
|
33
49
|
<span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
|
|
34
|
-
<div className="flex items-center gap-2">
|
|
35
|
-
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle'] as const).map(style => {
|
|
50
|
+
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
51
|
+
{captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
|
|
36
52
|
const active = captionTrack.style === style
|
|
37
53
|
return (
|
|
38
54
|
<button
|
|
@@ -52,6 +68,38 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
52
68
|
</button>
|
|
53
69
|
)
|
|
54
70
|
})}
|
|
71
|
+
{captionTrack && (
|
|
72
|
+
<div className="flex items-center gap-1">
|
|
73
|
+
<input
|
|
74
|
+
type="range"
|
|
75
|
+
min={28}
|
|
76
|
+
max={120}
|
|
77
|
+
step={2}
|
|
78
|
+
value={fontsize}
|
|
79
|
+
className="w-20 sm:w-24 accent-purple-500"
|
|
80
|
+
onChange={e => {
|
|
81
|
+
if (!project.captions) return
|
|
82
|
+
const v = Number(e.target.value)
|
|
83
|
+
setFontsize(v)
|
|
84
|
+
// Live preview only — cheap local-state update, no save.
|
|
85
|
+
onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
86
|
+
}}
|
|
87
|
+
onPointerUp={e => {
|
|
88
|
+
if (!project.captions) return
|
|
89
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
90
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
91
|
+
}}
|
|
92
|
+
onKeyUp={e => {
|
|
93
|
+
if (!project.captions) return
|
|
94
|
+
const v = Number((e.target as HTMLInputElement).value)
|
|
95
|
+
onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
|
|
99
|
+
{fontsize}px
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
55
103
|
{onRegenerateCaptions && (
|
|
56
104
|
<button
|
|
57
105
|
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|
|
@@ -60,6 +108,30 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
60
108
|
Regenerate
|
|
61
109
|
</button>
|
|
62
110
|
)}
|
|
111
|
+
{captionTrack && (
|
|
112
|
+
<button
|
|
113
|
+
className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
|
|
114
|
+
confirmRemove
|
|
115
|
+
? 'bg-red-500/20 border-red-500/60 text-red-400'
|
|
116
|
+
: 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
|
|
117
|
+
}`}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
if (!confirmRemove) {
|
|
120
|
+
setConfirmRemove(true)
|
|
121
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
122
|
+
removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
|
|
126
|
+
setConfirmRemove(false)
|
|
127
|
+
// Persistence needs an explicit `null` to clear the field server-side
|
|
128
|
+
// (a live PUT test confirmed `undefined` is dropped from the JSON body).
|
|
129
|
+
onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{confirmRemove ? 'Really remove?' : 'Remove'}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
63
135
|
{segs.length > 0 && (
|
|
64
136
|
<button
|
|
65
137
|
className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
|