@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export type {
29
29
  OverlayFactory,
30
30
  RenderEvent,
31
31
  RenderOptions,
32
+ CaptionEvent,
33
+ GenerateCaptionsOptions,
32
34
  MediaScope,
33
35
  MediaItem,
34
36
  GlobalOverlay,
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"