@bycrux/editor 0.5.3 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,249 @@
1
+ import * as React from 'react'
2
+ import type { VisualItem } from '../schema'
3
+ import {
4
+ renderedSourceRect,
5
+ fractionToWrapperPx,
6
+ applyCropHandleDrag,
7
+ type CropFraction,
8
+ type CropHandle,
9
+ } from './crop-math'
10
+
11
+ // Sibling of CanvasCropOverlay, typed to a tracks[0] VisualItem (video) instead
12
+ // of an ImageElement. The full source clip is letterboxed into the preview via a
13
+ // muted <video>, an 8-handle free-form crop window is drawn on top, and handle
14
+ // drags emit the updated `sourceCrop` as source fractions clamped to [0, 1].
15
+ //
16
+ // Source dims come either from the item (sourceWidth/sourceHeight, set on a prior
17
+ // crop) or from the <video>'s videoWidth/videoHeight on loadedmetadata — exactly
18
+ // analogous to CanvasCropOverlay reading naturalWidth/Height from <img> onLoad.
19
+
20
+ const CROP_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
21
+
22
+ const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
23
+ nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
24
+ n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
25
+ ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
26
+ w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
27
+ e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
28
+ sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
29
+ s: { dx: 0.5, dy: 1, cursor: 's-resize' },
30
+ se: { dx: 1, dy: 1, cursor: 'se-resize' },
31
+ }
32
+
33
+ const DEFAULT_CROP: CropFraction = { x: 0, y: 0, w: 1, h: 1 }
34
+
35
+ function clampFraction(c: CropFraction): CropFraction {
36
+ const x = Math.min(1, Math.max(0, c.x))
37
+ const y = Math.min(1, Math.max(0, c.y))
38
+ return {
39
+ x,
40
+ y,
41
+ w: Math.min(1 - x, Math.max(0, c.w)),
42
+ h: Math.min(1 - y, Math.max(0, c.h)),
43
+ }
44
+ }
45
+
46
+ export type VideoSourceCropOverlayProps = {
47
+ /** The tracks[0] video item being cropped. */
48
+ item: VisualItem
49
+ /**
50
+ * Host-supplied resolver for the clip's preview URL. Implement on the caller
51
+ * side as `(item) => adapter.fileUrl(item.nobg_preview_src ?? item.src)`.
52
+ */
53
+ resolveSrc: (item: VisualItem) => string
54
+ /** Wrapper width in CSS px (the rect the overlay fills — the preview area). */
55
+ wrapperWidth: number
56
+ /** Wrapper height in CSS px. */
57
+ wrapperHeight: number
58
+ /** Called once on pointer-up (drag end) with the final clamped [0,1] fractions. */
59
+ onChange: (next: CropFraction) => void
60
+ /** Called once when the <video> fires loadedmetadata with the source's intrinsic dims. */
61
+ onSrcDimsLoaded: (dims: { width: number; height: number }) => void
62
+ }
63
+
64
+ export function VideoSourceCropOverlay({
65
+ item,
66
+ resolveSrc,
67
+ wrapperWidth,
68
+ wrapperHeight,
69
+ onChange,
70
+ onSrcDimsLoaded,
71
+ }: VideoSourceCropOverlayProps) {
72
+ const srcUrl = resolveSrc(item)
73
+
74
+ // Local mirror of dims loaded from the <video> when the item doesn't already
75
+ // carry them, so the crop window renders as soon as metadata arrives.
76
+ const [loadedDims, setLoadedDims] = React.useState<{ width: number; height: number } | null>(null)
77
+ const srcDims =
78
+ item.sourceWidth && item.sourceHeight
79
+ ? { width: item.sourceWidth, height: item.sourceHeight }
80
+ : loadedDims
81
+
82
+ // Prop-driven crop (source of truth when not dragging)
83
+ const propCrop = item.sourceCrop ?? DEFAULT_CROP
84
+
85
+ // In-flight drag crop — non-null only during an active handle drag.
86
+ // While dragging, the rendered window follows this local state for smoothness.
87
+ // On pointer-up it's cleared after calling onChange once with the final value.
88
+ const [liveCrop, setLiveCrop] = React.useState<CropFraction | null>(null)
89
+ // Ref mirror so onHandlePointerUp always reads the latest value regardless of closure age.
90
+ const liveCropRef = React.useRef<CropFraction | null>(null)
91
+
92
+ // The crop we actually render: live state during drag, props otherwise.
93
+ const localCrop = liveCrop ?? propCrop
94
+
95
+ const rendered =
96
+ srcDims && wrapperWidth > 0 && wrapperHeight > 0
97
+ ? renderedSourceRect({
98
+ wrapperW: wrapperWidth,
99
+ wrapperH: wrapperHeight,
100
+ srcWidth: srcDims.width,
101
+ srcHeight: srcDims.height,
102
+ })
103
+ : null
104
+ const windowPx = rendered ? fractionToWrapperPx({ crop: localCrop, rendered }) : null
105
+
106
+ const dragStateRef = React.useRef<{
107
+ handle: CropHandle
108
+ startClient: { x: number; y: number }
109
+ startCrop: CropFraction
110
+ } | null>(null)
111
+
112
+ const onHandlePointerDown = (handle: CropHandle, e: React.PointerEvent<HTMLDivElement>) => {
113
+ e.stopPropagation()
114
+ e.currentTarget.setPointerCapture?.(e.pointerId)
115
+ dragStateRef.current = {
116
+ handle,
117
+ startClient: { x: e.clientX, y: e.clientY },
118
+ startCrop: propCrop,
119
+ }
120
+ }
121
+
122
+ const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
123
+ const drag = dragStateRef.current
124
+ if (!drag || !srcDims) return
125
+ const dx = e.clientX - drag.startClient.x
126
+ const dy = e.clientY - drag.startClient.y
127
+ const next = applyCropHandleDrag({
128
+ handle: drag.handle,
129
+ initialCrop: drag.startCrop,
130
+ deltaPx: { x: dx, y: dy },
131
+ wrapperW: wrapperWidth,
132
+ wrapperH: wrapperHeight,
133
+ srcWidth: srcDims.width,
134
+ srcHeight: srcDims.height,
135
+ })
136
+ // Accumulate in local state for smooth visual feedback — do NOT call onChange yet.
137
+ const clamped = clampFraction(next)
138
+ liveCropRef.current = clamped
139
+ setLiveCrop(clamped)
140
+ }
141
+
142
+ const onHandlePointerUp = () => {
143
+ const drag = dragStateRef.current
144
+ dragStateRef.current = null
145
+ const final = liveCropRef.current
146
+ liveCropRef.current = null
147
+ setLiveCrop(null)
148
+ if (drag && final !== null) {
149
+ // Call onChange exactly once with the final value.
150
+ onChange(final)
151
+ }
152
+ }
153
+
154
+ return (
155
+ <div
156
+ style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'auto' }}
157
+ onPointerDown={(e) => {
158
+ e.stopPropagation()
159
+ }}
160
+ >
161
+ {/* Letterbox preview of the full source clip */}
162
+ <video
163
+ src={srcUrl}
164
+ muted
165
+ playsInline
166
+ preload="metadata"
167
+ onLoadedMetadata={(e) => {
168
+ const v = e.currentTarget
169
+ const dims = { width: v.videoWidth, height: v.videoHeight }
170
+ setLoadedDims(dims)
171
+ onSrcDimsLoaded(dims)
172
+ }}
173
+ style={{
174
+ position: 'absolute',
175
+ inset: 0,
176
+ width: '100%',
177
+ height: '100%',
178
+ objectFit: 'contain',
179
+ pointerEvents: 'none',
180
+ }}
181
+ />
182
+
183
+ {windowPx && (
184
+ <>
185
+ {/* Dark dimming mask outside the crop window */}
186
+ <div
187
+ data-testid="crop-dim"
188
+ style={{
189
+ position: 'absolute',
190
+ inset: 0,
191
+ background: 'rgba(0, 0, 0, 0.55)',
192
+ clipPath: `polygon(
193
+ 0 0, 100% 0, 100% 100%, 0 100%, 0 0,
194
+ ${windowPx.x}px ${windowPx.y}px,
195
+ ${windowPx.x}px ${windowPx.y + windowPx.h}px,
196
+ ${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
197
+ ${windowPx.x + windowPx.w}px ${windowPx.y}px,
198
+ ${windowPx.x}px ${windowPx.y}px
199
+ )`,
200
+ pointerEvents: 'none',
201
+ }}
202
+ />
203
+
204
+ {/* Crop window border */}
205
+ <div
206
+ data-testid="crop-window"
207
+ style={{
208
+ position: 'absolute',
209
+ left: windowPx.x,
210
+ top: windowPx.y,
211
+ width: windowPx.w,
212
+ height: windowPx.h,
213
+ outline: '2px solid var(--editor-selection)',
214
+ pointerEvents: 'none',
215
+ }}
216
+ />
217
+
218
+ {/* 8 resize handles */}
219
+ {CROP_HANDLES.map((handle) => {
220
+ const o = HANDLE_OFFSET[handle]
221
+ return (
222
+ <div
223
+ key={handle}
224
+ data-testid={`crop-handle-${handle}`}
225
+ onPointerDown={(e) => onHandlePointerDown(handle, e)}
226
+ onPointerMove={onHandlePointerMove}
227
+ onPointerUp={onHandlePointerUp}
228
+ style={{
229
+ position: 'absolute',
230
+ left: windowPx.x + o.dx * windowPx.w - 6,
231
+ top: windowPx.y + o.dy * windowPx.h - 6,
232
+ width: 12,
233
+ height: 12,
234
+ backgroundColor: '#fff',
235
+ border: '1.5px solid var(--editor-selection)',
236
+ borderRadius: 2,
237
+ cursor: o.cursor,
238
+ zIndex: 10,
239
+ pointerEvents: 'auto',
240
+ touchAction: 'none',
241
+ }}
242
+ />
243
+ )
244
+ })}
245
+ </>
246
+ )}
247
+ </div>
248
+ )
249
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { fireEvent, render, cleanup } from '@testing-library/react'
3
+ import type { VisualItem } from '../../schema'
4
+ import { VideoSourceCropOverlay } from '../VideoSourceCropOverlay'
5
+
6
+ afterEach(() => cleanup())
7
+
8
+ // Aspect-matched fixture: wrapper 400×500, source 800×1000. The rendered source
9
+ // fills the wrapper with no letterbox, so crop fractions map 1:1 onto wrapper px
10
+ // (x over 400, y over 500). Initial crop (0.2,0.2,0.6,0.6) → px rect (80,100)–(320,400).
11
+ function makeItem(overrides: Partial<VisualItem> = {}): VisualItem {
12
+ return {
13
+ id: 'v1',
14
+ type: 'video',
15
+ src: '/clip.mov',
16
+ start: 0,
17
+ end: 5,
18
+ sourceWidth: 800,
19
+ sourceHeight: 1000,
20
+ sourceCrop: { x: 0.2, y: 0.2, w: 0.6, h: 0.6 },
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ describe('VideoSourceCropOverlay', () => {
26
+ it('renders the 8 crop handles for a video item with known source dims', () => {
27
+ const { getByTestId } = render(
28
+ <VideoSourceCropOverlay
29
+ item={makeItem()}
30
+ resolveSrc={() => '/clip.mov'}
31
+ wrapperWidth={400}
32
+ wrapperHeight={500}
33
+ onChange={vi.fn()}
34
+ onSrcDimsLoaded={vi.fn()}
35
+ />,
36
+ )
37
+ for (const h of ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']) {
38
+ expect(getByTestId(`crop-handle-${h}`)).toBeTruthy()
39
+ }
40
+ })
41
+
42
+ it('emits onChange exactly once on pointer-up (not per move) with the final clamped [0,1] fractions', () => {
43
+ const onChange = vi.fn()
44
+ const { getByTestId } = render(
45
+ <VideoSourceCropOverlay
46
+ item={makeItem()}
47
+ resolveSrc={() => '/clip.mov'}
48
+ wrapperWidth={400}
49
+ wrapperHeight={500}
50
+ onChange={onChange}
51
+ onSrcDimsLoaded={vi.fn()}
52
+ />,
53
+ )
54
+
55
+ const se = getByTestId('crop-handle-se')
56
+ // Simulate a full drag sequence: pointerdown → multiple pointermoves → pointerup.
57
+ // jsdom has no PointerEvent constructor, so fireEvent.pointer* drops
58
+ // clientX/Y. Dispatch MouseEvents under the pointer* type names instead —
59
+ // MouseEvent carries clientX/Y and React listens on the event type string.
60
+ se.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 400 }))
61
+ // Multiple intermediate moves — onChange must NOT be called during these.
62
+ se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 290, clientY: 400 }))
63
+ se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 270, clientY: 400 }))
64
+
65
+ // onChange must not fire during move — only after release.
66
+ expect(onChange).not.toHaveBeenCalled()
67
+
68
+ // Release — final position: SE handle moved left 50 px from start (320 → 270).
69
+ se.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 270, clientY: 400 }))
70
+
71
+ // onChange called exactly once on pointer-up.
72
+ expect(onChange).toHaveBeenCalledTimes(1)
73
+
74
+ const next = onChange.mock.calls[0][0]
75
+ // dx = -50 px over a 400-px-wide rendered source = -0.125 fractions.
76
+ expect(next.x).toBeCloseTo(0.2)
77
+ expect(next.y).toBeCloseTo(0.2)
78
+ expect(next.w).toBeCloseTo(0.6 - 50 / 400) // 0.475
79
+ expect(next.h).toBeCloseTo(0.6)
80
+ // All emitted fractions stay within [0, 1].
81
+ for (const v of [next.x, next.y, next.w, next.h]) {
82
+ expect(v).toBeGreaterThanOrEqual(0)
83
+ expect(v).toBeLessThanOrEqual(1)
84
+ }
85
+ })
86
+
87
+ it('reads source dims from the <video> loadedmetadata when not supplied on the item', () => {
88
+ const onSrcDimsLoaded = vi.fn()
89
+ const { container } = render(
90
+ <VideoSourceCropOverlay
91
+ item={makeItem({ sourceWidth: undefined, sourceHeight: undefined })}
92
+ resolveSrc={() => '/clip.mov'}
93
+ wrapperWidth={400}
94
+ wrapperHeight={500}
95
+ onChange={vi.fn()}
96
+ onSrcDimsLoaded={onSrcDimsLoaded}
97
+ />,
98
+ )
99
+ const video = container.querySelector('video') as HTMLVideoElement
100
+ Object.defineProperty(video, 'videoWidth', { value: 800, configurable: true })
101
+ Object.defineProperty(video, 'videoHeight', { value: 1000, configurable: true })
102
+ fireEvent.loadedMetadata(video)
103
+ expect(onSrcDimsLoaded).toHaveBeenCalledWith({ width: 800, height: 1000 })
104
+ })
105
+ })
@@ -55,6 +55,21 @@ describe('wrapperPxToFraction', () => {
55
55
  })
56
56
  })
57
57
 
58
+ describe('video-source round-trip (regression lock: helpers are element-agnostic)', () => {
59
+ it('12. round-trips a CropFraction through fractionToWrapperPx → wrapperPxToFraction for a 1920×1080 source in a 1080×1920 wrapper', () => {
60
+ // 1920×1080 landscape video rendered inside a 1080×1920 portrait wrapper.
61
+ // Source is much wider than the wrapper, so it letterboxes left/right.
62
+ const rendered = renderedSourceRect({ wrapperW: 1080, wrapperH: 1920, srcWidth: 1920, srcHeight: 1080 })
63
+ const orig: import('../crop-math').CropFraction = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 }
64
+ const px = fractionToWrapperPx({ crop: orig, rendered })
65
+ const back = wrapperPxToFraction({ px, rendered })
66
+ expect(back.x).toBeCloseTo(orig.x)
67
+ expect(back.y).toBeCloseTo(orig.y)
68
+ expect(back.w).toBeCloseTo(orig.w)
69
+ expect(back.h).toBeCloseTo(orig.h)
70
+ })
71
+ })
72
+
58
73
  import { applyCropHandleDrag } from '../crop-math'
59
74
 
60
75
  // Standard fixture: 400x500 element, 800x1000 source — aspect-matched so the
package/src/schema.ts CHANGED
@@ -76,7 +76,11 @@ export interface VisualItem {
76
76
  remove_bg?: boolean // video type only
77
77
  nobg_src?: string // video type only — ProRes 4444 .mov for final render
78
78
  nobg_preview_src?: string // video type only — VP9 WebM with alpha for browser preview
79
+ normalizedSrc?: string // derived per-window normalized cache; render/preview prefer it; src stays original
79
80
  muted?: boolean // video type only — suppress audio in preview and render
81
+ sourceCrop?: { x: number; y: number; w: number; h: number } // video type only — non-destructive crop of the source clip (0–1 fractions)
82
+ sourceWidth?: number // video type only — intrinsic width of the source clip in pixels
83
+ sourceHeight?: number // video type only — intrinsic height of the source clip in pixels
80
84
  generation?: { // ai_video only — frozen provenance from Kling generation
81
85
  // Single-shot fields (present when multiShot is falsy).
82
86
  sceneId?: string
@@ -186,7 +190,7 @@ export interface Slide {
186
190
  export interface EditorProject {
187
191
  id: string
188
192
  status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
189
- settings: { resolution: [number, number]; fps?: number; brandKit?: string }
193
+ settings: { resolution: [number, number]; fps?: number; brandKit?: string; normalize?: 'eager' | 'lazy' }
190
194
  name?: string | null
191
195
  editingPrompt?: string
192
196
  slides?: Slide[]
@@ -196,6 +200,7 @@ export interface EditorProject {
196
200
  assets?: Asset[]
197
201
  carousel?: { aspect: string }
198
202
  profile?: string
203
+ derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
199
204
  // Host-only / pipeline fields pass through at the type level.
200
205
  [key: string]: unknown
201
206
  }
package/src/types.ts CHANGED
@@ -489,6 +489,15 @@ export interface VideoEditorProps<P extends Project = Project> {
489
489
  slots?: EditorSlots
490
490
  readOnly?: boolean
491
491
  onBackToSetup?: () => void
492
+ /**
493
+ * Where the host's `slots.assetsPanel` is placed in the review layout:
494
+ * - `'right'` (default) — a sidebar column to the right of the preview/timeline.
495
+ * The historical Montaj-local layout; preferred when horizontal space is ample.
496
+ * - `'bottom'` — a full-width region stacked below the editor. Used by hosts with
497
+ * constrained width (e.g. the Hub editor) where vertical stacking reads better.
498
+ * The host chooses per deployment; the package defaults to `'right'`.
499
+ */
500
+ assetsPlacement?: 'right' | 'bottom'
492
501
 
493
502
  // ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
494
503
  // The clip/audio inspector and the subcut-regeneration tool read host-only
@@ -15,7 +15,7 @@ interface CaptionRegenModalProps<P extends Project = Project> {
15
15
  }
16
16
 
17
17
  function LogLine({ text }: { text: string }) {
18
- let color = 'text-gray-400'
18
+ let color = 'text-[var(--editor-text)]/60'
19
19
  if (/ready|complete|done|transcribed/i.test(text)) color = 'text-green-400'
20
20
  else if (/transcrib|detecting|loading|model/i.test(text)) color = 'text-sky-400'
21
21
  else if (/extract|building|composing/i.test(text)) color = 'text-amber-400'
@@ -107,16 +107,16 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
107
107
 
108
108
  return (
109
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">
110
+ <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
111
111
 
112
112
  {/* Header */}
113
- <div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
113
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
114
114
  <div className="flex items-center gap-2.5">
115
115
  {status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
116
116
  {status === 'done' && <span className="w-2 h-2 rounded-full bg-green-400" />}
117
117
  {status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
118
118
  <div className="flex flex-col gap-0.5">
119
- <h2 className="text-sm font-semibold text-white">
119
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">
120
120
  {status === 'running' ? 'Regenerating captions…'
121
121
  : status === 'done' ? 'Captions regenerated'
122
122
  : 'Caption regeneration failed'}
@@ -124,7 +124,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
124
124
  </div>
125
125
  </div>
126
126
  {status !== 'running' && (
127
- <button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
127
+ <button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
128
128
  )}
129
129
  </div>
130
130
 
@@ -132,17 +132,17 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
132
132
  <div className="relative">
133
133
  <button
134
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"
135
+ 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"
136
136
  title="Copy logs"
137
137
  >
138
138
  Copy
139
139
  </button>
140
140
  <div
141
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"
142
+ 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"
143
143
  >
144
144
  {logs.length === 0 && status === 'running' && (
145
- <span className="text-gray-600 italic">Starting transcription…</span>
145
+ <span className="text-[var(--editor-text)]/40 italic">Starting transcription…</span>
146
146
  )}
147
147
  {logs.map((line, i) => (
148
148
  <LogLine key={i} text={line} />
@@ -154,18 +154,18 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
154
154
  </div>
155
155
 
156
156
  {/* Footer */}
157
- <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gray-800">
157
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
158
158
  {status === 'running' ? (
159
159
  <button
160
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"
161
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
162
162
  >
163
163
  Cancel
164
164
  </button>
165
165
  ) : (
166
166
  <button
167
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"
168
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
169
169
  >
170
170
  Close
171
171
  </button>
@@ -22,15 +22,15 @@ function basename(p: string) { return p.split('/').pop() ?? p }
22
22
 
23
23
  function LogLine({ text }: { text: string }) {
24
24
  const t = text.replace(/^\[montaj render\]\s*/, '')
25
- let color = 'text-gray-400'
25
+ let color = 'text-[var(--editor-text)]/60'
26
26
  if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
27
27
  else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
28
28
  else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
29
- else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-gray-500'
29
+ else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
30
30
  else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
31
31
 
32
32
  const prefix = text.startsWith('[montaj render]')
33
- ? <span className="text-gray-600">[render] </span>
33
+ ? <span className="text-[var(--editor-text)]/40">[render] </span>
34
34
  : null
35
35
 
36
36
  return (
@@ -133,7 +133,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
133
133
  if (status === 'done' && outputPath) {
134
134
  return (
135
135
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md">
136
- <div className="w-[96vw] h-[96vh] bg-gray-950 border border-gray-800 rounded-2xl shadow-2xl flex overflow-hidden">
136
+ <div className="w-[96vw] h-[96vh] bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
137
137
 
138
138
  {/* Left — video */}
139
139
  <div className="flex-1 bg-black flex items-center justify-center overflow-hidden">
@@ -147,20 +147,20 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
147
147
  </div>
148
148
 
149
149
  {/* Right — info panel */}
150
- <div className="w-72 shrink-0 flex flex-col border-l border-gray-800">
151
- <div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
150
+ <div className="w-72 shrink-0 flex flex-col border-l border-[var(--editor-border)]">
151
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
152
152
  <div className="flex items-center gap-2.5">
153
153
  <span className="w-2 h-2 rounded-full bg-green-400" />
154
154
  <div>
155
- <p className="text-sm font-semibold text-white">Render complete</p>
156
- <p className="text-xs text-gray-400">Your video is ready.</p>
155
+ <p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
156
+ <p className="text-xs text-[var(--editor-text)]/60">Your video is ready.</p>
157
157
  </div>
158
158
  </div>
159
- <button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
159
+ <button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
160
160
  </div>
161
161
 
162
162
  <div className="flex flex-col gap-3 p-5 flex-1">
163
- <p className="text-xs font-mono text-gray-500 break-all leading-relaxed">{outputPath}</p>
163
+ <p className="text-xs font-mono text-[var(--editor-text)]/55 break-all leading-relaxed">{outputPath}</p>
164
164
  {/* Host-supplied export controls (e.g. download-all .zip). */}
165
165
  {exportActions}
166
166
  <a
@@ -172,7 +172,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
172
172
  </a>
173
173
  <button
174
174
  onClick={onClose}
175
- className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-300 hover:bg-gray-700 transition-colors"
175
+ className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
176
176
  >
177
177
  Close
178
178
  </button>
@@ -185,21 +185,21 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
185
185
 
186
186
  return (
187
187
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
188
- <div className="w-full max-w-3xl bg-gray-900 border border-gray-700 rounded-xl shadow-2xl flex flex-col overflow-hidden">
188
+ <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
189
189
 
190
190
  {/* Header */}
191
- <div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
191
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
192
192
  <div className="flex items-center gap-2.5">
193
193
  {status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
194
194
  {status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
195
195
  <div className="flex flex-col gap-0.5">
196
- <h2 className="text-sm font-semibold text-white">
196
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">
197
197
  {status === 'running' ? 'Rendering…' : 'Render failed'}
198
198
  </h2>
199
199
  </div>
200
200
  </div>
201
201
  {status !== 'running' && (
202
- <button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
202
+ <button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
203
203
  )}
204
204
  </div>
205
205
 
@@ -207,17 +207,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
207
207
  <div className="relative">
208
208
  <button
209
209
  onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
210
- 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"
210
+ 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"
211
211
  title="Copy logs"
212
212
  >
213
213
  Copy
214
214
  </button>
215
215
  <div
216
216
  ref={logRef}
217
- 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"
217
+ 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"
218
218
  >
219
219
  {logs.length === 0 && status === 'running' && (
220
- <span className="text-gray-600 italic">Starting render engine…</span>
220
+ <span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
221
221
  )}
222
222
  {logs.map((line, i) => (
223
223
  <LogLine key={i} text={line} />
@@ -229,18 +229,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
229
229
  </div>
230
230
 
231
231
  {/* Footer */}
232
- <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gray-800">
232
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
233
233
  {status === 'running' ? (
234
234
  <button
235
235
  onClick={handleCancel}
236
- 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"
236
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
237
237
  >
238
238
  Cancel
239
239
  </button>
240
240
  ) : (
241
241
  <button
242
242
  onClick={onClose}
243
- 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"
243
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
244
244
  >
245
245
  Close
246
246
  </button>