@bycrux/editor 0.5.3 → 0.6.1

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.
@@ -42,35 +42,35 @@ export default function VersionPanel({ versions, restoring, onRestore }: Version
42
42
  const deduped = dedupeVersions(versions)
43
43
 
44
44
  return (
45
- <div className="shrink-0 border-b border-gray-200 dark:border-gray-800 flex flex-col overflow-hidden" style={{ maxHeight: open ? 224 : 0, transition: 'max-height 0.15s ease' }}>
45
+ <div className="shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden" style={{ maxHeight: open ? 224 : 0, transition: 'max-height 0.15s ease' }}>
46
46
  <button
47
47
  onClick={() => setOpen(o => !o)}
48
- className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-900 transition-colors w-full text-left"
48
+ className="flex items-center justify-between px-3 py-2 border-b border-[var(--editor-border)] hover:bg-[var(--editor-surface)] transition-colors w-full text-left"
49
49
  >
50
- <span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Versions</span>
51
- <span className="text-gray-600 text-[10px]">{open ? '▲' : '▼'}</span>
50
+ <span className="text-xs font-medium text-[var(--editor-text)]/60 uppercase tracking-wide">Versions</span>
51
+ <span className="text-[var(--editor-text)]/50 text-[10px]">{open ? '▲' : '▼'}</span>
52
52
  </button>
53
53
  <div className="overflow-y-auto p-2 flex flex-col gap-1.5">
54
54
  {deduped.length === 0 ? (
55
- <p className="text-xs text-gray-600 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
55
+ <p className="text-xs text-[var(--editor-text)]/55 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
56
56
  ) : deduped.map(v => {
57
57
  const { run, label } = parseVersion(v)
58
58
  const isDefault = label === 'draft' || label === 'final' || label === 'pending'
59
59
  return (
60
- <div key={v.hash} className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-2 flex flex-col gap-1">
60
+ <div key={v.hash} className="rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] p-2 flex flex-col gap-1">
61
61
  <div className="flex items-center gap-1.5">
62
- <span className="text-[10px] text-gray-500 dark:text-gray-600 shrink-0">Run {run}</span>
62
+ <span className="text-[10px] text-[var(--editor-text)]/50 shrink-0">Run {run}</span>
63
63
  {isDefault ? (
64
- <span className="text-[10px] text-gray-500 capitalize">{label}</span>
64
+ <span className="text-[10px] text-[var(--editor-text)]/60 capitalize">{label}</span>
65
65
  ) : (
66
- <span className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate capitalize" title={label}>{label}</span>
66
+ <span className="text-xs font-medium text-[var(--editor-text)] truncate capitalize" title={label}>{label}</span>
67
67
  )}
68
68
  </div>
69
- <span className="text-[10px] text-gray-600">{formatTime(v.timestamp)}</span>
69
+ <span className="text-[10px] text-[var(--editor-text)]/55">{formatTime(v.timestamp)}</span>
70
70
  <button
71
71
  onClick={() => onRestore(v.hash)}
72
72
  disabled={restoring === v.hash}
73
- className="text-[10px] text-blue-500 hover:text-blue-400 text-left transition-colors disabled:opacity-40"
73
+ className="text-[10px] text-[var(--editor-accent)] hover:opacity-80 text-left transition-colors disabled:opacity-40"
74
74
  >
75
75
  {restoring === v.hash ? 'Restoring…' : 'Restore →'}
76
76
  </button>
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { Magnet } from 'lucide-react'
2
+ import { Crop, Magnet } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
+ import { VideoSourceCropOverlay } from '../crop/VideoSourceCropOverlay'
5
+ import { getOverlayDesignCanvas } from './design-canvas'
4
6
  import { applyTheme, defaultMontajTheme } from '../theme'
5
7
  import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
6
8
  import { repairCaptionWords } from './captionRepair'
@@ -38,6 +40,7 @@ export default function VideoEditor<P extends Project = Project>({
38
40
  theme,
39
41
  slots,
40
42
  onBackToSetup,
43
+ assetsPlacement = 'right',
41
44
  renderClipInspector,
42
45
  renderSubcutRegen,
43
46
  regenEnabled,
@@ -60,7 +63,7 @@ export default function VideoEditor<P extends Project = Project>({
60
63
 
61
64
  if (isPending) {
62
65
  return (
63
- <div ref={containerRef} className="flex flex-col h-full bg-white dark:bg-gray-950">
66
+ <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
64
67
  <PendingSurface
65
68
  project={project}
66
69
  adapter={adapter}
@@ -82,6 +85,7 @@ export default function VideoEditor<P extends Project = Project>({
82
85
  adapter={adapter}
83
86
  onProjectChange={emit}
84
87
  slots={slots}
88
+ assetsPlacement={assetsPlacement}
85
89
  getWaveformChunks={getWaveformChunks}
86
90
  resolveFilePath={resolveFilePath}
87
91
  save={save}
@@ -114,6 +118,7 @@ interface SurfaceProps<P extends Project> {
114
118
  adapter: VideoEditorProps<P>['adapter']
115
119
  onProjectChange: (p: P) => void
116
120
  slots?: VideoEditorProps<P>['slots']
121
+ assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
117
122
  getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
118
123
  resolveFilePath: (path: string) => string
119
124
  save: (p: P) => void
@@ -161,7 +166,7 @@ function PendingSurface<P extends Project>({
161
166
  <div className="flex flex-1 overflow-hidden">
162
167
  {/* Main */}
163
168
  <div className="flex flex-col flex-1 overflow-hidden">
164
- <div className="flex-1 flex items-center justify-center bg-gray-950 overflow-hidden p-4">
169
+ <div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-4">
165
170
  {hasTrimmedClips ? (
166
171
  <PreviewPlayer
167
172
  project={project}
@@ -180,12 +185,12 @@ function PendingSurface<P extends Project>({
180
185
  {slots?.pendingStatus ?? (
181
186
  <>
182
187
  <div className="flex flex-col items-center gap-2">
183
- <p className="text-white text-lg font-semibold">Message your agent to start</p>
184
- <p className="text-gray-400 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
188
+ <p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
189
+ <p className="text-[var(--editor-text)]/60 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
185
190
  </div>
186
191
  {skillPath && (
187
- <div className="w-full rounded-xl border-2 border-blue-400/50 bg-gray-900 p-5 flex flex-col gap-3 text-left shadow-lg shadow-blue-400/10">
188
- <p className="text-blue-400 text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
192
+ <div className="w-full rounded-xl border-2 border-[var(--editor-accent)]/50 bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
193
+ <p className="text-[var(--editor-accent)] text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
189
194
  <div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
190
195
  <span className="text-gray-200 text-[12px] leading-relaxed break-all">
191
196
  There is a new project pending: &quot;{project.name ?? project.id}&quot;. Please see @{skillPath} and start. Talk to me if you run into questions.
@@ -199,7 +204,7 @@ function PendingSurface<P extends Project>({
199
204
  setTimeout(() => setCopied(false), 2000)
200
205
  }}
201
206
  className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
202
- copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-gray-300 hover:bg-white/20 hover:text-white'
207
+ copied ? 'bg-green-700 text-green-200' : 'bg-[var(--editor-text)]/10 text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/20 hover:text-[var(--editor-text)]'
203
208
  }`}
204
209
  title="Copy prompt"
205
210
  >
@@ -210,11 +215,11 @@ function PendingSurface<P extends Project>({
210
215
  )}
211
216
  </>
212
217
  )}
213
- <p className="text-gray-600 text-xs font-mono">project id: {project.id}</p>
218
+ <p className="text-[var(--editor-text)]/40 text-xs font-mono">project id: {project.id}</p>
214
219
  {canGoBack && (
215
220
  <button
216
221
  onClick={onBackToSetup}
217
- className="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
222
+ className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors underline underline-offset-2"
218
223
  >
219
224
  ← Back to setup
220
225
  </button>
@@ -223,7 +228,7 @@ function PendingSurface<P extends Project>({
223
228
  )}
224
229
  </div>
225
230
 
226
- <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
231
+ <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
227
232
  <Timeline
228
233
  project={project}
229
234
  currentTime={currentTime}
@@ -237,7 +242,7 @@ function PendingSurface<P extends Project>({
237
242
 
238
243
  {/* Right sidebar — version history (hidden when the capability is absent) */}
239
244
  {adapter.listVersionHistory && (
240
- <div className="w-48 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950 flex flex-col overflow-hidden">
245
+ <div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
241
246
  <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
242
247
  </div>
243
248
  )}
@@ -252,6 +257,7 @@ function ReviewSurface<P extends Project>({
252
257
  adapter,
253
258
  onProjectChange,
254
259
  slots,
260
+ assetsPlacement = 'right',
255
261
  getWaveformChunks,
256
262
  resolveFilePath,
257
263
  save,
@@ -273,6 +279,9 @@ function ReviewSurface<P extends Project>({
273
279
  const [selectedIds, setSelectedIds] = useState<string[]>([])
274
280
  const primarySelectedId = selectedIds[0] ?? null
275
281
  const [rippleMode, setRippleMode] = useState(false)
282
+ // Source-crop mode: when on, a VideoSourceCropOverlay is mounted over the
283
+ // preview for the selected tracks[0] video item. Cleared when selection changes.
284
+ const [cropMode, setCropMode] = useState(false)
276
285
  const [renderOpen, setRenderOpen] = useState(false)
277
286
  const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
278
287
  // The clip/audio inspector target — derived from the timeline's inspect
@@ -281,6 +290,21 @@ function ReviewSurface<P extends Project>({
281
290
 
282
291
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
283
292
 
293
+ // Measured pixel size of the preview's rendered video rect — fed to the crop
294
+ // overlay as its wrapper dims so renderedSourceRect letterboxes correctly.
295
+ const previewBoxRef = useRef<HTMLDivElement>(null)
296
+ const [previewBox, setPreviewBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
297
+ useEffect(() => {
298
+ if (!cropMode) return
299
+ const el = previewBoxRef.current
300
+ if (!el) return
301
+ const obs = new ResizeObserver(([entry]) => {
302
+ setPreviewBox({ w: entry.contentRect.width, h: entry.contentRect.height })
303
+ })
304
+ obs.observe(el)
305
+ return () => obs.disconnect()
306
+ }, [cropMode])
307
+
284
308
  // Repair caption segments whose words[] text has diverged from edited seg.text.
285
309
  // Inline caption edits update seg.text but not seg.words; this normalizes the
286
310
  // data so PreviewPlayer's word-level timing is correct. Runs once per project.id.
@@ -297,6 +321,18 @@ function ReviewSurface<P extends Project>({
297
321
  const clips = project.tracks?.[0] ?? []
298
322
  const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
299
323
 
324
+ // The selected tracks[0] video item, if any — the only thing source-crop mode
325
+ // can target. Source crop is a tracks[0]-video primitive (the renderer applies
326
+ // it to the original clip before compositing).
327
+ const cropTarget = primarySelectedId
328
+ ? clips.find(c => c.id === primarySelectedId && c.type === 'video' && !!c.src) ?? null
329
+ : null
330
+
331
+ // Selecting a different item (or nothing croppable) exits crop mode.
332
+ useEffect(() => {
333
+ if (!cropTarget && cropMode) setCropMode(false)
334
+ }, [cropTarget, cropMode])
335
+
300
336
  function pushHistory(prev: P) {
301
337
  historyRef.current = [...historyRef.current.slice(-49), prev]
302
338
  setCanUndo(true)
@@ -331,7 +367,7 @@ function ReviewSurface<P extends Project>({
331
367
  setSelectedIds([])
332
368
  }
333
369
 
334
- function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) {
370
+ function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill'; sourceCrop?: { x: number; y: number; w: number; h: number }; sourceWidth?: number; sourceHeight?: number }) {
335
371
  pushHistory(project)
336
372
  const updated = {
337
373
  ...project,
@@ -397,29 +433,59 @@ function ReviewSurface<P extends Project>({
397
433
  <div className="flex flex-col flex-1 overflow-hidden">
398
434
  <div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
399
435
  {hasContent ? (
400
- <PreviewPlayer
401
- project={project}
402
- currentTime={currentTime}
403
- onTimeUpdate={setCurrentTime}
404
- selectedOverlayId={primarySelectedId ?? undefined}
405
- onOverlayChange={handleOverlayChange}
406
- compileOverlay={adapter.compileOverlay}
407
- clearOverlayCache={adapter.clearOverlayCache}
408
- watchFile={adapter.watchFile}
409
- fileUrl={adapter.fileUrl}
410
- resolveCaptionTemplate={adapter.resolveCaptionTemplate}
411
- />
436
+ <div
437
+ ref={previewBoxRef}
438
+ className="relative h-full max-w-full"
439
+ style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
440
+ >
441
+ <PreviewPlayer
442
+ project={project}
443
+ currentTime={currentTime}
444
+ onTimeUpdate={setCurrentTime}
445
+ selectedOverlayId={primarySelectedId ?? undefined}
446
+ onOverlayChange={handleOverlayChange}
447
+ compileOverlay={adapter.compileOverlay}
448
+ clearOverlayCache={adapter.clearOverlayCache}
449
+ watchFile={adapter.watchFile}
450
+ fileUrl={adapter.fileUrl}
451
+ resolveCaptionTemplate={adapter.resolveCaptionTemplate}
452
+ />
453
+ {/* Source-crop overlay — mounted over the preview for the selected
454
+ tracks[0] video. Persists sourceCrop through handleOverlayChange. */}
455
+ {cropMode && cropTarget && (
456
+ <div className="absolute inset-0" style={{ zIndex: 200 }}>
457
+ <VideoSourceCropOverlay
458
+ item={cropTarget}
459
+ resolveSrc={(it) => adapter.fileUrl(it.nobg_preview_src ?? it.src ?? '')}
460
+ wrapperWidth={previewBox.w}
461
+ wrapperHeight={previewBox.h}
462
+ onChange={(next) => handleOverlayChange(cropTarget.id, {
463
+ sourceCrop: {
464
+ x: Math.min(1, Math.max(0, next.x)),
465
+ y: Math.min(1, Math.max(0, next.y)),
466
+ w: Math.min(1, Math.max(0, next.w)),
467
+ h: Math.min(1, Math.max(0, next.h)),
468
+ },
469
+ })}
470
+ onSrcDimsLoaded={(dims) => {
471
+ if (cropTarget.sourceWidth && cropTarget.sourceHeight) return
472
+ handleOverlayChange(cropTarget.id, { sourceWidth: dims.width, sourceHeight: dims.height })
473
+ }}
474
+ />
475
+ </div>
476
+ )}
477
+ </div>
412
478
  ) : (
413
- <p className="text-gray-600 text-sm">No clips</p>
479
+ <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
414
480
  )}
415
481
  </div>
416
482
 
417
483
  {/* Track controls bar — split + ripple + render */}
418
- <div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
484
+ <div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
419
485
  <button
420
486
  onClick={() => handleSplit()}
421
487
  title="Split at playhead (S) — selected item or all clips"
422
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-gray-500 bg-transparent hover:text-gray-400"
488
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
423
489
  >
424
490
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
425
491
  <line x1="6" y1="0" x2="6" y2="12" />
@@ -434,11 +500,28 @@ function ReviewSurface<P extends Project>({
434
500
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
435
501
  rippleMode
436
502
  ? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
437
- : 'text-gray-500 bg-transparent hover:text-gray-400'
503
+ : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
438
504
  }`}
439
505
  >
440
506
  <Magnet size={12} />
441
507
  </button>
508
+ <button
509
+ onClick={() => setCropMode(m => !m)}
510
+ disabled={!cropTarget}
511
+ title={
512
+ !cropTarget
513
+ ? 'Select a video clip to crop its source'
514
+ : cropMode ? 'Exit source crop' : 'Crop source — non-destructively crop the selected clip'
515
+ }
516
+ aria-pressed={cropMode}
517
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
518
+ cropMode
519
+ ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
520
+ : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
521
+ }`}
522
+ >
523
+ <Crop size={12} />
524
+ </button>
442
525
  <button
443
526
  onClick={() => {
444
527
  const final = { ...project, status: 'final' } as P
@@ -446,13 +529,13 @@ function ReviewSurface<P extends Project>({
446
529
  save(final)
447
530
  setRenderOpen(true)
448
531
  }}
449
- className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-500 transition-colors"
532
+ className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
450
533
  >
451
534
  Render →
452
535
  </button>
453
536
  </div>
454
537
 
455
- <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
538
+ <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
456
539
  <Timeline
457
540
  project={project}
458
541
  currentTime={currentTime}
@@ -478,9 +561,17 @@ function ReviewSurface<P extends Project>({
478
561
  </div>
479
562
  </div>
480
563
 
564
+ {/* Assets — right sidebar column (assetsPlacement: 'right', the default /
565
+ Montaj-local layout). The host's panel manages its own scroll. */}
566
+ {assetsPlacement === 'right' && slots?.assetsPanel && (
567
+ <div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
568
+ {slots.assetsPanel}
569
+ </div>
570
+ )}
571
+
481
572
  {/* Right rail — version history + run history slot */}
482
573
  {(adapter.listVersionHistory || slots?.runHistory) && (
483
- <div className="w-48 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950 flex flex-col overflow-hidden">
574
+ <div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
484
575
  {adapter.listVersionHistory && (
485
576
  <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
486
577
  )}
@@ -492,11 +583,11 @@ function ReviewSurface<P extends Project>({
492
583
  )}
493
584
  </div>
494
585
 
495
- {/* Project media / assets — full-width region stacked BELOW the editor,
496
- mirroring CarouselEditor's layout (was previously crammed into the
497
- narrow right rail). The host's panel manages its own scroll. */}
498
- {slots?.assetsPanel && (
499
- <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 w-full flex flex-col max-h-[45%] overflow-hidden">
586
+ {/* Project media / assets — full-width region stacked BELOW the editor
587
+ (assetsPlacement: 'bottom'). Preferred by width-constrained hosts (Hub).
588
+ The host's panel manages its own scroll. */}
589
+ {assetsPlacement === 'bottom' && slots?.assetsPanel && (
590
+ <div className="shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col max-h-[45%] overflow-hidden">
500
591
  {slots.assetsPanel}
501
592
  </div>
502
593
  )}
@@ -62,6 +62,9 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
62
62
  return (
63
63
  <video
64
64
  ref={ref}
65
+ // Anonymous CORS so cross-origin R2 clips aren't tainted (would mute the
66
+ // Web Audio graph). crossOrigin must be set before src. R2 sends ACAO.
67
+ crossOrigin="anonymous"
65
68
  src={src}
66
69
  muted={muted}
67
70
  preload="auto"
@@ -6,6 +6,7 @@ import { getOverlayDesignCanvas } from '../design-canvas'
6
6
  import { useDragOverlay } from './useDragOverlay'
7
7
  import OverlayItemsLayer from './OverlayItemsLayer'
8
8
  import { useVideoPlayback } from './useVideoPlayback'
9
+ import { sourceCropVideoStyle } from './sourceCropStyle'
9
10
  import CarouselPreview from './CarouselPreview'
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -42,6 +43,12 @@ export default function PreviewPlayer({
42
43
 
43
44
  const containerRef = useRef<HTMLDivElement>(null)
44
45
  const [renderScale, setRenderScale] = useState<number>(1)
46
+ // Frame pixel size — used to compute the sourceCrop CSS transform that mirrors
47
+ // render's crop→contain. Tracked alongside renderScale from the same observer.
48
+ const [frameSize, setFrameSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
49
+ // Intrinsic dims of the loaded source <video>, captured on loadedmetadata.
50
+ // Falls back to a clip's own sourceWidth/sourceHeight when present.
51
+ const [videoDims, setVideoDims] = useState<{ w: number; h: number } | null>(null)
45
52
 
46
53
  // Track container size to scale overlay components from 1080×1920 → preview size
47
54
  useEffect(() => {
@@ -49,6 +56,7 @@ export default function PreviewPlayer({
49
56
  if (!el) return
50
57
  const obs = new ResizeObserver(([entry]) => {
51
58
  setRenderScale(entry.contentRect.width / RENDER_W)
59
+ setFrameSize({ w: entry.contentRect.width, h: entry.contentRect.height })
52
60
  })
53
61
  obs.observe(el)
54
62
  return () => obs.disconnect()
@@ -81,6 +89,36 @@ export default function PreviewPlayer({
81
89
 
82
90
  const captionTrack = useMemo(() => project.captions, [project])
83
91
 
92
+ // ── sourceCrop reflection ───────────────────────────────────────────────────
93
+ // Mirror render's crop→contain so the preview frames the clip the way the
94
+ // final output will. The active clip is the one whose [start, end) contains the
95
+ // playhead (same selection the playback hook uses internally). Only the active
96
+ // <video> slot is opaque, so applying the active clip's crop to both slots is
97
+ // safe — the inactive slot is invisible.
98
+ const activeClip = useMemo(
99
+ () => clips.find(c => currentTime >= c.start && currentTime < c.end) ?? clips[clips.length - 1],
100
+ [clips, currentTime],
101
+ )
102
+ const cropStyle = useMemo(() => {
103
+ const crop = activeClip?.sourceCrop
104
+ if (!crop) return null
105
+ const sw = activeClip?.sourceWidth ?? videoDims?.w
106
+ const sh = activeClip?.sourceHeight ?? videoDims?.h
107
+ if (!sw || !sh || !frameSize.w || !frameSize.h) return null
108
+ return sourceCropVideoStyle({
109
+ crop,
110
+ sourceWidth: sw,
111
+ sourceHeight: sh,
112
+ frameWidth: frameSize.w,
113
+ frameHeight: frameSize.h,
114
+ })
115
+ }, [activeClip, videoDims, frameSize])
116
+
117
+ // The default full-frame style (no crop). object-contain letterboxes the source.
118
+ const baseVideoStyle = cropStyle
119
+ ? { ...cropStyle }
120
+ : { position: 'absolute' as const, inset: 0, width: '100%', height: '100%', objectFit: 'contain' as const }
121
+
84
122
  return (
85
123
  <div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
86
124
  {isCanvasProject ? (
@@ -94,24 +132,31 @@ export default function PreviewPlayer({
94
132
  {/* Slot 0 */}
95
133
  <video
96
134
  ref={video0Ref}
97
- className="absolute inset-0 w-full h-full object-contain"
135
+ // Clips load cross-origin from R2; without this the media is CORS-tainted
136
+ // and the Web Audio createMediaElementSource graph outputs silence. R2
137
+ // sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
138
+ crossOrigin="anonymous"
139
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
98
140
  onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
99
141
  onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
100
142
  onPlay={() => { if (activeSlotRef.current === 0) setIsPlaying(true) }}
101
143
  onPause={() => { if (activeSlotRef.current === 0) handlePause() }}
102
144
  playsInline
103
- style={{ opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
145
+ style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
104
146
  />
105
147
  {/* Slot 1 */}
106
148
  <video
107
149
  ref={video1Ref}
108
- className="absolute inset-0 w-full h-full object-contain"
150
+ // See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
151
+ // (which would mute the Web Audio graph).
152
+ crossOrigin="anonymous"
153
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
109
154
  onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
110
155
  onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
111
156
  onPlay={() => { if (activeSlotRef.current === 1) setIsPlaying(true) }}
112
157
  onPause={() => { if (activeSlotRef.current === 1) handlePause() }}
113
158
  playsInline
114
- style={{ opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
159
+ style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
115
160
  />
116
161
  </>
117
162
  )}
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { sourceCropVideoStyle } from '../sourceCropStyle'
3
+
4
+ describe('sourceCropVideoStyle', () => {
5
+ it('returns null for a full-frame (default) crop', () => {
6
+ expect(
7
+ sourceCropVideoStyle({
8
+ crop: { x: 0, y: 0, w: 1, h: 1 },
9
+ sourceWidth: 1920, sourceHeight: 1080,
10
+ frameWidth: 1080, frameHeight: 1920,
11
+ }),
12
+ ).toBeNull()
13
+ })
14
+
15
+ it('returns null without source dims', () => {
16
+ expect(
17
+ sourceCropVideoStyle({
18
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
19
+ sourceWidth: 0, sourceHeight: 0,
20
+ frameWidth: 1080, frameHeight: 1920,
21
+ }),
22
+ ).toBeNull()
23
+ })
24
+
25
+ it('center vertical-strip crop of a landscape source fills a portrait frame', () => {
26
+ // 1920x1080 source, crop the centre 50% width / full height → 960x1080 region
27
+ // (aspect 0.888...). Portrait frame 1080x1920 (aspect 0.5625). Crop aspect >
28
+ // frame aspect → crop fills frame WIDTH, letterboxed vertically.
29
+ const style = sourceCropVideoStyle({
30
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
31
+ sourceWidth: 1920, sourceHeight: 1080,
32
+ frameWidth: 1080, frameHeight: 1920,
33
+ })!
34
+ expect(style).not.toBeNull()
35
+ // videoWRatio = cropWRatio(1) / crop.w(0.5) = 2 → 200% wide
36
+ expect(style.width).toBe('200%')
37
+ // cropAspect = (1920*0.5)/(1080*1) = 0.8889; frameAspect = 0.5625
38
+ // cropHRatio = frameAspect/cropAspect = 0.6328; videoHRatio = /h(1) = 0.6328
39
+ expect(parseFloat(style.height as string)).toBeCloseTo(63.28, 1)
40
+ // leftRatio = (1-1)/2 - 0.25*2 = -0.5 → -50%
41
+ expect(style.left).toBe('-50%')
42
+ // topRatio = (1-0.6328)/2 - 0*... = 0.1836 → ~18.36%
43
+ expect(parseFloat(style.top as string)).toBeCloseTo(18.36, 1)
44
+ expect(style.objectFit).toBe('fill')
45
+ })
46
+
47
+ it('crop region matching frame aspect fills the frame exactly (no letterbox)', () => {
48
+ // Portrait source 1080x1920, crop a centred 9:16 sub-rect → same aspect as a
49
+ // 1080x1920 frame. Should fill edge-to-edge.
50
+ const style = sourceCropVideoStyle({
51
+ crop: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 },
52
+ sourceWidth: 1080, sourceHeight: 1920,
53
+ frameWidth: 1080, frameHeight: 1920,
54
+ })!
55
+ expect(style.width).toBe('125%') // 1/0.8
56
+ expect(style.height).toBe('125%') // 1/0.8
57
+ // left/top = (1-1)/2 - 0.1*1.25 = -0.125 → -12.5%
58
+ expect(style.left).toBe('-12.5%')
59
+ expect(style.top).toBe('-12.5%')
60
+ })
61
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
3
+
4
+ // These helpers mirror render's collectAllItems rebase (render.js): when the
5
+ // preview loads a normalizedSrc window cache (which starts at 0 and covers
6
+ // [inPoint, outPoint] of the original), the effective seek inPoint is 0 and the
7
+ // outPoint is rebased to the window length. nobg_preview_src is the full source
8
+ // and must NOT rebase.
9
+
10
+ describe('effectiveInPoint', () => {
11
+ it('rebases to 0 when normalizedSrc is the chosen src', () => {
12
+ expect(effectiveInPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBe(0)
13
+ })
14
+
15
+ it('returns clip.inPoint when there is no normalizedSrc', () => {
16
+ expect(effectiveInPoint({ inPoint: 496.92, src: '/orig.mov' })).toBe(496.92)
17
+ })
18
+
19
+ it('does NOT rebase when nobg_preview_src takes precedence over normalizedSrc', () => {
20
+ // nobg_preview_src wins in playbackSrcFor and covers the full source.
21
+ expect(
22
+ effectiveInPoint({ inPoint: 496.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/cache/window.mp4' }),
23
+ ).toBe(496.92)
24
+ })
25
+
26
+ it('defaults to 0 when inPoint is absent and no cache', () => {
27
+ expect(effectiveInPoint({ src: '/orig.mov' })).toBe(0)
28
+ })
29
+ })
30
+
31
+ describe('effectiveOutPoint', () => {
32
+ it('rebases to the window length (outPoint - inPoint) for a normalizedSrc cache', () => {
33
+ // original inPoint 496.92, outPoint 514.92 → 18s window cache
34
+ expect(
35
+ effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' }),
36
+ ).toBeCloseTo(18, 5)
37
+ })
38
+
39
+ it('returns the stored outPoint when there is no cache', () => {
40
+ expect(effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, src: '/orig.mov' })).toBe(514.92)
41
+ })
42
+
43
+ it('does NOT rebase when nobg_preview_src takes precedence', () => {
44
+ expect(
45
+ effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/c.mp4' }),
46
+ ).toBe(514.92)
47
+ })
48
+
49
+ it('returns undefined when no outPoint is stored', () => {
50
+ expect(effectiveOutPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBeUndefined()
51
+ })
52
+ })