@bycrux/editor 0.8.5 → 0.8.6

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.8.5",
3
+ "version": "0.8.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/types.ts CHANGED
@@ -544,6 +544,21 @@ export interface VideoEditorProps<P extends Project = Project> {
544
544
  */
545
545
  assetsPlacement?: 'sidebar' | 'right' | 'bottom'
546
546
 
547
+ /**
548
+ * Which progress UI the RenderModal shows while a render runs:
549
+ * - `'phases'` — the compact phase stepper (Preparing → Rendering → … →
550
+ * Saving). Works on any transport: poll-based hosts drive it from the
551
+ * status `phase`; SSE hosts park it on "Rendering". This is the universal
552
+ * default and what Hub clients use.
553
+ * - `'logs'` — the full scrolling render-log panel (colorized lines + Copy).
554
+ * REQUIRES the SSE `adapter.render()` transport, which streams per-line
555
+ * logs; this is the historical montaj-native desktop view. On a poll-based
556
+ * host (no log lines) this panel would sit empty, so only pass `'logs'`
557
+ * from a host whose adapter implements the streaming `render()` path.
558
+ * The host chooses per deployment; the package defaults to `'phases'`.
559
+ */
560
+ renderProgressView?: 'phases' | 'logs'
561
+
547
562
  /**
548
563
  * Opt a host OUT of the package's built-in toolbar Render button so it can
549
564
  * place Render in its own chrome (e.g. the desktop OS editor's top header).
@@ -17,6 +17,16 @@ interface RenderModalProps<P extends Project = Project> {
17
17
  /** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
18
18
  * in the done state's action area, mirroring the carousel render modal. */
19
19
  exportActions?: ReactNode
20
+ /**
21
+ * Which progress UI to show while the render runs:
22
+ * 'phases' — the compact phase stepper (Preparing → … → Saving). Works on
23
+ * any transport; the default when omitted.
24
+ * 'logs' — the full scrolling render-log panel. Requires the SSE
25
+ * `adapter.render()` transport (which streams per-line logs);
26
+ * on a poll-based host it would sit empty.
27
+ * Threaded down from the host as a flag (montaj-native → 'logs', Hub clients
28
+ * → 'phases'), mirroring `VideoEditorProps.renderProgressView`. */
29
+ progressView?: 'phases' | 'logs'
20
30
  }
21
31
 
22
32
  /** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
@@ -113,17 +123,49 @@ function PhaseStepper({ current }: { current: RenderPhase }) {
113
123
  )
114
124
  }
115
125
 
116
- export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions }: RenderModalProps<P>) {
126
+ // ── Log line (SSE / log-panel view) ───────────────────────────────────────────
127
+
128
+ function LogLine({ text }: { text: string }) {
129
+ const t = text.replace(/^\[montaj render\]\s*/, '')
130
+ let color = 'text-[var(--editor-text)]/60'
131
+ if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
132
+ else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
133
+ else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
134
+ else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
135
+ else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
136
+
137
+ const prefix = text.startsWith('[montaj render]')
138
+ ? <span className="text-[var(--editor-text)]/40">[render] </span>
139
+ : null
140
+
141
+ return (
142
+ <span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
143
+ {prefix}{t}
144
+ </span>
145
+ )
146
+ }
147
+
148
+ export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions, progressView }: RenderModalProps<P>) {
117
149
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
118
150
  const [phase, setPhase] = useState<RenderPhase>('preparing')
151
+ const [logs, setLogs] = useState<string[]>([])
119
152
  const [media, setMedia] = useState<RenderMedia[] | null>(null)
120
153
  const [outputPath, setOutput] = useState<string | null>(null)
121
154
  const [errorMsg, setError] = useState<string | null>(null)
155
+ const logRef = useRef<HTMLDivElement>(null)
122
156
  const cancelledRef = useRef(false)
123
157
  const unmountedRef = useRef(false)
124
158
  const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
125
159
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
126
160
 
161
+ // `usePolling` is the data-transport decision (does this adapter expose the
162
+ // poll-based render API?) — independent of which progress UI we show.
163
+ const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
164
+ // `view` is the host's UI choice, threaded down as a flag (montaj-native →
165
+ // 'logs', Hub clients → 'phases'). Defaults to the universally-safe stepper;
166
+ // 'logs' only renders content on the SSE transport, which streams log lines.
167
+ const view = progressView ?? 'phases'
168
+
127
169
  useEffect(() => {
128
170
  // React StrictMode in dev fires mount → cleanup → mount synchronously to
129
171
  // catch effects that aren't idempotent. Triggering a render is the textbook
@@ -145,8 +187,6 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
145
187
  unmountedRef.current = false
146
188
  cancelledRef.current = false
147
189
 
148
- const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
149
-
150
190
  void (async () => {
151
191
  try {
152
192
  if (usePolling) {
@@ -210,8 +250,10 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
210
250
  for await (const ev of adapter.render(projectId)) {
211
251
  if (unmountedRef.current || cancelledRef.current) break
212
252
  if (ev.type === 'log') {
213
- // Streaming hosts have no phase signal — keep the stepper on
214
- // "rendering" as an honest mid-pipeline indicator.
253
+ // Streaming hosts have no phase signal. Accumulate the line for
254
+ // the log panel, and keep the stepper (if shown) on "rendering"
255
+ // as an honest mid-pipeline indicator.
256
+ setLogs(l => [...l, ev.message])
215
257
  setPhase('rendering')
216
258
  } else if (ev.type === 'done') {
217
259
  setOutput(ev.outputPath)
@@ -252,6 +294,11 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
252
294
  }
253
295
  }, [projectId, adapter])
254
296
 
297
+ // Auto-scroll the log panel as lines stream in.
298
+ useEffect(() => {
299
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
300
+ }, [logs])
301
+
255
302
  // Escape to close only when done/error
256
303
  useEffect(() => {
257
304
  const onKey = (e: KeyboardEvent) => {
@@ -341,7 +388,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
341
388
 
342
389
  return createPortal(
343
390
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
344
- <div className="w-full max-w-md bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
391
+ <div className={`w-full ${view === 'logs' ? 'max-w-3xl' : 'max-w-md'} bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden`}>
345
392
 
346
393
  {/* Header */}
347
394
  <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
@@ -359,21 +406,47 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
359
406
  )}
360
407
  </div>
361
408
 
362
- {/* Body */}
363
- <div className="px-5 py-5">
364
- {status === 'running' ? (
365
- <>
366
- <PhaseStepper current={phase} />
367
- <p className="mt-5 text-xs text-[var(--editor-text)]/50">
368
- This can take a few minutes for longer videos.
409
+ {/* Body — full log panel (montaj-native / SSE) or phase stepper (Hub) */}
410
+ {view === 'logs' ? (
411
+ <div className="relative">
412
+ <button
413
+ onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
414
+ className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-border)] transition-colors"
415
+ title="Copy logs"
416
+ >
417
+ Copy
418
+ </button>
419
+ <div
420
+ ref={logRef}
421
+ className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
422
+ >
423
+ {logs.length === 0 && status === 'running' && (
424
+ <span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
425
+ )}
426
+ {logs.map((line, i) => (
427
+ <LogLine key={i} text={line} />
428
+ ))}
429
+ {status === 'error' && errorMsg && (
430
+ <span className="text-red-400 mt-1">{errorMsg}</span>
431
+ )}
432
+ </div>
433
+ </div>
434
+ ) : (
435
+ <div className="px-5 py-5">
436
+ {status === 'running' ? (
437
+ <>
438
+ <PhaseStepper current={phase} />
439
+ <p className="mt-5 text-xs text-[var(--editor-text)]/50">
440
+ This can take a few minutes for longer videos.
441
+ </p>
442
+ </>
443
+ ) : (
444
+ <p className="text-sm text-red-400 whitespace-pre-wrap break-words">
445
+ {errorMsg ?? 'Render failed.'}
369
446
  </p>
370
- </>
371
- ) : (
372
- <p className="text-sm text-red-400 whitespace-pre-wrap break-words">
373
- {errorMsg ?? 'Render failed.'}
374
- </p>
375
- )}
376
- </div>
447
+ )}
448
+ </div>
449
+ )}
377
450
 
378
451
  {/* Footer */}
379
452
  <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
@@ -41,6 +41,7 @@ export default function VideoEditor<P extends Project = Project>({
41
41
  slots,
42
42
  onBackToSetup,
43
43
  assetsPlacement = 'right',
44
+ renderProgressView = 'phases',
44
45
  renderClipInspector,
45
46
  renderSubcutRegen,
46
47
  regenEnabled,
@@ -87,6 +88,7 @@ export default function VideoEditor<P extends Project = Project>({
87
88
  onProjectChange={emit}
88
89
  slots={slots}
89
90
  assetsPlacement={assetsPlacement}
91
+ renderProgressView={renderProgressView}
90
92
  getWaveformChunks={getWaveformChunks}
91
93
  resolveFilePath={resolveFilePath}
92
94
  save={save}
@@ -121,6 +123,7 @@ interface SurfaceProps<P extends Project> {
121
123
  onProjectChange: (p: P) => void
122
124
  slots?: VideoEditorProps<P>['slots']
123
125
  assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
126
+ renderProgressView?: VideoEditorProps<P>['renderProgressView']
124
127
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
125
128
  resolveFilePath: (path: string) => string
126
129
  save: (p: P) => void
@@ -261,6 +264,7 @@ function ReviewSurface<P extends Project>({
261
264
  onProjectChange,
262
265
  slots,
263
266
  assetsPlacement = 'right',
267
+ renderProgressView = 'phases',
264
268
  getWaveformChunks,
265
269
  resolveFilePath,
266
270
  save,
@@ -615,6 +619,7 @@ function ReviewSurface<P extends Project>({
615
619
  projectId={project.id}
616
620
  adapter={adapter}
617
621
  exportActions={slots?.exportActions}
622
+ progressView={renderProgressView}
618
623
  onClose={() => setRenderOpen(false)}
619
624
  onCancel={() => setRenderOpen(false)}
620
625
  />
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { renderHook } from '@testing-library/react'
3
+ import { useItemDragDrop } from '../useItemDragDrop'
4
+
5
+ // A press that drifts < 4px must stay a CLICK (so the timeline's click-to-select
6
+ // fires); only a press that travels past the threshold becomes a drag. Regression
7
+ // guard for the bug where any pixel of jitter suppressed clip selection.
8
+
9
+ const RECT = {
10
+ width: 1000, height: 100, left: 0, top: 0, right: 1000, bottom: 100, x: 0, y: 0,
11
+ toJSON: () => ({}),
12
+ } as DOMRect
13
+
14
+ function setup() {
15
+ const draggedFlagRef = { current: false }
16
+ const scrollRef = { current: { getBoundingClientRect: () => RECT } }
17
+ const { result } = renderHook(() =>
18
+ useItemDragDrop({
19
+ totalDuration: 100,
20
+ snapBoundaries: [],
21
+ scrollRef: scrollRef as unknown as React.RefObject<HTMLDivElement>,
22
+ draggedFlagRef: draggedFlagRef as unknown as React.MutableRefObject<boolean>,
23
+ }),
24
+ )
25
+ return { beginDrag: result.current.beginDrag, draggedFlagRef }
26
+ }
27
+
28
+ const ITEM = { id: 'a', start: 10, end: 20 }
29
+
30
+ function press(beginDrag: ReturnType<typeof setup>['beginDrag']) {
31
+ const onLivePreview = vi.fn()
32
+ const onCommit = vi.fn()
33
+ beginDrag(
34
+ { stopPropagation: vi.fn(), clientX: 200, clientY: 200 } as unknown as React.MouseEvent,
35
+ ITEM,
36
+ { onLivePreview, onCommit },
37
+ )
38
+ return { onLivePreview, onCommit }
39
+ }
40
+
41
+ afterEach(() => {
42
+ // ensure no stray listeners between tests
43
+ document.dispatchEvent(new MouseEvent('mouseup'))
44
+ vi.restoreAllMocks()
45
+ })
46
+
47
+ describe('useItemDragDrop — click vs drag threshold', () => {
48
+ it('sub-threshold drift stays a click: no drag flag, no move, no commit', () => {
49
+ const { beginDrag, draggedFlagRef } = setup()
50
+ const { onLivePreview, onCommit } = press(beginDrag)
51
+
52
+ // ~2.2px of jitter (hypot(2,1)) — under the 4px threshold
53
+ document.dispatchEvent(new MouseEvent('mousemove', { clientX: 202, clientY: 201 }))
54
+ document.dispatchEvent(new MouseEvent('mouseup'))
55
+
56
+ expect(draggedFlagRef.current).toBe(false) // click-to-select is NOT suppressed
57
+ expect(onLivePreview).not.toHaveBeenCalled()
58
+ expect(onCommit).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('moving past the threshold becomes a drag: flag set, move + commit fire', () => {
62
+ const { beginDrag, draggedFlagRef } = setup()
63
+ const { onLivePreview, onCommit } = press(beginDrag)
64
+
65
+ document.dispatchEvent(new MouseEvent('mousemove', { clientX: 240, clientY: 200 })) // 40px → drag
66
+ expect(draggedFlagRef.current).toBe(true)
67
+ expect(onLivePreview).toHaveBeenCalled()
68
+
69
+ document.dispatchEvent(new MouseEvent('mouseup'))
70
+ expect(onCommit).toHaveBeenCalled()
71
+ })
72
+ })
@@ -33,6 +33,13 @@ export interface UseItemDragDropConfig {
33
33
  draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
34
34
  }
35
35
 
36
+ /** A press must travel at least this many pixels before it becomes a drag.
37
+ * Below it the gesture stays a click. Without this threshold, a single pixel of
38
+ * pointer drift during a click flips the drag flag, and the timeline suppresses
39
+ * click-to-select — so clips/overlays can't be selected (or deleted), which
40
+ * bites constantly on trackpads and touchscreens where clicks always jitter. */
41
+ const DRAG_THRESHOLD_PX = 4
42
+
36
43
  export function useItemDragDrop(config: UseItemDragDropConfig) {
37
44
  const {
38
45
  totalDuration,
@@ -133,8 +140,17 @@ export function useItemDragDrop(config: UseItemDragDropConfig) {
133
140
  const initStart = item.start
134
141
  const initEnd = item.end
135
142
  const duration = initEnd - initStart
143
+ let dragStarted = false
136
144
 
137
145
  function onMove(moveE: MouseEvent) {
146
+ // Ignore sub-threshold pointer drift — it's a click, not a drag. Only once
147
+ // the press travels past DRAG_THRESHOLD_PX do we set the drag flag (which
148
+ // suppresses click-to-select) and start moving the item.
149
+ if (!dragStarted) {
150
+ const moved = Math.hypot(moveE.clientX - initX, moveE.clientY - initY)
151
+ if (moved < DRAG_THRESHOLD_PX) return
152
+ dragStarted = true
153
+ }
138
154
  if (draggedFlagRef) draggedFlagRef.current = true
139
155
 
140
156
  const rect = scrollRef.current?.getBoundingClientRect()