@bycrux/editor 0.12.0 → 1.0.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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
3
|
+
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react'
|
|
4
|
+
import type { EditorAdapter, ImageElement, Project } from '../../types'
|
|
5
|
+
import RenderModal, { pickSampleTime } from '../RenderModal'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
// ── The Export dialog ─────────────────────────────────────────────────────────
|
|
10
|
+
// The modal historically fired the render on mount with no inputs. Hosts that
|
|
11
|
+
// pass `preRenderOptions` (montaj always does — HDR and SDR alike) now open on
|
|
12
|
+
// the Export dialog first; hosts that omit it keep the old fire-on-mount
|
|
13
|
+
// behavior exactly, which is the regression these tests pin.
|
|
14
|
+
|
|
15
|
+
function baseAdapter(): EditorAdapter<Project> {
|
|
16
|
+
return {
|
|
17
|
+
loadProject: vi.fn(),
|
|
18
|
+
saveProject: vi.fn(),
|
|
19
|
+
subscribe: () => () => {},
|
|
20
|
+
render: vi.fn(async function* () {}),
|
|
21
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
22
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
23
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
24
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
25
|
+
uploadFile: vi.fn(async () => ''),
|
|
26
|
+
fileUrl: (p: string) => `/files?path=${p}`,
|
|
27
|
+
} as unknown as EditorAdapter<Project>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An adapter on the poll transport (renderAsync + getRenderStatus). */
|
|
31
|
+
function pollAdapter(): EditorAdapter<Project> {
|
|
32
|
+
const a = baseAdapter()
|
|
33
|
+
a.renderAsync = vi.fn(async () => ({ status: 'running' }))
|
|
34
|
+
a.getRenderStatus = vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const }))
|
|
35
|
+
return a
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const KEEPS = [{ start: 0, end: 12 }]
|
|
39
|
+
|
|
40
|
+
// Query helpers that disambiguate the dialog title (heading) from the primary
|
|
41
|
+
// action (button), which share the label "Export".
|
|
42
|
+
const exportButton = () => screen.getByRole('button', { name: 'Export' })
|
|
43
|
+
const exportHeading = () => screen.getByRole('heading', { name: 'Export' })
|
|
44
|
+
|
|
45
|
+
describe('pickSampleTime', () => {
|
|
46
|
+
it('lands inside the chosen keep, away from its edges', () => {
|
|
47
|
+
const at = pickSampleTime([{ start: 10, end: 20 }], () => 0.5)!
|
|
48
|
+
expect(at).toBeGreaterThan(10)
|
|
49
|
+
expect(at).toBeLessThan(20)
|
|
50
|
+
// Middle 60% band: 0.2 + 0.5 * 0.6 = 0.5 of the span.
|
|
51
|
+
expect(at).toBeCloseTo(15, 5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('picks from the keeps it is given', () => {
|
|
55
|
+
// rand() = 0 → first keep, earliest point of the sampling band.
|
|
56
|
+
expect(pickSampleTime([{ start: 0, end: 10 }, { start: 100, end: 110 }], () => 0)).toBeCloseTo(2, 5)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns null when there is nothing worth sampling', () => {
|
|
60
|
+
expect(pickSampleTime(undefined)).toBeNull()
|
|
61
|
+
expect(pickSampleTime([])).toBeNull()
|
|
62
|
+
// Slivers are skipped — a frame from a 0.1s keep is not a useful sample.
|
|
63
|
+
expect(pickSampleTime([{ start: 4, end: 4.1 }])).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('RenderModal — fire-on-mount (no preRenderOptions)', () => {
|
|
68
|
+
it('fires the render on mount when preRenderOptions is absent', async () => {
|
|
69
|
+
const adapter = pollAdapter()
|
|
70
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
71
|
+
|
|
72
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
73
|
+
expect(screen.queryByRole('heading', { name: 'Export' })).toBeNull()
|
|
74
|
+
expect(screen.getByText('Rendering…')).toBeTruthy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('passes no render options through when there was nothing to choose', async () => {
|
|
78
|
+
const adapter = pollAdapter()
|
|
79
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
80
|
+
|
|
81
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
82
|
+
expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', undefined)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('RenderModal — Export dialog', () => {
|
|
87
|
+
it('opens for an HDR project and does NOT start the render', async () => {
|
|
88
|
+
const adapter = pollAdapter()
|
|
89
|
+
render(
|
|
90
|
+
<RenderModal
|
|
91
|
+
adapter={adapter}
|
|
92
|
+
projectId="vid-1"
|
|
93
|
+
onClose={vi.fn()}
|
|
94
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
95
|
+
/>,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(exportHeading()).toBeTruthy()
|
|
99
|
+
expect(screen.getByText('Match footage (HDR)')).toBeTruthy()
|
|
100
|
+
expect(exportButton()).toBeTruthy()
|
|
101
|
+
|
|
102
|
+
// Give any effect a chance to misfire before asserting it did not.
|
|
103
|
+
await waitFor(() => expect(screen.getByText('Advanced')).toBeTruthy())
|
|
104
|
+
expect(adapter.renderAsync).not.toHaveBeenCalled()
|
|
105
|
+
expect(adapter.render).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('opens for an SDR project and hides the HDR-only controls', async () => {
|
|
109
|
+
const adapter = pollAdapter()
|
|
110
|
+
render(
|
|
111
|
+
<RenderModal
|
|
112
|
+
adapter={adapter}
|
|
113
|
+
projectId="vid-1"
|
|
114
|
+
onClose={vi.fn()}
|
|
115
|
+
preRenderOptions={{ isHdr: false, keeps: KEEPS, name: 'clip' }}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// The dialog shows, with Name + Save-to, but none of the HDR controls.
|
|
120
|
+
expect(exportHeading()).toBeTruthy()
|
|
121
|
+
expect(screen.getByText('Name')).toBeTruthy()
|
|
122
|
+
expect(screen.getByText('Save to')).toBeTruthy()
|
|
123
|
+
expect(screen.queryByText('Format')).toBeNull()
|
|
124
|
+
expect(screen.queryByText('Match footage (HDR)')).toBeNull()
|
|
125
|
+
expect(screen.queryByText('Image color')).toBeNull()
|
|
126
|
+
expect(screen.queryByText('Advanced')).toBeNull()
|
|
127
|
+
|
|
128
|
+
// Nothing renders until the user hits Export.
|
|
129
|
+
await waitFor(() => expect(screen.getByText('Save to')).toBeTruthy())
|
|
130
|
+
expect(adapter.renderAsync).not.toHaveBeenCalled()
|
|
131
|
+
|
|
132
|
+
fireEvent.click(exportButton())
|
|
133
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('RenderModal — name + cover', () => {
|
|
138
|
+
it('threads the edited name and chosen cover into the render opts (poll)', async () => {
|
|
139
|
+
const adapter = pollAdapter()
|
|
140
|
+
adapter.getSampleFrame = vi.fn(async () => ({ url: '/files?path=/cover.png' }))
|
|
141
|
+
render(
|
|
142
|
+
<RenderModal
|
|
143
|
+
adapter={adapter}
|
|
144
|
+
projectId="vid-1"
|
|
145
|
+
onClose={vi.fn()}
|
|
146
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS, durationSec: 12, name: 'export' }}
|
|
147
|
+
/>,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
fireEvent.change(screen.getByPlaceholderText('export'), { target: { value: 'my-clip' } })
|
|
151
|
+
fireEvent.click(screen.getByText('Edit cover'))
|
|
152
|
+
// Cover is a frame-tile picker with a fixed 10-frame grid. durationSec 12 →
|
|
153
|
+
// tiles at ((i+0.5)/10)*12 = 0.6 / 1.8 / 3.0 / …; clicking the second (index
|
|
154
|
+
// 1) sets cover to 1.8.
|
|
155
|
+
const tiles = within(screen.getByRole('radiogroup', { name: 'Cover frame' })).getAllByRole('radio')
|
|
156
|
+
expect(tiles).toHaveLength(10)
|
|
157
|
+
fireEvent.click(tiles[1])
|
|
158
|
+
fireEvent.click(exportButton())
|
|
159
|
+
|
|
160
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
161
|
+
expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
|
|
162
|
+
export: 'auto',
|
|
163
|
+
sdrCurve: 'vivid1',
|
|
164
|
+
name: 'my-clip',
|
|
165
|
+
cover: expect.closeTo(1.8, 5),
|
|
166
|
+
}))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('defaults the name to the host-suggested value', async () => {
|
|
170
|
+
const adapter = pollAdapter()
|
|
171
|
+
render(
|
|
172
|
+
<RenderModal
|
|
173
|
+
adapter={adapter}
|
|
174
|
+
projectId="vid-1"
|
|
175
|
+
onClose={vi.fn()}
|
|
176
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS, name: 'reel-2' }}
|
|
177
|
+
/>,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
expect((screen.getByPlaceholderText('export') as HTMLInputElement).value).toBe('reel-2')
|
|
181
|
+
fireEvent.click(exportButton())
|
|
182
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
183
|
+
expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({ name: 'reel-2' }))
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('RenderModal — HDR format + curve', () => {
|
|
188
|
+
it('starts the render with the chosen format and curve (poll transport)', async () => {
|
|
189
|
+
const adapter = pollAdapter()
|
|
190
|
+
render(
|
|
191
|
+
<RenderModal
|
|
192
|
+
adapter={adapter}
|
|
193
|
+
projectId="vid-1"
|
|
194
|
+
onClose={vi.fn()}
|
|
195
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
196
|
+
/>,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
fireEvent.click(screen.getByText('Both'))
|
|
200
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
201
|
+
fireEvent.click(screen.getByText('Neutral brights'))
|
|
202
|
+
fireEvent.click(exportButton())
|
|
203
|
+
|
|
204
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
205
|
+
expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
|
|
206
|
+
export: 'both',
|
|
207
|
+
sdrCurve: 'vivid1-neutral',
|
|
208
|
+
}))
|
|
209
|
+
expect(screen.queryByRole('heading', { name: 'Export' })).toBeNull()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('starts the render with the chosen format and curve (SSE transport)', async () => {
|
|
213
|
+
const adapter = baseAdapter()
|
|
214
|
+
render(
|
|
215
|
+
<RenderModal
|
|
216
|
+
adapter={adapter}
|
|
217
|
+
projectId="vid-1"
|
|
218
|
+
onClose={vi.fn()}
|
|
219
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
220
|
+
/>,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
fireEvent.click(screen.getByText('SDR'))
|
|
224
|
+
fireEvent.click(exportButton())
|
|
225
|
+
|
|
226
|
+
await waitFor(() => expect(adapter.render).toHaveBeenCalledTimes(1))
|
|
227
|
+
expect(adapter.render).toHaveBeenCalledWith('vid-1', expect.objectContaining({
|
|
228
|
+
export: 'sdr',
|
|
229
|
+
sdrCurve: 'vivid1',
|
|
230
|
+
}))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('defaults to matching the footage on the default curve', async () => {
|
|
234
|
+
const adapter = pollAdapter()
|
|
235
|
+
render(
|
|
236
|
+
<RenderModal
|
|
237
|
+
adapter={adapter}
|
|
238
|
+
projectId="vid-1"
|
|
239
|
+
onClose={vi.fn()}
|
|
240
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
241
|
+
/>,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
fireEvent.click(exportButton())
|
|
245
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
246
|
+
expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
|
|
247
|
+
export: 'auto',
|
|
248
|
+
sdrCurve: 'vivid1',
|
|
249
|
+
}))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('shows the SDR derive step only when an SDR file is derived', async () => {
|
|
253
|
+
const adapter = pollAdapter()
|
|
254
|
+
const { unmount } = render(
|
|
255
|
+
<RenderModal
|
|
256
|
+
adapter={adapter}
|
|
257
|
+
projectId="vid-1"
|
|
258
|
+
onClose={vi.fn()}
|
|
259
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
260
|
+
/>,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
// Default (auto) → no derive step in the stepper.
|
|
264
|
+
fireEvent.click(exportButton())
|
|
265
|
+
await waitFor(() => expect(screen.getByText('Rendering graphics')).toBeTruthy())
|
|
266
|
+
expect(screen.queryByText('Deriving SDR')).toBeNull()
|
|
267
|
+
unmount()
|
|
268
|
+
|
|
269
|
+
render(
|
|
270
|
+
<RenderModal
|
|
271
|
+
adapter={pollAdapter()}
|
|
272
|
+
projectId="vid-2"
|
|
273
|
+
onClose={vi.fn()}
|
|
274
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
275
|
+
/>,
|
|
276
|
+
)
|
|
277
|
+
fireEvent.click(screen.getByText('Both'))
|
|
278
|
+
fireEvent.click(exportButton())
|
|
279
|
+
await waitFor(() => expect(screen.getByText('Deriving SDR')).toBeTruthy())
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('RenderModal — curve picker honesty line', () => {
|
|
284
|
+
const DEFAULT_LINE = 'SDR export matches this preview; HDR export adds highlight range on HDR displays.'
|
|
285
|
+
|
|
286
|
+
function openAdvanced() {
|
|
287
|
+
const adapter = pollAdapter()
|
|
288
|
+
render(
|
|
289
|
+
<RenderModal
|
|
290
|
+
adapter={adapter}
|
|
291
|
+
projectId="vid-1"
|
|
292
|
+
onClose={vi.fn()}
|
|
293
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
294
|
+
/>,
|
|
295
|
+
)
|
|
296
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
297
|
+
return adapter
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it('states how the export relates to the preview on the default curve', () => {
|
|
301
|
+
openAdvanced()
|
|
302
|
+
expect(screen.getByTestId('sdr-honesty-line').textContent).toBe(DEFAULT_LINE)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('swaps to a differs-from-preview caveat on a non-default curve', () => {
|
|
306
|
+
openAdvanced()
|
|
307
|
+
fireEvent.click(screen.getByText('Neutral brights'))
|
|
308
|
+
|
|
309
|
+
const line = screen.getByTestId('sdr-honesty-line').textContent ?? ''
|
|
310
|
+
expect(line).not.toBe(DEFAULT_LINE)
|
|
311
|
+
expect(line).toMatch(/will not match your preview/i)
|
|
312
|
+
expect(line).toMatch(/Montaj Vivid/)
|
|
313
|
+
|
|
314
|
+
// And back again.
|
|
315
|
+
fireEvent.click(screen.getByText('Montaj Vivid'))
|
|
316
|
+
expect(screen.getByTestId('sdr-honesty-line').textContent).toBe(DEFAULT_LINE)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('keeps every option string free of em dashes (project copy rule)', () => {
|
|
320
|
+
openAdvanced()
|
|
321
|
+
expect(document.body.textContent ?? '').not.toContain('—')
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('RenderModal — curve thumbnails', () => {
|
|
326
|
+
it('samples the same frame once per curve', async () => {
|
|
327
|
+
const adapter = pollAdapter()
|
|
328
|
+
adapter.getSampleFrame = vi.fn(async (_id: string, _at: number, opts?: { sdrCurve?: string }) => ({
|
|
329
|
+
url: `/files?path=/sample-${opts?.sdrCurve}.png`,
|
|
330
|
+
}))
|
|
331
|
+
|
|
332
|
+
render(
|
|
333
|
+
<RenderModal
|
|
334
|
+
adapter={adapter}
|
|
335
|
+
projectId="vid-1"
|
|
336
|
+
onClose={vi.fn()}
|
|
337
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
338
|
+
/>,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// Curve samples are deferred until Advanced is opened (they only show
|
|
342
|
+
// there). Opening it fires exactly one sample per curve; the cover sample is
|
|
343
|
+
// debounced, so it hasn't landed by the time these two resolve.
|
|
344
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
345
|
+
await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
|
|
346
|
+
const calls = (adapter.getSampleFrame as ReturnType<typeof vi.fn>).mock.calls
|
|
347
|
+
expect(calls.map(c => c[2]?.sdrCurve).sort()).toEqual(['vivid1', 'vivid1-neutral'])
|
|
348
|
+
// Both curves must be judged on the SAME frame, inside the given keep.
|
|
349
|
+
expect(calls[0][1]).toBe(calls[1][1])
|
|
350
|
+
expect(calls[0][1]).toBeGreaterThan(0)
|
|
351
|
+
expect(calls[0][1]).toBeLessThan(12)
|
|
352
|
+
|
|
353
|
+
await waitFor(() => {
|
|
354
|
+
const srcs = Array.from(document.querySelectorAll('img')).map(i => i.getAttribute('src'))
|
|
355
|
+
expect(srcs).toContain('/files?path=/sample-vivid1.png')
|
|
356
|
+
expect(srcs).toContain('/files?path=/sample-vivid1-neutral.png')
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('still paints thumbnails under StrictMode double-mount (M3 regression)', async () => {
|
|
361
|
+
// StrictMode fires mount→cleanup→mount in dev. The old effect flipped a
|
|
362
|
+
// per-invocation `alive` flag false in mount #1's cleanup, while mount #2
|
|
363
|
+
// hit the dedup ref-guard and returned without re-arming it — so the fetched
|
|
364
|
+
// thumbnails resolved into a dead closure and never painted. Sampling still
|
|
365
|
+
// ran once (the dedup), which is why it looked like it "worked" everywhere
|
|
366
|
+
// but the screen. Production has no double-invoke, so this was dev-only.
|
|
367
|
+
const adapter = pollAdapter()
|
|
368
|
+
adapter.getSampleFrame = vi.fn(async (_id: string, _at: number, opts?: { sdrCurve?: string }) => ({
|
|
369
|
+
url: `/files?path=/sample-${opts?.sdrCurve}.png`,
|
|
370
|
+
}))
|
|
371
|
+
|
|
372
|
+
render(
|
|
373
|
+
<StrictMode>
|
|
374
|
+
<RenderModal
|
|
375
|
+
adapter={adapter}
|
|
376
|
+
projectId="vid-1"
|
|
377
|
+
onClose={vi.fn()}
|
|
378
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
379
|
+
/>
|
|
380
|
+
</StrictMode>,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
// Open Advanced to trigger the (now-deferred) curve sampling. Dedup still
|
|
384
|
+
// holds: exactly one sample per curve despite the double-mount.
|
|
385
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
386
|
+
await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
|
|
387
|
+
|
|
388
|
+
await waitFor(() => {
|
|
389
|
+
const srcs = Array.from(document.querySelectorAll('img')).map(i => i.getAttribute('src'))
|
|
390
|
+
expect(srcs).toContain('/files?path=/sample-vivid1.png')
|
|
391
|
+
expect(srcs).toContain('/files?path=/sample-vivid1-neutral.png')
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('renders the picker with labels only when the adapter cannot sample', async () => {
|
|
396
|
+
const adapter = pollAdapter() // no getSampleFrame at all
|
|
397
|
+
render(
|
|
398
|
+
<RenderModal
|
|
399
|
+
adapter={adapter}
|
|
400
|
+
projectId="vid-1"
|
|
401
|
+
onClose={vi.fn()}
|
|
402
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
403
|
+
/>,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
407
|
+
expect(screen.getByText('Montaj Vivid')).toBeTruthy()
|
|
408
|
+
expect(screen.getByText('Neutral brights')).toBeTruthy()
|
|
409
|
+
expect(document.querySelectorAll('img').length).toBe(0)
|
|
410
|
+
|
|
411
|
+
// Still perfectly renderable.
|
|
412
|
+
fireEvent.click(exportButton())
|
|
413
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('never blocks the render when sampling fails', async () => {
|
|
417
|
+
const adapter = pollAdapter()
|
|
418
|
+
adapter.getSampleFrame = vi.fn(async () => { throw new Error('ffmpeg exploded') })
|
|
419
|
+
|
|
420
|
+
render(
|
|
421
|
+
<RenderModal
|
|
422
|
+
adapter={adapter}
|
|
423
|
+
projectId="vid-1"
|
|
424
|
+
onClose={vi.fn()}
|
|
425
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
426
|
+
/>,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
430
|
+
await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
|
|
431
|
+
expect(screen.getByText('Montaj Vivid')).toBeTruthy()
|
|
432
|
+
|
|
433
|
+
fireEvent.click(exportButton())
|
|
434
|
+
await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('samples no curve thumbnails when there are no keeps to sample from', () => {
|
|
438
|
+
const adapter = pollAdapter()
|
|
439
|
+
adapter.getSampleFrame = vi.fn(async () => ({ url: '/x.png' }))
|
|
440
|
+
|
|
441
|
+
render(
|
|
442
|
+
<RenderModal
|
|
443
|
+
adapter={adapter}
|
|
444
|
+
projectId="vid-1"
|
|
445
|
+
onClose={vi.fn()}
|
|
446
|
+
preRenderOptions={{ isHdr: true, keeps: [] }}
|
|
447
|
+
/>,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
// The dialog still opens; opening Advanced shows curve labels but no
|
|
451
|
+
// thumbnail sampling was scheduled (sampleAt is null with no keeps).
|
|
452
|
+
expect(exportHeading()).toBeTruthy()
|
|
453
|
+
fireEvent.click(screen.getByText('Advanced'))
|
|
454
|
+
expect(screen.queryByText('Sampling a frame…')).toBeNull()
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
describe('RenderModal — image color control', () => {
|
|
459
|
+
it('persists a tone choice through the host callback', () => {
|
|
460
|
+
const set = vi.fn()
|
|
461
|
+
render(
|
|
462
|
+
<RenderModal
|
|
463
|
+
adapter={pollAdapter()}
|
|
464
|
+
projectId="vid-1"
|
|
465
|
+
onClose={vi.fn()}
|
|
466
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS, imageTone: { value: 'vivid', set } }}
|
|
467
|
+
/>,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
// Image color is a first-class expandable section: click its header to open
|
|
471
|
+
// the tone list, then pick another.
|
|
472
|
+
fireEvent.click(screen.getByText('Image color'))
|
|
473
|
+
fireEvent.click(screen.getByText('Broadcast'))
|
|
474
|
+
expect(set).toHaveBeenCalledWith('broadcast')
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('omits the tone menu when the host supplies no callback', () => {
|
|
478
|
+
render(
|
|
479
|
+
<RenderModal
|
|
480
|
+
adapter={pollAdapter()}
|
|
481
|
+
projectId="vid-1"
|
|
482
|
+
onClose={vi.fn()}
|
|
483
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
484
|
+
/>,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
// With no imageTone callback the whole Image color control is omitted.
|
|
488
|
+
expect(screen.queryByText('Image color:')).toBeNull()
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
describe('RenderModal — leaving the dialog', () => {
|
|
493
|
+
it('cancels without starting a render', () => {
|
|
494
|
+
const onCancel = vi.fn()
|
|
495
|
+
const onClose = vi.fn()
|
|
496
|
+
const adapter = pollAdapter()
|
|
497
|
+
render(
|
|
498
|
+
<RenderModal
|
|
499
|
+
adapter={adapter}
|
|
500
|
+
projectId="vid-1"
|
|
501
|
+
onClose={onClose}
|
|
502
|
+
onCancel={onCancel}
|
|
503
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
504
|
+
/>,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
fireEvent.click(screen.getByText('Cancel'))
|
|
508
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
509
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
510
|
+
expect(adapter.renderAsync).not.toHaveBeenCalled()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('closes on Escape, since nothing is running yet', () => {
|
|
514
|
+
const onCancel = vi.fn()
|
|
515
|
+
render(
|
|
516
|
+
<RenderModal
|
|
517
|
+
adapter={pollAdapter()}
|
|
518
|
+
projectId="vid-1"
|
|
519
|
+
onClose={vi.fn()}
|
|
520
|
+
onCancel={onCancel}
|
|
521
|
+
preRenderOptions={{ isHdr: true, keeps: KEEPS }}
|
|
522
|
+
/>,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
526
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe('RenderModal — done state outputs', () => {
|
|
531
|
+
function sseAdapter(doneEvent: { type: 'done'; outputPath: string; outputPaths?: string[] }) {
|
|
532
|
+
const a = baseAdapter()
|
|
533
|
+
a.render = vi.fn(async function* () { yield doneEvent })
|
|
534
|
+
return a
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
it('a both export surfaces the derived SDR sibling as its own download', async () => {
|
|
538
|
+
const adapter = sseAdapter({
|
|
539
|
+
type: 'done',
|
|
540
|
+
outputPath: '/ws/p/render/final.mp4',
|
|
541
|
+
outputPaths: ['/ws/p/render/final.mp4', '/ws/p/render/final-sdr.mp4'],
|
|
542
|
+
})
|
|
543
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
544
|
+
|
|
545
|
+
await waitFor(() => expect(screen.getByText('Render complete')).toBeTruthy())
|
|
546
|
+
const master = screen.getByText('Download HDR master') as HTMLAnchorElement
|
|
547
|
+
const sdr = screen.getByText('Download SDR version') as HTMLAnchorElement
|
|
548
|
+
expect(master.getAttribute('href')).toContain('final.mp4')
|
|
549
|
+
expect(sdr.getAttribute('href')).toContain('final-sdr.mp4')
|
|
550
|
+
expect(sdr.getAttribute('download')).toBe('final-sdr.mp4')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('a single-file done event keeps the plain Download button', async () => {
|
|
554
|
+
const adapter = sseAdapter({ type: 'done', outputPath: '/ws/p/render/final.mp4' })
|
|
555
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
556
|
+
|
|
557
|
+
await waitFor(() => expect(screen.getByText('Render complete')).toBeTruthy())
|
|
558
|
+
expect(screen.getByText('Download')).toBeTruthy()
|
|
559
|
+
expect(screen.queryByText('Download SDR version')).toBeNull()
|
|
560
|
+
expect(screen.queryByText('Download HDR master')).toBeNull()
|
|
561
|
+
})
|
|
562
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
/**
|
|
3
|
+
* `parseLogProgress` — the SSE/log-transport render progress estimate. It reads
|
|
4
|
+
* the render's two real running counters (frame-baking jobs and composition
|
|
5
|
+
* segments) so the bar tracks total work. The load-bearing case these pin: the
|
|
6
|
+
* renderer logs `encoded <job> (jobsDone/total done)` after EVERY job, and the
|
|
7
|
+
* "done" in "(N/M done)" is a per-job counter — it must NOT be read as the
|
|
8
|
+
* render finishing, which pinned the bar at ~98% for the entire render.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { parseLogProgress } from '../RenderModal'
|
|
12
|
+
|
|
13
|
+
describe('parseLogProgress', () => {
|
|
14
|
+
it('is null before any line lands (indeterminate bar)', () => {
|
|
15
|
+
expect(parseLogProgress([])).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does NOT read a per-job "(N/M done)" counter as the render finishing', () => {
|
|
19
|
+
// THE regression: the first "encoded ... (1/12 done)" used to leap the bar to
|
|
20
|
+
// ~98% and freeze it there for the whole render.
|
|
21
|
+
const p = parseLogProgress([
|
|
22
|
+
'[render] rendering captions chunk 1/10 (120 frames)...',
|
|
23
|
+
'[render] encoded captions chunk 1/10 (1/12 done)',
|
|
24
|
+
])!
|
|
25
|
+
expect(p).toBeLessThan(0.15) // 1 of 12 jobs baked — early, not near-full
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('climbs through frame-baking via jobsDone/total', () => {
|
|
29
|
+
const early = parseLogProgress(['[render] encoded overlay-1 (2/12 done)'])!
|
|
30
|
+
const late = parseLogProgress(['[render] encoded captions chunk 10/10 (11/12 done)'])!
|
|
31
|
+
expect(early).toBeGreaterThan(0.05)
|
|
32
|
+
expect(late).toBeGreaterThan(early)
|
|
33
|
+
expect(late).toBeLessThan(0.55) // baking tops at ~0.55, before composition
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('climbs through composition via "[montaj compose] segment i/N"', () => {
|
|
37
|
+
const start = parseLogProgress(['[render] composing final video...'])!
|
|
38
|
+
const early = parseLogProgress(['[montaj compose] segment 2/30 (0.1-0.2s): 1 item(s)'])!
|
|
39
|
+
const late = parseLogProgress(['[montaj compose] segment 27/30 (5.0-5.1s): 1 item(s)'])!
|
|
40
|
+
expect(start).toBeGreaterThanOrEqual(0.55)
|
|
41
|
+
expect(early).toBeGreaterThanOrEqual(0.55)
|
|
42
|
+
expect(late).toBeGreaterThan(early)
|
|
43
|
+
expect(late).toBeGreaterThan(0.85) // deep into composition
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('is monotonic across a full baking → composition → finalize sequence', () => {
|
|
47
|
+
const seq = [
|
|
48
|
+
'[render] rendering overlay-1 chunk 1/2 (72 frames)...',
|
|
49
|
+
'[render] encoded overlay-1 chunk 1/2 (1/12 done)',
|
|
50
|
+
'[render] encoded captions chunk 5/10 (6/12 done)',
|
|
51
|
+
'[render] encoded captions chunk 10/10 (12/12 done)',
|
|
52
|
+
'[render] composing final video...',
|
|
53
|
+
'[montaj compose] segment 15/30 (2.0s): 1 item(s)',
|
|
54
|
+
'[montaj compose] segment 30/30 (5.0s): 1 item(s)',
|
|
55
|
+
'[montaj compose] concatenating 30 segment(s)...',
|
|
56
|
+
]
|
|
57
|
+
let prev = 0
|
|
58
|
+
for (let i = 1; i <= seq.length; i++) {
|
|
59
|
+
const p = parseLogProgress(seq.slice(0, i))!
|
|
60
|
+
expect(p).toBeGreaterThanOrEqual(prev)
|
|
61
|
+
prev = p
|
|
62
|
+
}
|
|
63
|
+
expect(prev).toBeGreaterThan(0.85)
|
|
64
|
+
})
|
|
65
|
+
})
|