@bycrux/editor 1.2.2 → 1.3.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 +1 -1
- package/src/ControlsInfoModal.tsx +4 -4
- package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
- package/src/__tests__/node-shims.d.ts +19 -0
- package/src/carousel/AddElementMenu.tsx +1 -1
- package/src/carousel/CarouselEditor.tsx +11 -11
- package/src/carousel/CarouselRenderModal.tsx +11 -11
- package/src/carousel/OverlayPicker.tsx +3 -3
- package/src/carousel/SlidePropertyPanel.tsx +6 -6
- package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
- package/src/components/FilmstripScrubber.tsx +1 -1
- package/src/crop/VideoSourceCropModal.tsx +4 -4
- package/src/state/__tests__/use-project-sync.test.tsx +76 -0
- package/src/state/use-project-sync.ts +21 -1
- package/src/text/FontPicker.tsx +3 -3
- package/src/text/TextFormattingToolbar.tsx +3 -3
- package/src/types.ts +16 -0
- package/src/ui/Loader.tsx +1 -1
- package/src/ui/NumberField.tsx +3 -3
- package/src/ui/SwatchInput.tsx +1 -1
- package/src/ui/Tooltip.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/label.tsx +1 -1
- package/src/ui/textarea.tsx +1 -1
- package/src/video/AudioPolishModal.tsx +21 -21
- package/src/video/CaptionListPanel.test.tsx +8 -7
- package/src/video/CaptionListPanel.tsx +17 -17
- package/src/video/CaptionRegenModal.tsx +7 -7
- package/src/video/CaptionStyleGallery.tsx +1 -1
- package/src/video/CommandPalette.tsx +7 -7
- package/src/video/ImageToneMenu.tsx +7 -7
- package/src/video/OverlayInspector.tsx +7 -7
- package/src/video/RenderModal.tsx +52 -52
- package/src/video/VersionCompare.tsx +7 -7
- package/src/video/VersionPanel.tsx +2 -2
- package/src/video/VideoEditor.tsx +43 -23
- package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
- package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
- package/src/video/panels/OverlayContentPanel.tsx +5 -5
- package/src/video/preview/OverlayItemsLayer.tsx +7 -2
- package/src/video/preview/SocialPreviewMenu.tsx +5 -5
- package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +2 -2
- package/src/video/timeline/SpeedControl.tsx +3 -3
- package/src/video/timeline/Timeline.tsx +3 -3
- package/src/video/timeline/TrackGutter.tsx +6 -6
- package/src/video/timeline/TrackSettingsPopover.tsx +3 -3
- package/src/video/timeline/VolumeControl.tsx +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
|
|
2
|
-
import { Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
|
|
2
|
+
import { AlertCircle, Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
4
|
import type { AudioTrack, VisualItem } from '../schema'
|
|
5
5
|
import { useProjectSync, type UseProjectSync } from '../state/use-project-sync'
|
|
@@ -212,6 +212,11 @@ export function exportDurationSec(project: { tracks?: unknown }): number {
|
|
|
212
212
|
* prop, so a status transition (agent finishes → 'draft') must come from the
|
|
213
213
|
* core's own stream, not the host re-rendering with a new prop.
|
|
214
214
|
*
|
|
215
|
+
* `pendingSurface="host"` skips the pending surface: a pending project renders
|
|
216
|
+
* the review surface (which never branches on status), and the host draws its
|
|
217
|
+
* own gate over it. `onUserEdit` reports the user's own edits (never SSE frames)
|
|
218
|
+
* so such a host can react when the user edits while an agent is working.
|
|
219
|
+
*
|
|
215
220
|
* ProjectHeader is lifted out (the host renders it in its shell). This component
|
|
216
221
|
* renders: timeline + preview + version panel + render modal + the host-supplied
|
|
217
222
|
* inspector/subcut render-prop seams + an optional back-to-setup affordance.
|
|
@@ -223,6 +228,8 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
223
228
|
theme,
|
|
224
229
|
slots,
|
|
225
230
|
onBackToSetup,
|
|
231
|
+
pendingSurface = 'default',
|
|
232
|
+
onUserEdit,
|
|
226
233
|
assetsPlacement = 'right',
|
|
227
234
|
renderProgressView = 'phases',
|
|
228
235
|
renderGenerationPanel,
|
|
@@ -245,7 +252,9 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
245
252
|
// both the surface switch and the surface contents. `project` (the prop) is
|
|
246
253
|
// only the initial value — after mount the core owns state and reconciles live
|
|
247
254
|
// frames itself (video-shaped → default plain-replace reconcile).
|
|
248
|
-
|
|
255
|
+
// `onUserEdit` rides the core's `onLocalEdit` (held in a ref there, so a new
|
|
256
|
+
// identity per render is harmless): fired for user edits only, never SSE.
|
|
257
|
+
const sync = useProjectSync<P>(adapter, project.id, project, { onLocalEdit: onUserEdit })
|
|
249
258
|
|
|
250
259
|
// Set for the duration of a caption drag gesture (ReviewSurface's
|
|
251
260
|
// `handleProjectChange` → `commitTimelineEdit`), read by the lane-
|
|
@@ -375,9 +384,9 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
375
384
|
// T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
|
|
376
385
|
const getFilmstrip = adapter.getFilmstrip
|
|
377
386
|
|
|
378
|
-
if (isPending) {
|
|
387
|
+
if (isPending && pendingSurface !== 'host') {
|
|
379
388
|
return (
|
|
380
|
-
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
389
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)] text-[var(--editor-text)]">
|
|
381
390
|
<PendingSurface
|
|
382
391
|
sync={sync}
|
|
383
392
|
adapter={adapter}
|
|
@@ -394,7 +403,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
394
403
|
}
|
|
395
404
|
|
|
396
405
|
return (
|
|
397
|
-
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
406
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)] text-[var(--editor-text)]">
|
|
398
407
|
<ReviewSurface
|
|
399
408
|
sync={sync}
|
|
400
409
|
captionGestureRef={captionGestureRef}
|
|
@@ -629,10 +638,10 @@ function PendingSurface<P extends Project>({
|
|
|
629
638
|
<>
|
|
630
639
|
<div className="flex flex-col items-center gap-2">
|
|
631
640
|
<p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
|
|
632
|
-
<p className="text-[var(--editor-text)]
|
|
641
|
+
<p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
|
|
633
642
|
</div>
|
|
634
643
|
{skillPath && (
|
|
635
|
-
<div className="w-full rounded-xl border-2 border-[var(--editor-accent)]
|
|
644
|
+
<div className="w-full rounded-xl border-2 border-[color-mix(in_srgb,var(--editor-accent)_50%,transparent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]">
|
|
636
645
|
{/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
|
|
637
646
|
`--editor-surface`, and 12px bold is still NORMAL text under WCAG
|
|
638
647
|
(large starts at 18.66px bold), so it needs the 4.5:1 floor. The
|
|
@@ -2133,7 +2142,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2133
2142
|
<div className="shrink-0 flex items-center gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
2134
2143
|
<span
|
|
2135
2144
|
data-testid="preview-timecode"
|
|
2136
|
-
className="mr-auto text-[10px] font-mono tabular-nums text-[var(--editor-text)]
|
|
2145
|
+
className="mr-auto text-[10px] font-mono tabular-nums text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] select-none"
|
|
2137
2146
|
>
|
|
2138
2147
|
{/* The playhead can sit past `previewDuration` while parked in
|
|
2139
2148
|
`getTotalDuration()`'s ~20% trailing headroom (drag room for
|
|
@@ -2155,7 +2164,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2155
2164
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2156
2165
|
currentSocialPreview !== null
|
|
2157
2166
|
? (timelineMode === 'light' ? 'text-sky-600 bg-sky-400/15 hover:bg-sky-400/25' : 'text-sky-400 bg-sky-400/15 hover:bg-sky-400/25')
|
|
2158
|
-
: 'text-[var(--editor-text)]
|
|
2167
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2159
2168
|
}`}
|
|
2160
2169
|
>
|
|
2161
2170
|
{/* Trigger reflects the active selection: the platform's own
|
|
@@ -2180,7 +2189,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2180
2189
|
onClick={toggleFullscreen}
|
|
2181
2190
|
aria-label="Toggle fullscreen"
|
|
2182
2191
|
aria-pressed={isFullscreen}
|
|
2183
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2192
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
|
|
2184
2193
|
>
|
|
2185
2194
|
{isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
|
2186
2195
|
</button>
|
|
@@ -2189,7 +2198,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2189
2198
|
</>
|
|
2190
2199
|
) : (
|
|
2191
2200
|
<div className="flex-1 min-h-0 flex items-center justify-center p-2">
|
|
2192
|
-
<p className="text-[var(--editor-text)]
|
|
2201
|
+
<p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">No clips</p>
|
|
2193
2202
|
</div>
|
|
2194
2203
|
)}
|
|
2195
2204
|
</div>
|
|
@@ -2234,7 +2243,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2234
2243
|
<button
|
|
2235
2244
|
onClick={() => setShowControls(true)}
|
|
2236
2245
|
aria-label="Editor controls & shortcuts"
|
|
2237
|
-
className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2246
|
+
className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_75%,transparent)] bg-transparent hover:text-[var(--editor-text)] hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]"
|
|
2238
2247
|
>
|
|
2239
2248
|
<HelpCircle size={14} />
|
|
2240
2249
|
<span className="text-[10px] leading-none">Controls</span>
|
|
@@ -2245,7 +2254,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2245
2254
|
onClick={sync.undo}
|
|
2246
2255
|
disabled={!sync.canUndo}
|
|
2247
2256
|
aria-label="Undo"
|
|
2248
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2257
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
|
|
2249
2258
|
>
|
|
2250
2259
|
<Undo2 size={12} />
|
|
2251
2260
|
</button>
|
|
@@ -2255,7 +2264,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2255
2264
|
onClick={sync.redo}
|
|
2256
2265
|
disabled={!sync.canRedo}
|
|
2257
2266
|
aria-label="Redo"
|
|
2258
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2267
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
|
|
2259
2268
|
>
|
|
2260
2269
|
<Redo2 size={12} />
|
|
2261
2270
|
</button>
|
|
@@ -2268,7 +2277,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2268
2277
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2269
2278
|
previewAxis
|
|
2270
2279
|
? (timelineMode === 'light' ? 'text-yellow-700 bg-yellow-400/15 hover:bg-yellow-400/25' : 'text-yellow-400 bg-yellow-400/15 hover:bg-yellow-400/25')
|
|
2271
|
-
: 'text-[var(--editor-text)]
|
|
2280
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2272
2281
|
}`}
|
|
2273
2282
|
>
|
|
2274
2283
|
<SeparatorVertical size={12} />
|
|
@@ -2282,7 +2291,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2282
2291
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2283
2292
|
rippleMode
|
|
2284
2293
|
? (timelineMode === 'light' ? 'text-teal-600 bg-teal-400/15 hover:bg-teal-400/25' : 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25')
|
|
2285
|
-
: 'text-[var(--editor-text)]
|
|
2294
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2286
2295
|
}`}
|
|
2287
2296
|
>
|
|
2288
2297
|
<Magnet size={12} />
|
|
@@ -2299,7 +2308,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2299
2308
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
|
2300
2309
|
cropMode
|
|
2301
2310
|
? (timelineMode === 'light' ? 'text-amber-600 bg-amber-400/15 hover:bg-amber-400/25' : 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25')
|
|
2302
|
-
: 'text-[var(--editor-text)]
|
|
2311
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2303
2312
|
}`}
|
|
2304
2313
|
>
|
|
2305
2314
|
<Crop size={12} />
|
|
@@ -2328,7 +2337,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2328
2337
|
<button
|
|
2329
2338
|
onClick={handleAudioPolish}
|
|
2330
2339
|
aria-label="Polish audio"
|
|
2331
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2340
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
|
|
2332
2341
|
>
|
|
2333
2342
|
<Wand2 size={12} />
|
|
2334
2343
|
</button>
|
|
@@ -2342,7 +2351,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2342
2351
|
onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
|
|
2343
2352
|
aria-label="Toggle audible drag-scrub"
|
|
2344
2353
|
aria-pressed={currentAudibleScrub}
|
|
2345
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]
|
|
2354
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]"
|
|
2346
2355
|
>
|
|
2347
2356
|
{currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
|
|
2348
2357
|
</button>
|
|
@@ -2796,9 +2805,9 @@ function ReviewSurface<P extends Project>({
|
|
|
2796
2805
|
package's generic centered default. */
|
|
2797
2806
|
slots?.propertiesEmptyState ?? (
|
|
2798
2807
|
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
|
|
2799
|
-
<SquareDashedMousePointer size={26} className="text-[var(--editor-text)]
|
|
2800
|
-
<p className="text-xs font-medium text-[var(--editor-text)]
|
|
2801
|
-
<p className="text-[11px] text-[var(--editor-text)]
|
|
2808
|
+
<SquareDashedMousePointer size={26} className="text-[color-mix(in_srgb,var(--editor-text)_25%,transparent)]" />
|
|
2809
|
+
<p className="text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Select an element</p>
|
|
2810
|
+
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">Choose a clip, overlay, or track to edit its properties.</p>
|
|
2802
2811
|
</div>
|
|
2803
2812
|
)
|
|
2804
2813
|
)}
|
|
@@ -2808,7 +2817,18 @@ function ReviewSurface<P extends Project>({
|
|
|
2808
2817
|
)
|
|
2809
2818
|
|
|
2810
2819
|
return (
|
|
2811
|
-
<div className="flex flex-col flex-1 overflow-hidden">
|
|
2820
|
+
<div className="relative flex flex-col flex-1 overflow-hidden">
|
|
2821
|
+
{/* Save-error banner — a failed sync.mutate/commit rolls the edit back
|
|
2822
|
+
silently otherwise (see CarouselEditor's identical banner). Sits on
|
|
2823
|
+
the outermost container so it is visible above BOTH layout branches
|
|
2824
|
+
below (CapCut and classic) rather than needing its own copy in each. */}
|
|
2825
|
+
{sync.lastError && (
|
|
2826
|
+
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-3 py-2 rounded-md border border-red-500/40 bg-red-950/80 text-red-200 text-xs">
|
|
2827
|
+
<AlertCircle size={14} />
|
|
2828
|
+
<span>{sync.lastError}</span>
|
|
2829
|
+
<button onClick={sync.clearError} className="ml-2 underline">dismiss</button>
|
|
2830
|
+
</div>
|
|
2831
|
+
)}
|
|
2812
2832
|
{slots?.mediaPanel ? (
|
|
2813
2833
|
/* CapCut layout (opt-in via slots.mediaPanel): three columns across the
|
|
2814
2834
|
top — [left panel | preview | properties] — with a full-width timeline
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act } from '@testing-library/react'
|
|
3
|
+
import type {
|
|
4
|
+
EditorAdapter,
|
|
5
|
+
ImageElement,
|
|
6
|
+
Project,
|
|
7
|
+
RenderEvent,
|
|
8
|
+
VersionEntry,
|
|
9
|
+
WaveformChunk,
|
|
10
|
+
} from '../../types'
|
|
11
|
+
import VideoEditor from '../VideoEditor'
|
|
12
|
+
|
|
13
|
+
// `pendingSurface` and `onUserEdit`: a host that owns the pending gate
|
|
14
|
+
// (`'host'`) gets the full review editor while `status === 'pending'`, and a
|
|
15
|
+
// signal that tells its own user's edits apart from an agent's SSE writes.
|
|
16
|
+
// Adapter helpers copied from VideoEditor.test.tsx.
|
|
17
|
+
|
|
18
|
+
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
19
|
+
// Full EditorAdapter with the video-editor capabilities VideoEditor threads:
|
|
20
|
+
// listVersionHistory / restoreVersion / getWaveformChunks / compileOverlay /
|
|
21
|
+
// fileUrl / resolveCaptionTemplate. No host (`@/`) modules are mocked — the
|
|
22
|
+
// package owns the assembled editor.
|
|
23
|
+
|
|
24
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
25
|
+
return {
|
|
26
|
+
id: 'vid-1',
|
|
27
|
+
name: 'Test Video',
|
|
28
|
+
status: 'draft',
|
|
29
|
+
editingPrompt: '',
|
|
30
|
+
projectType: 'video',
|
|
31
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
32
|
+
tracks: [
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
id: 'clip-0',
|
|
36
|
+
type: 'video',
|
|
37
|
+
src: 'a.mp4',
|
|
38
|
+
start: 0,
|
|
39
|
+
end: 4,
|
|
40
|
+
inPoint: 0,
|
|
41
|
+
outPoint: 4,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
audio: { tracks: [] },
|
|
46
|
+
assets: [],
|
|
47
|
+
...overrides,
|
|
48
|
+
} as Project
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
52
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
53
|
+
/** Push a server-authored SSE frame to every active subscriber. */
|
|
54
|
+
emit: (project: Project) => void
|
|
55
|
+
/** When true, saveProject() blocks until flushSaves() so a save stays pending. */
|
|
56
|
+
setHoldSaves: (hold: boolean) => void
|
|
57
|
+
/** Resolve every held saveProject() promise. */
|
|
58
|
+
flushSaves: () => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
62
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
63
|
+
let subscribers: Array<(project: Project) => void> = []
|
|
64
|
+
let holdSaves = false
|
|
65
|
+
let saveResolvers: Array<() => void> = []
|
|
66
|
+
return {
|
|
67
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
68
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
69
|
+
saveCalls.push({ id, project })
|
|
70
|
+
if (holdSaves) await new Promise<void>((resolve) => saveResolvers.push(resolve))
|
|
71
|
+
}),
|
|
72
|
+
// Capture the sync core's frame callback so a test can drive SSE frames.
|
|
73
|
+
subscribe: (_id: string, onFrame: (project: Project) => void) => {
|
|
74
|
+
subscribers.push(onFrame)
|
|
75
|
+
return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
|
|
76
|
+
},
|
|
77
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
78
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
79
|
+
},
|
|
80
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
81
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
82
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
83
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
84
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
85
|
+
fileUrl: (path: string) => path,
|
|
86
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
87
|
+
restoreVersion: vi.fn(async (_id: string, _hash: string) => makeVideoProject()),
|
|
88
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
89
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
90
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
91
|
+
saveCalls,
|
|
92
|
+
emit: (project: Project) => { for (const s of [...subscribers]) s(project) },
|
|
93
|
+
setHoldSaves: (hold: boolean) => { holdSaves = hold },
|
|
94
|
+
flushSaves: () => { const r = saveResolvers; saveResolvers = []; r.forEach((res) => res()) },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
// The caption panel now splits into Style / Captions sub-tabs and defaults
|
|
100
|
+
// to Style; these tests want the transcript + Regenerate trigger visible, so
|
|
101
|
+
// pin the sub-tab to 'captions' (usePersistentState reads this at mount).
|
|
102
|
+
window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
|
|
103
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
104
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
105
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
106
|
+
observe() {}
|
|
107
|
+
unobserve() {}
|
|
108
|
+
disconnect() {}
|
|
109
|
+
}
|
|
110
|
+
// jsdom doesn't implement media element playback.
|
|
111
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
112
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
113
|
+
// jsdom has no Web Audio API; the video player wires per-clip gain through it.
|
|
114
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
115
|
+
state = 'running'
|
|
116
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
117
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
118
|
+
get destination() { return {} }
|
|
119
|
+
close() {}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
afterEach(() => vi.restoreAllMocks())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
function pendingProject(overrides: Partial<Project> = {}): Project {
|
|
126
|
+
return makeVideoProject({ status: 'pending', tracks: [{ id: 'trk-0', items: [] }], ...overrides } as Partial<Project>)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('VideoEditor — pendingSurface', () => {
|
|
130
|
+
it('default: a pending project shows the built-in pending surface, not the review chrome', async () => {
|
|
131
|
+
const adapter = makeFakeAdapter()
|
|
132
|
+
const { findByTestId, queryByText, container } = render(
|
|
133
|
+
<VideoEditor
|
|
134
|
+
project={pendingProject()}
|
|
135
|
+
adapter={adapter}
|
|
136
|
+
onProjectChange={vi.fn()}
|
|
137
|
+
slots={{ pendingStatus: <div data-testid="pending" /> }}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
await findByTestId('pending')
|
|
141
|
+
expect(container.textContent).toContain('project id:')
|
|
142
|
+
expect(queryByText('Render →')).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("'host': a pending project renders the review editor and none of the pending surface", async () => {
|
|
146
|
+
const adapter = makeFakeAdapter()
|
|
147
|
+
const { findByText, queryByTestId, container } = render(
|
|
148
|
+
<VideoEditor
|
|
149
|
+
project={makeVideoProject({ status: 'pending' })}
|
|
150
|
+
adapter={adapter}
|
|
151
|
+
onProjectChange={vi.fn()}
|
|
152
|
+
pendingSurface="host"
|
|
153
|
+
onBackToSetup={vi.fn()}
|
|
154
|
+
slots={{ pendingStatus: <div data-testid="pending" /> }}
|
|
155
|
+
/>,
|
|
156
|
+
)
|
|
157
|
+
await findByText('Render →')
|
|
158
|
+
expect(queryByTestId('pending')).toBeNull()
|
|
159
|
+
expect(container.textContent).not.toContain('project id:')
|
|
160
|
+
expect(container.textContent).not.toMatch(/Back to setup/i)
|
|
161
|
+
// The skill-path fetch lives in the pending surface only.
|
|
162
|
+
expect(adapter.getInfo).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("'host': a pending project with zero-duration placeholders renders without throwing", async () => {
|
|
166
|
+
const adapter = makeFakeAdapter()
|
|
167
|
+
const project = makeVideoProject({
|
|
168
|
+
status: 'pending',
|
|
169
|
+
tracks: [
|
|
170
|
+
[
|
|
171
|
+
{ id: 'ph-0', type: 'video', src: 'a.mp4', start: 0, end: 0 },
|
|
172
|
+
{ id: 'ph-1', type: 'video', src: 'b.mp4' },
|
|
173
|
+
],
|
|
174
|
+
],
|
|
175
|
+
} as unknown as Partial<Project>)
|
|
176
|
+
const { findByText } = render(
|
|
177
|
+
<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} pendingSurface="host" />,
|
|
178
|
+
)
|
|
179
|
+
await findByText('Render →')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("'host': an SSE frame flipping pending to draft keeps the same surface mounted", async () => {
|
|
183
|
+
const adapter = makeFakeAdapter()
|
|
184
|
+
const initial = makeVideoProject({ status: 'pending' })
|
|
185
|
+
const onProjectChange = vi.fn()
|
|
186
|
+
const { findByText } = render(
|
|
187
|
+
<VideoEditor project={initial} adapter={adapter} onProjectChange={onProjectChange} pendingSurface="host" />,
|
|
188
|
+
)
|
|
189
|
+
const before = await findByText('Render →')
|
|
190
|
+
await act(async () => { adapter.emit({ ...initial, status: 'draft' }) })
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'draft' }))
|
|
193
|
+
})
|
|
194
|
+
const after = await findByText('Render →')
|
|
195
|
+
expect(after).toBe(before)
|
|
196
|
+
expect(before.isConnected).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('VideoEditor — onUserEdit', () => {
|
|
201
|
+
it('fires once for a user edit and once for undo, never for an SSE frame', async () => {
|
|
202
|
+
const adapter = makeFakeAdapter()
|
|
203
|
+
const initial = makeVideoProject({ status: 'draft' })
|
|
204
|
+
const onUserEdit = vi.fn()
|
|
205
|
+
const { findByText } = render(
|
|
206
|
+
<VideoEditor
|
|
207
|
+
project={initial}
|
|
208
|
+
adapter={adapter}
|
|
209
|
+
onProjectChange={vi.fn()}
|
|
210
|
+
onUserEdit={onUserEdit}
|
|
211
|
+
slots={{ exportActions: <div /> }}
|
|
212
|
+
/>,
|
|
213
|
+
)
|
|
214
|
+
const renderBtn = await findByText('Render →')
|
|
215
|
+
// Let mount-time normalization settle: it goes through applyExternal.
|
|
216
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
217
|
+
expect(onUserEdit).not.toHaveBeenCalled()
|
|
218
|
+
|
|
219
|
+
// Render flips status to 'final' through sync.mutate: a user edit.
|
|
220
|
+
await act(async () => { renderBtn.click() })
|
|
221
|
+
expect(onUserEdit).toHaveBeenCalledTimes(1)
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
225
|
+
})
|
|
226
|
+
expect(onUserEdit).toHaveBeenCalledTimes(2)
|
|
227
|
+
|
|
228
|
+
await waitFor(() => expect(adapter.saveCalls.length).toBe(2))
|
|
229
|
+
await act(async () => { adapter.emit({ ...initial, name: 'Agent rename' }) })
|
|
230
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
231
|
+
expect(onUserEdit).toHaveBeenCalledTimes(2)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import VideoEditor from '../VideoEditor'
|
|
5
|
+
|
|
6
|
+
// ── Save-error banner ────────────────────────────────────────────────────────
|
|
7
|
+
// `sync.lastError` (use-project-sync.ts) used to go unrendered in the video
|
|
8
|
+
// editor: a failed save rolled the edit back with no visible sign anything
|
|
9
|
+
// went wrong. CarouselEditor already surfaces it as a dismissible banner
|
|
10
|
+
// (CarouselEditor.tsx ~584-590); this mirrors that pattern in VideoEditor.
|
|
11
|
+
//
|
|
12
|
+
// "M drops a marker" (VideoEditor.keymap.test.tsx) is reused as the trigger —
|
|
13
|
+
// the simplest real `sync.mutate` call reachable without selecting a clip —
|
|
14
|
+
// so this test can focus on the banner itself rather than on how to cause a
|
|
15
|
+
// mutation.
|
|
16
|
+
|
|
17
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
18
|
+
return {
|
|
19
|
+
id: 'vid-1',
|
|
20
|
+
name: 'Test Video',
|
|
21
|
+
status: 'draft',
|
|
22
|
+
editingPrompt: '',
|
|
23
|
+
projectType: 'video',
|
|
24
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
25
|
+
tracks: [
|
|
26
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
27
|
+
],
|
|
28
|
+
audio: { tracks: [] },
|
|
29
|
+
assets: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
} as Project
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeFailingSaveAdapter(): EditorAdapter<Project> {
|
|
35
|
+
return {
|
|
36
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
37
|
+
saveProject: vi.fn(async () => { throw new Error('save boom') }),
|
|
38
|
+
subscribe: () => () => {},
|
|
39
|
+
render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
|
|
40
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
41
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
42
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
43
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
44
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
45
|
+
fileUrl: (path: string) => path,
|
|
46
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
47
|
+
restoreVersion: vi.fn(async () => makeVideoProject()),
|
|
48
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
49
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
50
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
56
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
58
|
+
observe() {}
|
|
59
|
+
unobserve() {}
|
|
60
|
+
disconnect() {}
|
|
61
|
+
}
|
|
62
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
63
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
64
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
65
|
+
state = 'running'
|
|
66
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
67
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
68
|
+
get destination() { return {} }
|
|
69
|
+
close() {}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
afterEach(() => vi.restoreAllMocks())
|
|
73
|
+
|
|
74
|
+
describe('VideoEditor — save-error banner', () => {
|
|
75
|
+
it('shows sync.lastError when a save rejects, and dismiss clears it', async () => {
|
|
76
|
+
const adapter = makeFailingSaveAdapter()
|
|
77
|
+
render(
|
|
78
|
+
<VideoEditor
|
|
79
|
+
project={makeVideoProject()}
|
|
80
|
+
adapter={adapter}
|
|
81
|
+
onProjectChange={vi.fn()}
|
|
82
|
+
slots={{ exportActions: <div /> }}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
await screen.findByLabelText('Preview axis')
|
|
86
|
+
|
|
87
|
+
// No error yet.
|
|
88
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
89
|
+
|
|
90
|
+
// Drop a marker — the simplest sync.mutate a mounted editor can reach
|
|
91
|
+
// without first selecting a clip. Its save rejects per the adapter above.
|
|
92
|
+
await act(async () => {
|
|
93
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const banner = await waitFor(() => screen.getByText('save boom'))
|
|
97
|
+
expect(banner).toBeTruthy()
|
|
98
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
99
|
+
|
|
100
|
+
const dismissBtn = screen.getByText('dismiss')
|
|
101
|
+
await act(async () => { fireEvent.click(dismissBtn) })
|
|
102
|
+
|
|
103
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -68,7 +68,7 @@ export interface ClipPropertiesPanelProps {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
71
|
-
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]
|
|
71
|
+
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
72
72
|
|
|
73
73
|
function basename(path: string): string {
|
|
74
74
|
return path.split('/').pop() ?? path
|
|
@@ -107,7 +107,7 @@ function CollapsibleSection({
|
|
|
107
107
|
type="button"
|
|
108
108
|
aria-expanded={!collapsed}
|
|
109
109
|
onClick={onToggle}
|
|
110
|
-
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]
|
|
110
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] transition-colors hover:text-[var(--editor-text)]"
|
|
111
111
|
>
|
|
112
112
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
113
113
|
<span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
|
|
@@ -358,7 +358,7 @@ function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSl
|
|
|
358
358
|
<div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
|
|
359
359
|
<Crop size={20} />
|
|
360
360
|
</div>
|
|
361
|
-
<p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]
|
|
361
|
+
<p className="max-w-[220px] text-xs leading-relaxed text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
362
362
|
Reframe this clip by cropping its source video. Pick the part of the frame to keep.
|
|
363
363
|
</p>
|
|
364
364
|
<button
|
|
@@ -678,7 +678,7 @@ export default function ClipPropertiesPanel({
|
|
|
678
678
|
if (!selection) {
|
|
679
679
|
return (
|
|
680
680
|
<div className={SECTION_CLASS}>
|
|
681
|
-
<div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]
|
|
681
|
+
<div className="px-3 py-6 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
682
682
|
Select a clip to edit its properties.
|
|
683
683
|
</div>
|
|
684
684
|
</div>
|
|
@@ -65,7 +65,7 @@ export interface OverlayContentPanelProps {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
68
|
-
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]
|
|
68
|
+
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Image prop control: a thumbnail preview plus a file picker that uploads the
|
|
@@ -128,16 +128,16 @@ function ImageField({
|
|
|
128
128
|
className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
|
|
129
129
|
/>
|
|
130
130
|
) : (
|
|
131
|
-
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]
|
|
131
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
|
|
132
132
|
none
|
|
133
133
|
</div>
|
|
134
134
|
)}
|
|
135
135
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
136
|
-
<span className="truncate font-mono text-xs text-[var(--editor-text)]
|
|
136
|
+
<span className="truncate font-mono text-xs text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]" title={value}>
|
|
137
137
|
{value ? value.split('/').pop() : '—'}
|
|
138
138
|
</span>
|
|
139
139
|
{uploadFile ? (
|
|
140
|
-
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]
|
|
140
|
+
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]">
|
|
141
141
|
{busy ? 'Uploading…' : 'Change…'}
|
|
142
142
|
<input
|
|
143
143
|
type="file"
|
|
@@ -324,7 +324,7 @@ export default function OverlayContentPanel({
|
|
|
324
324
|
<div className={SECTION_CLASS}>
|
|
325
325
|
<div className="flex flex-col gap-3 p-2">
|
|
326
326
|
{fields.length === 0 ? (
|
|
327
|
-
<p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]
|
|
327
|
+
<p className="px-1 py-4 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
328
328
|
{item
|
|
329
329
|
? 'This overlay has no editable content.'
|
|
330
330
|
: 'Select an overlay to edit its content.'}
|
|
@@ -113,13 +113,18 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, speed = 1, isPlayi
|
|
|
113
113
|
// already recurses — without this, image paths nested in array/object props
|
|
114
114
|
// (e.g. players[].src, items[].src) reach <img> raw as /var/hub-scratch/... and
|
|
115
115
|
// render blank in preview even though they render correctly in the final MP4.
|
|
116
|
-
// Already-proxied /api/ URLs are left untouched (idempotent).
|
|
116
|
+
// Already-proxied /api/ URLs are left untouched (idempotent). On Windows an
|
|
117
|
+
// absolute path is a drive-letter one (C:\ or C:/); this runs in the browser,
|
|
118
|
+
// so that is a regex, not Node's path module. UNC (\\host) is not rewritten.
|
|
119
|
+
const DRIVE_ABS_PATH = /^[A-Za-z]:[\\/]/
|
|
120
|
+
|
|
117
121
|
export function resolveOverlayPropPaths(
|
|
118
122
|
value: unknown,
|
|
119
123
|
fileUrl: (path: string) => string,
|
|
120
124
|
): unknown {
|
|
121
125
|
if (typeof value === 'string') {
|
|
122
|
-
|
|
126
|
+
const abs = (value.startsWith('/') && !value.startsWith('/api/')) || DRIVE_ABS_PATH.test(value)
|
|
127
|
+
return abs ? fileUrl(value) : value
|
|
123
128
|
}
|
|
124
129
|
if (Array.isArray(value)) {
|
|
125
130
|
return value.map((v) => resolveOverlayPropPaths(v, fileUrl))
|