@bycrux/editor 0.5.1 → 0.5.3
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 +1 -1
- package/src/index.ts +2 -0
- package/src/types.ts +41 -0
- package/src/video/CaptionRegenModal.tsx +177 -0
- package/src/video/VideoEditor.tsx +20 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +65 -0
- package/src/video/preview/useVideoPlayback.ts +37 -0
- package/src/video/timeline/Timeline.tsx +6 -1
- package/src/video/timeline/TranscriptPanel.tsx +12 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
CarouselElement,
|
|
22
22
|
ImageElement,
|
|
23
23
|
OverlayElement,
|
|
24
|
+
Captions,
|
|
24
25
|
} from './schema'
|
|
25
26
|
|
|
26
27
|
// ── Overlay compiler ─────────────────────────────────────────────────────────
|
|
@@ -68,6 +69,35 @@ export interface RenderOptions {
|
|
|
68
69
|
scale?: number
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// ── Caption regeneration ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A single frame of caption-regeneration progress. Discriminated on `type`:
|
|
76
|
+
* - 'log' — a human-readable progress line.
|
|
77
|
+
* - 'done' — terminal success; `captions` is the freshly transcribed caption
|
|
78
|
+
* track to patch onto the project.
|
|
79
|
+
* - 'error' — terminal failure; `message` describes what went wrong (the host
|
|
80
|
+
* route may emit `multi_source`/`no_clips`/`empty_keeps` — shown
|
|
81
|
+
* verbatim).
|
|
82
|
+
*/
|
|
83
|
+
export type CaptionEvent =
|
|
84
|
+
| { type: 'log'; message: string }
|
|
85
|
+
| { type: 'done'; captions: Captions }
|
|
86
|
+
| { type: 'error'; message: string }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Options for a caption-regeneration request. All optional — the host fills in
|
|
90
|
+
* sensible defaults (multilingual whisper model + auto-detected language).
|
|
91
|
+
*/
|
|
92
|
+
export interface GenerateCaptionsOptions {
|
|
93
|
+
/** Whisper model to run (e.g. 'large'). */
|
|
94
|
+
model?: string
|
|
95
|
+
/** Source-language hint (e.g. 'es'); omit to auto-detect. */
|
|
96
|
+
language?: string
|
|
97
|
+
/** Caption style to seed the regenerated track with. */
|
|
98
|
+
style?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
// ── Overlay library types ─────────────────────────────────────────────────────
|
|
72
102
|
// Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
|
|
73
103
|
// shape the editor consumes. A host's overlay-listing endpoints return these;
|
|
@@ -319,6 +349,17 @@ export interface EditorAdapter<P extends Project = Project> {
|
|
|
319
349
|
* caption support omit this entirely.
|
|
320
350
|
*/
|
|
321
351
|
resolveCaptionTemplate?(style: string): string
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Optional: regenerate the project's caption track by re-running multilingual
|
|
355
|
+
* transcription on the host's sidecar, streaming progress as an async iterable
|
|
356
|
+
* of `CaptionEvent`s. The iterable completes after a terminal 'done' (carrying
|
|
357
|
+
* the fresh `Captions`) or 'error'. The host persists the captions server-side;
|
|
358
|
+
* the editor patches `project.captions` from the 'done' event. Hosts without a
|
|
359
|
+
* transcription pipeline omit this; the editor feature-detects its absence and
|
|
360
|
+
* hides the "Regenerate captions" control.
|
|
361
|
+
*/
|
|
362
|
+
generateCaptions?(id: string, opts?: GenerateCaptionsOptions): AsyncIterable<CaptionEvent>
|
|
322
363
|
}
|
|
323
364
|
|
|
324
365
|
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { EditorAdapter, Project } from '../types'
|
|
3
|
+
import type { Captions } from '../schema'
|
|
4
|
+
|
|
5
|
+
interface CaptionRegenModalProps<P extends Project = Project> {
|
|
6
|
+
projectId: string
|
|
7
|
+
/** Adapter driving the caption-regeneration stream. Must implement
|
|
8
|
+
* `generateCaptions` — callers gate rendering on its presence. */
|
|
9
|
+
adapter: EditorAdapter<P>
|
|
10
|
+
/** Fired on terminal success with the freshly transcribed caption track. The
|
|
11
|
+
* caller patches `project.captions` from this; the modal then closes. */
|
|
12
|
+
onDone: (captions: Captions) => void
|
|
13
|
+
/** Fired when the modal closes (cancel, error dismiss, or post-done). */
|
|
14
|
+
onClose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function LogLine({ text }: { text: string }) {
|
|
18
|
+
let color = 'text-gray-400'
|
|
19
|
+
if (/ready|complete|done|transcribed/i.test(text)) color = 'text-green-400'
|
|
20
|
+
else if (/transcrib|detecting|loading|model/i.test(text)) color = 'text-sky-400'
|
|
21
|
+
else if (/extract|building|composing/i.test(text)) color = 'text-amber-400'
|
|
22
|
+
else if (/error|fail|warn/i.test(text)) color = 'text-red-400'
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
|
|
26
|
+
{text}
|
|
27
|
+
</span>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, onDone, onClose }: CaptionRegenModalProps<P>) {
|
|
32
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
33
|
+
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
34
|
+
const [errorMsg, setError] = useState<string | null>(null)
|
|
35
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
36
|
+
const cancelledRef = useRef(false)
|
|
37
|
+
const unmountedRef = useRef(false)
|
|
38
|
+
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
// React StrictMode in dev fires mount → cleanup → mount synchronously to
|
|
42
|
+
// catch effects that aren't idempotent. Triggering transcription is a
|
|
43
|
+
// non-idempotent effect (spawns sidecar work), so we defer the teardown in
|
|
44
|
+
// cleanup, and if the next mount fires within the same tick we rescue the
|
|
45
|
+
// pending teardown — mirroring RenderModal's StrictMode handling.
|
|
46
|
+
if (cleanupTimerRef.current !== null) {
|
|
47
|
+
clearTimeout(cleanupTimerRef.current)
|
|
48
|
+
cleanupTimerRef.current = null
|
|
49
|
+
unmountedRef.current = false
|
|
50
|
+
cancelledRef.current = false
|
|
51
|
+
return scheduleCleanup
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
unmountedRef.current = false
|
|
55
|
+
cancelledRef.current = false
|
|
56
|
+
|
|
57
|
+
void (async () => {
|
|
58
|
+
try {
|
|
59
|
+
for await (const ev of adapter.generateCaptions!(projectId)) {
|
|
60
|
+
if (unmountedRef.current || cancelledRef.current) break
|
|
61
|
+
if (ev.type === 'log') {
|
|
62
|
+
setLogs(l => [...l, ev.message])
|
|
63
|
+
} else if (ev.type === 'done') {
|
|
64
|
+
setStatus('done')
|
|
65
|
+
onDone(ev.captions)
|
|
66
|
+
} else {
|
|
67
|
+
setError(ev.message)
|
|
68
|
+
setStatus('error')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (!unmountedRef.current && !cancelledRef.current) {
|
|
73
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
74
|
+
setStatus('error')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})()
|
|
78
|
+
|
|
79
|
+
return scheduleCleanup
|
|
80
|
+
|
|
81
|
+
function scheduleCleanup() {
|
|
82
|
+
cleanupTimerRef.current = setTimeout(() => {
|
|
83
|
+
cleanupTimerRef.current = null
|
|
84
|
+
unmountedRef.current = true
|
|
85
|
+
}, 0)
|
|
86
|
+
}
|
|
87
|
+
}, [projectId, adapter, onDone])
|
|
88
|
+
|
|
89
|
+
// Auto-scroll logs
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
92
|
+
}, [logs])
|
|
93
|
+
|
|
94
|
+
// Escape to close only when not running
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const onKey = (e: KeyboardEvent) => {
|
|
97
|
+
if (e.key === 'Escape' && status !== 'running') onClose()
|
|
98
|
+
}
|
|
99
|
+
document.addEventListener('keydown', onKey)
|
|
100
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
101
|
+
}, [status, onClose])
|
|
102
|
+
|
|
103
|
+
function handleCancel() {
|
|
104
|
+
cancelledRef.current = true
|
|
105
|
+
onClose()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
110
|
+
<div className="w-full max-w-3xl bg-gray-900 border border-gray-700 rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
111
|
+
|
|
112
|
+
{/* Header */}
|
|
113
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
|
114
|
+
<div className="flex items-center gap-2.5">
|
|
115
|
+
{status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
116
|
+
{status === 'done' && <span className="w-2 h-2 rounded-full bg-green-400" />}
|
|
117
|
+
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
118
|
+
<div className="flex flex-col gap-0.5">
|
|
119
|
+
<h2 className="text-sm font-semibold text-white">
|
|
120
|
+
{status === 'running' ? 'Regenerating captions…'
|
|
121
|
+
: status === 'done' ? 'Captions regenerated'
|
|
122
|
+
: 'Caption regeneration failed'}
|
|
123
|
+
</h2>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
{status !== 'running' && (
|
|
127
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Log output */}
|
|
132
|
+
<div className="relative">
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
135
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-gray-800 border border-gray-700 text-gray-400 hover:text-white hover:border-gray-500 transition-colors"
|
|
136
|
+
title="Copy logs"
|
|
137
|
+
>
|
|
138
|
+
Copy
|
|
139
|
+
</button>
|
|
140
|
+
<div
|
|
141
|
+
ref={logRef}
|
|
142
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-gray-300 bg-gray-950 flex flex-col gap-0.5"
|
|
143
|
+
>
|
|
144
|
+
{logs.length === 0 && status === 'running' && (
|
|
145
|
+
<span className="text-gray-600 italic">Starting transcription…</span>
|
|
146
|
+
)}
|
|
147
|
+
{logs.map((line, i) => (
|
|
148
|
+
<LogLine key={i} text={line} />
|
|
149
|
+
))}
|
|
150
|
+
{status === 'error' && errorMsg && (
|
|
151
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* Footer */}
|
|
157
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gray-800">
|
|
158
|
+
{status === 'running' ? (
|
|
159
|
+
<button
|
|
160
|
+
onClick={handleCancel}
|
|
161
|
+
className="text-sm px-4 py-1.5 rounded-md bg-gray-800 border border-gray-700 text-gray-300 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
|
|
162
|
+
>
|
|
163
|
+
Cancel
|
|
164
|
+
</button>
|
|
165
|
+
) : (
|
|
166
|
+
<button
|
|
167
|
+
onClick={onClose}
|
|
168
|
+
className="text-sm px-4 py-1.5 rounded-md bg-gray-800 border border-gray-700 text-white hover:bg-gray-700 transition-colors"
|
|
169
|
+
>
|
|
170
|
+
Close
|
|
171
|
+
</button>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -8,6 +8,7 @@ import Timeline from './timeline/Timeline'
|
|
|
8
8
|
import PreviewPlayer from './preview/PreviewPlayer'
|
|
9
9
|
import VersionPanel from './VersionPanel'
|
|
10
10
|
import RenderModal from './RenderModal'
|
|
11
|
+
import CaptionRegenModal from './CaptionRegenModal'
|
|
11
12
|
|
|
12
13
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
13
14
|
// own `Project`). Montaj passes its richer Project; the index signature on
|
|
@@ -273,6 +274,7 @@ function ReviewSurface<P extends Project>({
|
|
|
273
274
|
const primarySelectedId = selectedIds[0] ?? null
|
|
274
275
|
const [rippleMode, setRippleMode] = useState(false)
|
|
275
276
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
277
|
+
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
276
278
|
// The clip/audio inspector target — derived from the timeline's inspect
|
|
277
279
|
// callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
|
|
278
280
|
const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
|
|
@@ -471,6 +473,7 @@ function ReviewSurface<P extends Project>({
|
|
|
471
473
|
regenEnabled={regenEnabled}
|
|
472
474
|
isClipQueued={isClipQueued}
|
|
473
475
|
renderSubcutRegen={renderSubcutRegen}
|
|
476
|
+
onRegenerateCaptions={adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined}
|
|
474
477
|
/>
|
|
475
478
|
</div>
|
|
476
479
|
</div>
|
|
@@ -509,6 +512,23 @@ function ReviewSurface<P extends Project>({
|
|
|
509
512
|
/>
|
|
510
513
|
)}
|
|
511
514
|
|
|
515
|
+
{/* Caption regen modal — adapter.generateCaptions stream. On done we patch
|
|
516
|
+
project.captions via onProjectChange only. We deliberately do NOT call
|
|
517
|
+
save(): montaj persists the regenerated captions server-side and the
|
|
518
|
+
SSE subscribe frame reconciles, so a saveProject here would double-write. */}
|
|
519
|
+
{regenCaptionsOpen && adapter.generateCaptions && (
|
|
520
|
+
<CaptionRegenModal
|
|
521
|
+
adapter={adapter}
|
|
522
|
+
projectId={project.id}
|
|
523
|
+
onClose={() => setRegenCaptionsOpen(false)}
|
|
524
|
+
onDone={(captions) => {
|
|
525
|
+
const next = { ...project, captions } as P
|
|
526
|
+
onProjectChange(next)
|
|
527
|
+
setRegenCaptionsOpen(false)
|
|
528
|
+
}}
|
|
529
|
+
/>
|
|
530
|
+
)}
|
|
531
|
+
|
|
512
532
|
{/* Clip / audio inspector — host-rendered via render-prop seam. */}
|
|
513
533
|
{inspecting && renderClipInspector?.({
|
|
514
534
|
item: inspecting,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, waitFor, cleanup } from '@testing-library/react'
|
|
3
|
+
import type { CaptionEvent, EditorAdapter, ImageElement, Project } from '../../types'
|
|
4
|
+
import type { Captions } from '../../schema'
|
|
5
|
+
import CaptionRegenModal from '../CaptionRegenModal'
|
|
6
|
+
|
|
7
|
+
afterEach(() => cleanup())
|
|
8
|
+
|
|
9
|
+
const doneCaptions: Captions = {
|
|
10
|
+
style: 'pop',
|
|
11
|
+
segments: [{ text: 'hola', start: 0, end: 1, words: [] }],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeAdapter(): EditorAdapter<Project> {
|
|
15
|
+
return {
|
|
16
|
+
loadProject: vi.fn(),
|
|
17
|
+
saveProject: vi.fn(),
|
|
18
|
+
subscribe: () => () => {},
|
|
19
|
+
render: async function* () {},
|
|
20
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
21
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
22
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
23
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
24
|
+
uploadFile: vi.fn(async () => ''),
|
|
25
|
+
fileUrl: (p: string) => p,
|
|
26
|
+
generateCaptions: async function* (): AsyncIterable<CaptionEvent> {
|
|
27
|
+
yield { type: 'log', message: 'transcribing audio…' }
|
|
28
|
+
yield { type: 'done', captions: doneCaptions }
|
|
29
|
+
},
|
|
30
|
+
} as unknown as EditorAdapter<Project>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('CaptionRegenModal', () => {
|
|
34
|
+
it('streams a log line and calls onDone with the final captions', async () => {
|
|
35
|
+
const onDone = vi.fn()
|
|
36
|
+
const onClose = vi.fn()
|
|
37
|
+
render(
|
|
38
|
+
<CaptionRegenModal
|
|
39
|
+
adapter={makeAdapter()}
|
|
40
|
+
projectId="vid-1"
|
|
41
|
+
onDone={onDone}
|
|
42
|
+
onClose={onClose}
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
|
|
47
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('shows an error message verbatim on error', async () => {
|
|
51
|
+
const errorAdapter = makeAdapter()
|
|
52
|
+
errorAdapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
|
|
53
|
+
yield { type: 'error', message: 'multi_source' }
|
|
54
|
+
}
|
|
55
|
+
render(
|
|
56
|
+
<CaptionRegenModal
|
|
57
|
+
adapter={errorAdapter}
|
|
58
|
+
projectId="vid-1"
|
|
59
|
+
onDone={vi.fn()}
|
|
60
|
+
onClose={vi.fn()}
|
|
61
|
+
/>,
|
|
62
|
+
)
|
|
63
|
+
await waitFor(() => expect(screen.getByText('multi_source')).toBeTruthy())
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -50,6 +50,10 @@ export function useVideoPlayback(
|
|
|
50
50
|
const loopOffsetRef = useRef(0)
|
|
51
51
|
const rafRef = useRef<number | null>(null)
|
|
52
52
|
const rafLastMs = useRef<number | null>(null)
|
|
53
|
+
// rAF clock for VIDEO projects — drives clip-boundary detection at ~60Hz
|
|
54
|
+
// instead of the <video> element's coarse `timeupdate` event (~4Hz). See the
|
|
55
|
+
// effect below for why.
|
|
56
|
+
const videoRafRef = useRef<number | null>(null)
|
|
53
57
|
const audioRefsMap = useRef<Map<string, HTMLAudioElement>>(new Map())
|
|
54
58
|
const audioSrcMap = useRef<Map<string, string>>(new Map())
|
|
55
59
|
// Web Audio API: GainNode per audio track allows volume > 1.0 (amplification).
|
|
@@ -693,6 +697,39 @@ export function useVideoPlayback(
|
|
|
693
697
|
handleTimeUpdate()
|
|
694
698
|
}, [handleTimeUpdate])
|
|
695
699
|
|
|
700
|
+
// ── Video boundary clock ─────────────────────────────────────────────────
|
|
701
|
+
// Drive clip-boundary detection from requestAnimationFrame (~60Hz) rather
|
|
702
|
+
// than relying on the <video> element's `timeupdate` event, which only fires
|
|
703
|
+
// ~every 250ms. Under timeupdate-gating the active clip plays up to a full
|
|
704
|
+
// ~250ms PAST its outPoint before the swap fires; on a silence-trimmed
|
|
705
|
+
// single-source timeline that overshoot is trimmed-out footage playing past
|
|
706
|
+
// the cut — the "underlying video keeps playing in the background" bug.
|
|
707
|
+
// Polling currentTime every frame tightens the boundary to ~16ms.
|
|
708
|
+
// handleTimeUpdate is idempotent (preload + swap are guarded), so the
|
|
709
|
+
// timeupdate event firing in addition to this is harmless. Canvas projects
|
|
710
|
+
// advance time via their own rAF above and are excluded here.
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (isCanvasProject || !isPlaying) {
|
|
713
|
+
if (videoRafRef.current !== null) {
|
|
714
|
+
cancelAnimationFrame(videoRafRef.current)
|
|
715
|
+
videoRafRef.current = null
|
|
716
|
+
}
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
function pump() {
|
|
720
|
+
// The gap clock owns time during gaps; handleTimeUpdate no-ops then.
|
|
721
|
+
if (!inGapRef.current) handleTimeUpdate()
|
|
722
|
+
videoRafRef.current = requestAnimationFrame(pump)
|
|
723
|
+
}
|
|
724
|
+
videoRafRef.current = requestAnimationFrame(pump)
|
|
725
|
+
return () => {
|
|
726
|
+
if (videoRafRef.current !== null) {
|
|
727
|
+
cancelAnimationFrame(videoRafRef.current)
|
|
728
|
+
videoRafRef.current = null
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}, [isPlaying, isCanvasProject, handleTimeUpdate])
|
|
732
|
+
|
|
696
733
|
function togglePlay() {
|
|
697
734
|
// GESTURE-ANCHORED: same rationale as the keydown handler — resume the
|
|
698
735
|
// AudioContext synchronously inside this user-gesture call so a wired
|
|
@@ -47,10 +47,14 @@ interface TimelineProps {
|
|
|
47
47
|
* regenQueue, storyboard, and onSave — none of which the package types know.
|
|
48
48
|
* Absent → the subcut tool is simply not rendered. */
|
|
49
49
|
renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
|
|
50
|
+
/** Opens the caption-regeneration modal. Threaded down to TranscriptPanel.
|
|
51
|
+
* Provided only when the host adapter supports `generateCaptions`; absent →
|
|
52
|
+
* the "Regenerate" button is hidden. */
|
|
53
|
+
onRegenerateCaptions?: () => void
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
|
|
53
|
-
export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen }: TimelineProps) {
|
|
57
|
+
export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
|
|
54
58
|
const primarySelectedId = selectedIds[0] ?? null
|
|
55
59
|
|
|
56
60
|
// Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
|
|
@@ -355,6 +359,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
355
359
|
onCaptionEdit={onCaptionEdit}
|
|
356
360
|
onProjectChange={onProjectChange}
|
|
357
361
|
onExpand={() => setTranscriptModalOpen(true)}
|
|
362
|
+
onRegenerateCaptions={onRegenerateCaptions}
|
|
358
363
|
/>
|
|
359
364
|
|
|
360
365
|
{/* ── Transcript modal ── */}
|
|
@@ -10,9 +10,12 @@ interface TranscriptPanelProps {
|
|
|
10
10
|
onCaptionEdit?: (project: Project) => void
|
|
11
11
|
onProjectChange?: (project: Project) => void
|
|
12
12
|
onExpand: () => void
|
|
13
|
+
/** Opens the caption-regeneration modal. Provided only when the host adapter
|
|
14
|
+
* supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
|
|
15
|
+
onRegenerateCaptions?: () => void
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand }: TranscriptPanelProps) {
|
|
18
|
+
export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
|
|
16
19
|
const segs = captionTrack?.segments ?? []
|
|
17
20
|
// Find active segment index
|
|
18
21
|
const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
|
|
@@ -49,6 +52,14 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
|
|
|
49
52
|
</button>
|
|
50
53
|
)
|
|
51
54
|
})}
|
|
55
|
+
{onRegenerateCaptions && (
|
|
56
|
+
<button
|
|
57
|
+
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"
|
|
58
|
+
onClick={() => onRegenerateCaptions?.()}
|
|
59
|
+
>
|
|
60
|
+
Regenerate
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
52
63
|
{segs.length > 0 && (
|
|
53
64
|
<button
|
|
54
65
|
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"
|