@bycrux/editor 1.2.1 → 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 +2 -2
- 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/index.ts +1 -0
- 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 +84 -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 +41 -15
- 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 +55 -25
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
- package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
- 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'
|
|
@@ -14,6 +14,7 @@ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
|
|
|
14
14
|
import { addMarker } from './timeline/markers'
|
|
15
15
|
import { repairCaptionWords } from './captionRepair'
|
|
16
16
|
import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
|
|
17
|
+
import { mergeCaptionProfileDefaults } from './captionProfileDefaults'
|
|
17
18
|
import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
|
|
18
19
|
import { visualDuration } from '@bycrux/timeline-core'
|
|
19
20
|
import { audioEnd, computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
|
|
@@ -211,6 +212,11 @@ export function exportDurationSec(project: { tracks?: unknown }): number {
|
|
|
211
212
|
* prop, so a status transition (agent finishes → 'draft') must come from the
|
|
212
213
|
* core's own stream, not the host re-rendering with a new prop.
|
|
213
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
|
+
*
|
|
214
220
|
* ProjectHeader is lifted out (the host renders it in its shell). This component
|
|
215
221
|
* renders: timeline + preview + version panel + render modal + the host-supplied
|
|
216
222
|
* inspector/subcut render-prop seams + an optional back-to-setup affordance.
|
|
@@ -222,6 +228,8 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
222
228
|
theme,
|
|
223
229
|
slots,
|
|
224
230
|
onBackToSetup,
|
|
231
|
+
pendingSurface = 'default',
|
|
232
|
+
onUserEdit,
|
|
225
233
|
assetsPlacement = 'right',
|
|
226
234
|
renderProgressView = 'phases',
|
|
227
235
|
renderGenerationPanel,
|
|
@@ -244,7 +252,9 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
244
252
|
// both the surface switch and the surface contents. `project` (the prop) is
|
|
245
253
|
// only the initial value — after mount the core owns state and reconciles live
|
|
246
254
|
// frames itself (video-shaped → default plain-replace reconcile).
|
|
247
|
-
|
|
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 })
|
|
248
258
|
|
|
249
259
|
// Set for the duration of a caption drag gesture (ReviewSurface's
|
|
250
260
|
// `handleProjectChange` → `commitTimelineEdit`), read by the lane-
|
|
@@ -374,9 +384,9 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
374
384
|
// T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
|
|
375
385
|
const getFilmstrip = adapter.getFilmstrip
|
|
376
386
|
|
|
377
|
-
if (isPending) {
|
|
387
|
+
if (isPending && pendingSurface !== 'host') {
|
|
378
388
|
return (
|
|
379
|
-
<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)]">
|
|
380
390
|
<PendingSurface
|
|
381
391
|
sync={sync}
|
|
382
392
|
adapter={adapter}
|
|
@@ -393,7 +403,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
393
403
|
}
|
|
394
404
|
|
|
395
405
|
return (
|
|
396
|
-
<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)]">
|
|
397
407
|
<ReviewSurface
|
|
398
408
|
sync={sync}
|
|
399
409
|
captionGestureRef={captionGestureRef}
|
|
@@ -628,10 +638,10 @@ function PendingSurface<P extends Project>({
|
|
|
628
638
|
<>
|
|
629
639
|
<div className="flex flex-col items-center gap-2">
|
|
630
640
|
<p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
|
|
631
|
-
<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>
|
|
632
642
|
</div>
|
|
633
643
|
{skillPath && (
|
|
634
|
-
<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)]">
|
|
635
645
|
{/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
|
|
636
646
|
`--editor-surface`, and 12px bold is still NORMAL text under WCAG
|
|
637
647
|
(large starts at 18.66px bold), so it needs the 4.5:1 floor. The
|
|
@@ -2132,7 +2142,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2132
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)]">
|
|
2133
2143
|
<span
|
|
2134
2144
|
data-testid="preview-timecode"
|
|
2135
|
-
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"
|
|
2136
2146
|
>
|
|
2137
2147
|
{/* The playhead can sit past `previewDuration` while parked in
|
|
2138
2148
|
`getTotalDuration()`'s ~20% trailing headroom (drag room for
|
|
@@ -2154,7 +2164,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2154
2164
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2155
2165
|
currentSocialPreview !== null
|
|
2156
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')
|
|
2157
|
-
: 'text-[var(--editor-text)]
|
|
2167
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2158
2168
|
}`}
|
|
2159
2169
|
>
|
|
2160
2170
|
{/* Trigger reflects the active selection: the platform's own
|
|
@@ -2179,7 +2189,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2179
2189
|
onClick={toggleFullscreen}
|
|
2180
2190
|
aria-label="Toggle fullscreen"
|
|
2181
2191
|
aria-pressed={isFullscreen}
|
|
2182
|
-
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)]"
|
|
2183
2193
|
>
|
|
2184
2194
|
{isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
|
2185
2195
|
</button>
|
|
@@ -2188,7 +2198,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2188
2198
|
</>
|
|
2189
2199
|
) : (
|
|
2190
2200
|
<div className="flex-1 min-h-0 flex items-center justify-center p-2">
|
|
2191
|
-
<p className="text-[var(--editor-text)]
|
|
2201
|
+
<p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">No clips</p>
|
|
2192
2202
|
</div>
|
|
2193
2203
|
)}
|
|
2194
2204
|
</div>
|
|
@@ -2233,7 +2243,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2233
2243
|
<button
|
|
2234
2244
|
onClick={() => setShowControls(true)}
|
|
2235
2245
|
aria-label="Editor controls & shortcuts"
|
|
2236
|
-
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)]"
|
|
2237
2247
|
>
|
|
2238
2248
|
<HelpCircle size={14} />
|
|
2239
2249
|
<span className="text-[10px] leading-none">Controls</span>
|
|
@@ -2244,7 +2254,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2244
2254
|
onClick={sync.undo}
|
|
2245
2255
|
disabled={!sync.canUndo}
|
|
2246
2256
|
aria-label="Undo"
|
|
2247
|
-
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"
|
|
2248
2258
|
>
|
|
2249
2259
|
<Undo2 size={12} />
|
|
2250
2260
|
</button>
|
|
@@ -2254,7 +2264,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2254
2264
|
onClick={sync.redo}
|
|
2255
2265
|
disabled={!sync.canRedo}
|
|
2256
2266
|
aria-label="Redo"
|
|
2257
|
-
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"
|
|
2258
2268
|
>
|
|
2259
2269
|
<Redo2 size={12} />
|
|
2260
2270
|
</button>
|
|
@@ -2267,7 +2277,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2267
2277
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2268
2278
|
previewAxis
|
|
2269
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')
|
|
2270
|
-
: 'text-[var(--editor-text)]
|
|
2280
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2271
2281
|
}`}
|
|
2272
2282
|
>
|
|
2273
2283
|
<SeparatorVertical size={12} />
|
|
@@ -2281,7 +2291,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2281
2291
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
2282
2292
|
rippleMode
|
|
2283
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')
|
|
2284
|
-
: 'text-[var(--editor-text)]
|
|
2294
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2285
2295
|
}`}
|
|
2286
2296
|
>
|
|
2287
2297
|
<Magnet size={12} />
|
|
@@ -2298,7 +2308,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2298
2308
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
|
2299
2309
|
cropMode
|
|
2300
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')
|
|
2301
|
-
: 'text-[var(--editor-text)]
|
|
2311
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
2302
2312
|
}`}
|
|
2303
2313
|
>
|
|
2304
2314
|
<Crop size={12} />
|
|
@@ -2327,7 +2337,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2327
2337
|
<button
|
|
2328
2338
|
onClick={handleAudioPolish}
|
|
2329
2339
|
aria-label="Polish audio"
|
|
2330
|
-
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)]"
|
|
2331
2341
|
>
|
|
2332
2342
|
<Wand2 size={12} />
|
|
2333
2343
|
</button>
|
|
@@ -2341,7 +2351,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2341
2351
|
onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
|
|
2342
2352
|
aria-label="Toggle audible drag-scrub"
|
|
2343
2353
|
aria-pressed={currentAudibleScrub}
|
|
2344
|
-
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)]"
|
|
2345
2355
|
>
|
|
2346
2356
|
{currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
|
|
2347
2357
|
</button>
|
|
@@ -2795,9 +2805,9 @@ function ReviewSurface<P extends Project>({
|
|
|
2795
2805
|
package's generic centered default. */
|
|
2796
2806
|
slots?.propertiesEmptyState ?? (
|
|
2797
2807
|
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
|
|
2798
|
-
<SquareDashedMousePointer size={26} className="text-[var(--editor-text)]
|
|
2799
|
-
<p className="text-xs font-medium text-[var(--editor-text)]
|
|
2800
|
-
<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>
|
|
2801
2811
|
</div>
|
|
2802
2812
|
)
|
|
2803
2813
|
)}
|
|
@@ -2807,7 +2817,18 @@ function ReviewSurface<P extends Project>({
|
|
|
2807
2817
|
)
|
|
2808
2818
|
|
|
2809
2819
|
return (
|
|
2810
|
-
<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
|
+
)}
|
|
2811
2832
|
{slots?.mediaPanel ? (
|
|
2812
2833
|
/* CapCut layout (opt-in via slots.mediaPanel): three columns across the
|
|
2813
2834
|
top — [left panel | preview | properties] — with a full-width timeline
|
|
@@ -2954,10 +2975,19 @@ function ReviewSurface<P extends Project>({
|
|
|
2954
2975
|
<CaptionRegenModal
|
|
2955
2976
|
adapter={adapter}
|
|
2956
2977
|
projectId={project.id}
|
|
2978
|
+
profile={project.profile}
|
|
2957
2979
|
existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
|
|
2958
2980
|
onClose={() => setRegenCaptionsOpen(false)}
|
|
2959
|
-
onDone={(captions) => {
|
|
2960
|
-
|
|
2981
|
+
onDone={(captions, profileDefaults) => {
|
|
2982
|
+
// The modal resolved the profile once, before the stream; we fold
|
|
2983
|
+
// its font/color onto the fresh track here rather than there
|
|
2984
|
+
// because this is the seam that decides what lands on the project
|
|
2985
|
+
// — and the merge only ever fills fields the host left unset, so
|
|
2986
|
+
// a host that starts returning them keeps authoring them.
|
|
2987
|
+
// `mergeCaptionProfileDefaults` returns `captions` itself when
|
|
2988
|
+
// there is nothing to seed, which is every host without the seam.
|
|
2989
|
+
const seeded = mergeCaptionProfileDefaults(captions, profileDefaults)
|
|
2990
|
+
sync.applyExternal({ ...syncProjectRef.current, captions: seeded } as P)
|
|
2961
2991
|
setRegenCaptionsOpen(false)
|
|
2962
2992
|
}}
|
|
2963
2993
|
mode={timelineMode}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, waitFor, cleanup } from '@testing-library/react'
|
|
3
|
-
import type { CaptionEvent, EditorAdapter, ImageElement, Project } from '../../types'
|
|
3
|
+
import type { CaptionEvent, CaptionProfileDefaults, EditorAdapter, ImageElement, Project } from '../../types'
|
|
4
4
|
import type { Captions } from '../../schema'
|
|
5
5
|
import CaptionRegenModal from '../CaptionRegenModal'
|
|
6
6
|
|
|
@@ -45,7 +45,9 @@ describe('CaptionRegenModal', () => {
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
|
|
48
|
-
|
|
48
|
+
// Second argument is the profile defaults the host resolved — `null` on
|
|
49
|
+
// this adapter, which implements no `getCaptionProfileDefaults`.
|
|
50
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
|
|
49
51
|
})
|
|
50
52
|
|
|
51
53
|
it('shows an error message verbatim on error', async () => {
|
|
@@ -92,3 +94,160 @@ describe('CaptionRegenModal', () => {
|
|
|
92
94
|
expect(screen.queryByText(/caption rows/i)).toBeNull()
|
|
93
95
|
})
|
|
94
96
|
})
|
|
97
|
+
|
|
98
|
+
// ── Profile-seeded caption defaults ──────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// `adapter.getCaptionProfileDefaults` is an OPTIONAL host seam: this package
|
|
101
|
+
// owns no notion of what a profile is beyond `Project.profile`'s bare name, so
|
|
102
|
+
// a host that can resolve one answers, and a host that cannot omits the method
|
|
103
|
+
// entirely. Every assertion below therefore comes in a pair — the seam present
|
|
104
|
+
// and the seam absent — because the absent case is the one every existing host
|
|
105
|
+
// (Hub, Los Parceros, the OSS `serve` UI) is in today, and it must keep
|
|
106
|
+
// producing the byte-identical call it produced before this seam existed.
|
|
107
|
+
|
|
108
|
+
/** Adapter whose `generateCaptions` is a spy, so the opts argument is readable. */
|
|
109
|
+
function makeSpyAdapter() {
|
|
110
|
+
const adapter = makeAdapter()
|
|
111
|
+
const generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
|
|
112
|
+
yield { type: 'done', captions: doneCaptions }
|
|
113
|
+
})
|
|
114
|
+
adapter.generateCaptions = generateCaptions
|
|
115
|
+
return { adapter, generateCaptions }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PROFILE_DEFAULTS: CaptionProfileDefaults = {
|
|
119
|
+
style: 'karaoke',
|
|
120
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
121
|
+
color: '#112233',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('CaptionRegenModal — profile-seeded caption defaults', () => {
|
|
125
|
+
it('seeds the generation style from the defaults the host resolves for the project profile', async () => {
|
|
126
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
127
|
+
const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
128
|
+
adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<CaptionRegenModal
|
|
132
|
+
adapter={adapter}
|
|
133
|
+
projectId="vid-1"
|
|
134
|
+
profile="sam"
|
|
135
|
+
existingRowCount={1}
|
|
136
|
+
onDone={vi.fn()}
|
|
137
|
+
onClose={vi.fn()}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' }))
|
|
142
|
+
expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('hands the resolved defaults to onDone alongside the fresh captions', async () => {
|
|
146
|
+
// The host merges font/color onto the track at its own apply seam; the
|
|
147
|
+
// modal resolves the profile ONCE and passes what it got, so the host
|
|
148
|
+
// never repeats the round trip (and can never race a second answer).
|
|
149
|
+
const { adapter } = makeSpyAdapter()
|
|
150
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
151
|
+
const onDone = vi.fn()
|
|
152
|
+
|
|
153
|
+
render(
|
|
154
|
+
<CaptionRegenModal
|
|
155
|
+
adapter={adapter}
|
|
156
|
+
projectId="vid-1"
|
|
157
|
+
profile="sam"
|
|
158
|
+
existingRowCount={1}
|
|
159
|
+
onDone={onDone}
|
|
160
|
+
onClose={vi.fn()}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, PROFILE_DEFAULTS))
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('passes no opts at all when the host implements no caption-defaults seam', async () => {
|
|
168
|
+
// Today's behaviour for every existing host, pinned: a bare one-argument
|
|
169
|
+
// call, not `(id, undefined)` and not `(id, {})`.
|
|
170
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
171
|
+
const onDone = vi.fn()
|
|
172
|
+
|
|
173
|
+
render(
|
|
174
|
+
<CaptionRegenModal
|
|
175
|
+
adapter={adapter}
|
|
176
|
+
projectId="vid-1"
|
|
177
|
+
profile="sam"
|
|
178
|
+
existingRowCount={1}
|
|
179
|
+
onDone={onDone}
|
|
180
|
+
onClose={vi.fn()}
|
|
181
|
+
/>,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
|
185
|
+
expect(generateCaptions).toHaveBeenCalledWith('vid-1')
|
|
186
|
+
expect(onDone).toHaveBeenCalledWith(doneCaptions, null)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('never asks for defaults when the project has no profile', async () => {
|
|
190
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
191
|
+
const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
192
|
+
adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
193
|
+
|
|
194
|
+
render(
|
|
195
|
+
<CaptionRegenModal
|
|
196
|
+
adapter={adapter}
|
|
197
|
+
projectId="vid-1"
|
|
198
|
+
existingRowCount={1}
|
|
199
|
+
onDone={vi.fn()}
|
|
200
|
+
onClose={vi.fn()}
|
|
201
|
+
/>,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
205
|
+
expect(getCaptionProfileDefaults).not.toHaveBeenCalled()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('still generates when the caption-defaults seam rejects', async () => {
|
|
209
|
+
// Seeding is a convenience. A profile lookup that 500s must never be the
|
|
210
|
+
// reason a user cannot regenerate their captions.
|
|
211
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
212
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => { throw new Error('boom') })
|
|
213
|
+
const onDone = vi.fn()
|
|
214
|
+
|
|
215
|
+
render(
|
|
216
|
+
<CaptionRegenModal
|
|
217
|
+
adapter={adapter}
|
|
218
|
+
projectId="vid-1"
|
|
219
|
+
profile="sam"
|
|
220
|
+
existingRowCount={1}
|
|
221
|
+
onDone={onDone}
|
|
222
|
+
onClose={vi.fn()}
|
|
223
|
+
/>,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
227
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
|
|
228
|
+
expect(screen.queryByText('boom')).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('passes no style when the profile resolves without one', async () => {
|
|
232
|
+
// A profile that sets only a font and a color must not send `style:
|
|
233
|
+
// undefined` — the host route reads presence, not value.
|
|
234
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
235
|
+
const fontOnly: CaptionProfileDefaults = { fontFamily: '"Inter", system-ui, sans-serif' }
|
|
236
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => fontOnly)
|
|
237
|
+
const onDone = vi.fn()
|
|
238
|
+
|
|
239
|
+
render(
|
|
240
|
+
<CaptionRegenModal
|
|
241
|
+
adapter={adapter}
|
|
242
|
+
projectId="vid-1"
|
|
243
|
+
profile="sam"
|
|
244
|
+
existingRowCount={1}
|
|
245
|
+
onDone={onDone}
|
|
246
|
+
onClose={vi.fn()}
|
|
247
|
+
/>,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
251
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, fontOnly))
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
|
3
3
|
import type { CaptionEvent, EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import type { Captions } from '../../schema'
|
|
4
5
|
import VideoEditor from '../VideoEditor'
|
|
5
6
|
|
|
6
7
|
// ── T1 — host-driven caption trigger seam ────────────────────────────────────
|
|
@@ -132,3 +133,104 @@ describe('VideoEditor — host-driven caption trigger seam', () => {
|
|
|
132
133
|
await waitFor(() => expect(screen.getByText('Regenerating captions…')).toBeTruthy())
|
|
133
134
|
})
|
|
134
135
|
})
|
|
136
|
+
|
|
137
|
+
// ── Profile-seeded caption defaults, through the real apply seam ─────────────
|
|
138
|
+
//
|
|
139
|
+
// The unit-level rules live in `captionProfileDefaults.test.ts`; what these
|
|
140
|
+
// two prove is the WIRING — that `project.profile` reaches the host seam, and
|
|
141
|
+
// that the merged track is what `applyExternal` lands on the project. There is
|
|
142
|
+
// no spy for `applyExternal` (it is internal to `useProjectSync`, and it
|
|
143
|
+
// deliberately calls neither `onProjectChange` nor `adapter.saveProject` — see
|
|
144
|
+
// VideoEditor.captionDelete.test.tsx's note), so the assertion reads the
|
|
145
|
+
// caption color back out of the Format tab's swatch, which renders
|
|
146
|
+
// `project.captions.color` and falls back to '#ffffff' when unset.
|
|
147
|
+
|
|
148
|
+
function readCaptionColor(): string {
|
|
149
|
+
fireEvent.click(screen.getByRole('button', { name: 'Format' }))
|
|
150
|
+
return (screen.getByLabelText('Caption text color') as HTMLInputElement).value
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Adapter whose regen stream terminates with the given track. */
|
|
154
|
+
function makeRegenAdapter(captions: Captions) {
|
|
155
|
+
const adapter = makeFakeAdapter()
|
|
156
|
+
adapter.generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
|
|
157
|
+
yield { type: 'done', captions }
|
|
158
|
+
})
|
|
159
|
+
return adapter
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe('VideoEditor — profile-seeded caption defaults', () => {
|
|
163
|
+
it('merges the profile font/color onto a server track that set neither', async () => {
|
|
164
|
+
const adapter = makeRegenAdapter({
|
|
165
|
+
style: 'pop',
|
|
166
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
167
|
+
})
|
|
168
|
+
const getCaptionProfileDefaults = vi.fn(async () => ({
|
|
169
|
+
style: 'karaoke',
|
|
170
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
171
|
+
color: '#112233',
|
|
172
|
+
}))
|
|
173
|
+
;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
174
|
+
|
|
175
|
+
render(
|
|
176
|
+
<VideoEditor
|
|
177
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
178
|
+
adapter={adapter}
|
|
179
|
+
onProjectChange={vi.fn()}
|
|
180
|
+
/>,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
184
|
+
|
|
185
|
+
// The modal closes itself once the host's onDone runs, which is also when
|
|
186
|
+
// the merged track has landed.
|
|
187
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
188
|
+
expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
|
|
189
|
+
expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' })
|
|
190
|
+
expect(readCaptionColor()).toBe('#112233')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('leaves a color the server response already set alone', async () => {
|
|
194
|
+
const adapter = makeRegenAdapter({
|
|
195
|
+
style: 'pop',
|
|
196
|
+
color: '#ff0000',
|
|
197
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
198
|
+
})
|
|
199
|
+
;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults =
|
|
200
|
+
vi.fn(async () => ({ color: '#112233' }))
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<VideoEditor
|
|
204
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
205
|
+
adapter={adapter}
|
|
206
|
+
onProjectChange={vi.fn()}
|
|
207
|
+
/>,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
211
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
212
|
+
expect(readCaptionColor()).toBe('#ff0000')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('applies the server track untouched on a host with no caption-defaults seam', async () => {
|
|
216
|
+
// Every host shipping today is in this case; the regen call must stay the
|
|
217
|
+
// bare one-argument call it has always been.
|
|
218
|
+
const adapter = makeRegenAdapter({
|
|
219
|
+
style: 'pop',
|
|
220
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
render(
|
|
224
|
+
<VideoEditor
|
|
225
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
226
|
+
adapter={adapter}
|
|
227
|
+
onProjectChange={vi.fn()}
|
|
228
|
+
/>,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
232
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
233
|
+
expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1')
|
|
234
|
+
expect(readCaptionColor()).toBe('#ffffff')
|
|
235
|
+
})
|
|
236
|
+
})
|