@bycrux/editor 0.11.2 → 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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
|
@@ -1,6 +1,80 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import
|
|
3
|
+
import { ChevronDown, ChevronRight, ChevronLeft, Maximize2 } from 'lucide-react'
|
|
4
|
+
import type {
|
|
5
|
+
EditorAdapter,
|
|
6
|
+
Project,
|
|
7
|
+
RenderExport,
|
|
8
|
+
RenderOptions,
|
|
9
|
+
RenderPhase,
|
|
10
|
+
RenderStatus,
|
|
11
|
+
} from '../types'
|
|
12
|
+
import { IMAGE_TONES, DEFAULT_IMAGE_TONE, type ImageTone } from './imageTone'
|
|
13
|
+
import { TONE_EXAMPLES } from './imageToneExamples'
|
|
14
|
+
import { SDR_CURVES, DEFAULT_SDR_CURVE, honestyLine, type SdrCurve } from './sdrCurves'
|
|
15
|
+
import { Loader } from '../ui/Loader'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Host-supplied inputs for the pre-render options state. Absent (or `isHdr:
|
|
19
|
+
* false`) → the modal keeps its zero-friction behavior: it fires the render on
|
|
20
|
+
* mount with no inputs at all. Present with `isHdr: true` → it opens on the
|
|
21
|
+
* options panel instead and only renders once the user hits Start render,
|
|
22
|
+
* because an HDR project now has an export decision to make.
|
|
23
|
+
*/
|
|
24
|
+
export interface PreRenderOptions {
|
|
25
|
+
/** True when this project renders HDR, which is the only case with a choice. */
|
|
26
|
+
isHdr: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Windows on the project timeline (seconds) the curve thumbnails may sample
|
|
29
|
+
* from — the kept segments of track 0. The modal picks one at random and
|
|
30
|
+
* samples a single timestamp inside it, so both curves are compared on the
|
|
31
|
+
* same frame. Empty/absent → no thumbnails, just labels.
|
|
32
|
+
*/
|
|
33
|
+
keeps?: Array<{ start: number; end: number }>
|
|
34
|
+
/**
|
|
35
|
+
* Current HDR image color mapping plus its setter, so the export dialog can
|
|
36
|
+
* surface the same `ImageToneMenu` the toolbar shows. The setter is the
|
|
37
|
+
* host's normal persistence path (an editor mutation that saves and undoes).
|
|
38
|
+
* Absent → the menu is not rendered.
|
|
39
|
+
*/
|
|
40
|
+
imageTone?: { value: ImageTone; set: (tone: ImageTone) => void }
|
|
41
|
+
/**
|
|
42
|
+
* Suggested output filename (no extension), seeding the dialog's Name field.
|
|
43
|
+
* Absent → the field defaults to `export`.
|
|
44
|
+
*/
|
|
45
|
+
name?: string
|
|
46
|
+
/**
|
|
47
|
+
* Total project-timeline length in seconds — the render's duration. Seeds the
|
|
48
|
+
* dialog's duration footer and bounds the cover-frame slider (0..durationSec).
|
|
49
|
+
* Absent → treated as 0 (footer/slider fall back gracefully).
|
|
50
|
+
*/
|
|
51
|
+
durationSec?: number
|
|
52
|
+
/**
|
|
53
|
+
* Output aspect ratio (width / height), so the cover preview matches the
|
|
54
|
+
* project's shape — a vertical project gets a vertical cover. Absent → 16/9.
|
|
55
|
+
*/
|
|
56
|
+
aspectRatio?: number
|
|
57
|
+
/**
|
|
58
|
+
* Export resolution control — source-capped tier picker.
|
|
59
|
+
* `value` — the currently-selected [w, h] (initial = the project's current settings.resolution).
|
|
60
|
+
* `available` — the tiers the dialog may offer, each as [w, h] preserving aspect; ordered ascending by short-side.
|
|
61
|
+
* `set` — the host's persistence setter (mutates settings.resolution + saves). Absent → no dropdown rendered.
|
|
62
|
+
*/
|
|
63
|
+
resolution?: {
|
|
64
|
+
value: [number, number]
|
|
65
|
+
available: Array<[number, number]>
|
|
66
|
+
set: (r: [number, number]) => void
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Export fps control — source-capped tier picker. `value` is the currently-selected fps
|
|
70
|
+
* (initial = settings.fps ?? 30). `available` is the fps tiers offerable (asc). `set` persists.
|
|
71
|
+
*/
|
|
72
|
+
fps?: {
|
|
73
|
+
value: number
|
|
74
|
+
available: number[]
|
|
75
|
+
set: (f: number) => void
|
|
76
|
+
}
|
|
77
|
+
}
|
|
4
78
|
|
|
5
79
|
interface RenderModalProps<P extends Project = Project> {
|
|
6
80
|
projectId: string
|
|
@@ -14,6 +88,12 @@ interface RenderModalProps<P extends Project = Project> {
|
|
|
14
88
|
* away from the editor — the project is unchanged and the user is likely
|
|
15
89
|
* about to keep editing. Defaults to onClose if not provided (back-compat). */
|
|
16
90
|
onCancel?: () => void
|
|
91
|
+
/** Fired exactly once, the moment the render transitions to 'done' (either
|
|
92
|
+
* transport — the poll loop or the SSE stream) — NOT on modal close, and
|
|
93
|
+
* not on error. Callers use this to refresh state that a finished render
|
|
94
|
+
* affects (e.g. re-fetching version history), independent of when/whether
|
|
95
|
+
* the operator dismisses the modal. */
|
|
96
|
+
onRenderComplete?: () => void
|
|
17
97
|
/** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
|
|
18
98
|
* in the done state's action area, mirroring the carousel render modal. */
|
|
19
99
|
exportActions?: ReactNode
|
|
@@ -27,6 +107,22 @@ interface RenderModalProps<P extends Project = Project> {
|
|
|
27
107
|
* Threaded down from the host as a flag (montaj-native → 'logs', Hub clients
|
|
28
108
|
* → 'phases'), mirroring `VideoEditorProps.renderProgressView`. */
|
|
29
109
|
progressView?: 'phases' | 'logs'
|
|
110
|
+
/**
|
|
111
|
+
* Opens the modal on a pre-render options panel instead of firing the render
|
|
112
|
+
* on mount. Only HDR projects have anything to choose, so hosts pass this
|
|
113
|
+
* with `isHdr: true` for those and omit it (or pass `isHdr: false`)
|
|
114
|
+
* everywhere else, which preserves the fire-on-mount behavior exactly. */
|
|
115
|
+
preRenderOptions?: PreRenderOptions
|
|
116
|
+
/** Editor theme mode — light/dark. The export dialog and progress/error
|
|
117
|
+
* panels follow `--editor-surface`, so warning/error hues need to darken
|
|
118
|
+
* in light mode. The render-log terminal boxes stay dark by design and so
|
|
119
|
+
* take no mode: they are painted an OPAQUE near-black (`#0a0e17`/`#0e131f`)
|
|
120
|
+
* rather than a translucent `bg-black/40`, which would have composited to
|
|
121
|
+
* mid-grey over a light surface instead of staying a terminal. Those two
|
|
122
|
+
* values are exactly what the old translucent blacks composited to over the
|
|
123
|
+
* dark `--editor-surface`, so dark mode is unchanged. Absent -> dark,
|
|
124
|
+
* matching every existing caller. */
|
|
125
|
+
mode?: 'light' | 'dark'
|
|
30
126
|
}
|
|
31
127
|
|
|
32
128
|
/** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
|
|
@@ -34,6 +130,32 @@ type RenderMedia = NonNullable<RenderStatus['media']>[number]
|
|
|
34
130
|
|
|
35
131
|
function basename(p: string) { return p.split('/').pop() ?? p }
|
|
36
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Client mirror of the server's output-name sanitizer
|
|
135
|
+
* (`serve/routes/projects.py` `_sanitize_output_name`): basename, drop the
|
|
136
|
+
* extension and `..`, keep a conservative whitelist, trim leading/trailing
|
|
137
|
+
* space/dot. Falls back to `export` so an empty field is deterministic and the
|
|
138
|
+
* "Save to" line always shows the real filename the render will produce (the
|
|
139
|
+
* server re-sanitizes, so this only needs to match for the common cases).
|
|
140
|
+
*/
|
|
141
|
+
export function sanitizeOutputName(name: string): string {
|
|
142
|
+
const base = (name.split(/[/\\]/).pop() ?? '')
|
|
143
|
+
.replace(/\.[^.]*$/, '')
|
|
144
|
+
.replace(/\.\./g, '')
|
|
145
|
+
.replace(/[^A-Za-z0-9 _.-]+/g, '')
|
|
146
|
+
.replace(/^[ .]+|[ .]+$/g, '')
|
|
147
|
+
return base || 'export'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
/** mm:ss for a duration/timecode in seconds. Clamps negatives to 0:00. */
|
|
152
|
+
function formatTimecode(sec: number): string {
|
|
153
|
+
const total = Math.max(0, Math.floor(sec))
|
|
154
|
+
const m = Math.floor(total / 60)
|
|
155
|
+
const s = total % 60
|
|
156
|
+
return `${m}:${String(s).padStart(2, '0')}`
|
|
157
|
+
}
|
|
158
|
+
|
|
37
159
|
// ── Phase model (pure, exported for tests + the stepper) ──────────────────────
|
|
38
160
|
|
|
39
161
|
/**
|
|
@@ -45,6 +167,7 @@ export const RENDER_PHASES: RenderPhase[] = [
|
|
|
45
167
|
'rendering',
|
|
46
168
|
'captions',
|
|
47
169
|
'encoding',
|
|
170
|
+
'sdr_derive',
|
|
48
171
|
'saving',
|
|
49
172
|
'done',
|
|
50
173
|
]
|
|
@@ -52,12 +175,13 @@ export const RENDER_PHASES: RenderPhase[] = [
|
|
|
52
175
|
/** User-facing label for a render phase. Honest, plain-English, no jargon. */
|
|
53
176
|
export function phaseLabel(phase: RenderPhase): string {
|
|
54
177
|
switch (phase) {
|
|
55
|
-
case 'preparing':
|
|
56
|
-
case 'rendering':
|
|
57
|
-
case 'captions':
|
|
58
|
-
case 'encoding':
|
|
59
|
-
case '
|
|
60
|
-
case '
|
|
178
|
+
case 'preparing': return 'Preparing'
|
|
179
|
+
case 'rendering': return 'Rendering graphics'
|
|
180
|
+
case 'captions': return 'Adding captions'
|
|
181
|
+
case 'encoding': return 'Encoding video'
|
|
182
|
+
case 'sdr_derive': return 'Deriving SDR'
|
|
183
|
+
case 'saving': return 'Saving to your library'
|
|
184
|
+
case 'done': return 'Done'
|
|
61
185
|
}
|
|
62
186
|
}
|
|
63
187
|
|
|
@@ -66,9 +190,67 @@ export function phaseIndex(phase: RenderPhase): number {
|
|
|
66
190
|
return RENDER_PHASES.indexOf(phase)
|
|
67
191
|
}
|
|
68
192
|
|
|
69
|
-
/** The
|
|
193
|
+
/** The phases shown in the running stepper (terminal `done` excluded). */
|
|
70
194
|
const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
|
|
71
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The stepper rows for a given render. `sdr_derive` only runs when an HDR
|
|
198
|
+
* project asked for an SDR file, so listing it on every render would promise a
|
|
199
|
+
* step that never happens.
|
|
200
|
+
*/
|
|
201
|
+
export function stepperPhases(opts?: RenderOptions): RenderPhase[] {
|
|
202
|
+
const derives = opts?.export === 'sdr' || opts?.export === 'both'
|
|
203
|
+
return derives ? STEPPER_PHASES : STEPPER_PHASES.filter(p => p !== 'sdr_derive')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Pick one timestamp (project-timeline seconds) to sample the curve thumbnails
|
|
208
|
+
* at: a random point inside a random keep, kept away from the segment edges so
|
|
209
|
+
* the frame doesn't land on a transition or a black first frame. Returns null
|
|
210
|
+
* when there is nothing worth sampling, which is the caller's signal to render
|
|
211
|
+
* the picker without thumbnails. `rand` is injectable for tests.
|
|
212
|
+
*/
|
|
213
|
+
export function pickSampleTime(
|
|
214
|
+
keeps: Array<{ start: number; end: number }> | undefined,
|
|
215
|
+
rand: () => number = Math.random,
|
|
216
|
+
): number | null {
|
|
217
|
+
const usable = (keeps ?? []).filter(k => k.end - k.start > 0.2)
|
|
218
|
+
if (usable.length === 0) return null
|
|
219
|
+
const keep = usable[Math.min(usable.length - 1, Math.floor(rand() * usable.length))]
|
|
220
|
+
const span = keep.end - keep.start
|
|
221
|
+
// Middle 60% of the keep.
|
|
222
|
+
return keep.start + span * (0.2 + rand() * 0.6)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The three export choices, in the order they read best: safest first. */
|
|
226
|
+
const EXPORT_CHOICES: Array<{ id: RenderExport; label: string; blurb: string }> = [
|
|
227
|
+
{ id: 'auto', label: 'Match footage (HDR)', blurb: 'One HDR file, the way it was shot.' },
|
|
228
|
+
{ id: 'sdr', label: 'SDR', blurb: 'One standard file. Plays the same everywhere.' },
|
|
229
|
+
{ id: 'both', label: 'Both', blurb: 'The HDR file plus an SDR copy beside it.' },
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
/** Friendly tier name for a resolution, keyed off the short side (e.g. 1080
|
|
233
|
+
* for both 1080x1920 and 1920x1080). Falls back to "<n>p" for non-standard
|
|
234
|
+
* short sides so an odd source resolution still reads sensibly. */
|
|
235
|
+
export function resLabel([w, h]: [number, number]): string {
|
|
236
|
+
const short = Math.min(w, h)
|
|
237
|
+
switch (short) {
|
|
238
|
+
case 720: return '720p'
|
|
239
|
+
case 1080: return '1080p (HD)'
|
|
240
|
+
case 1440: return '1440p (2K)'
|
|
241
|
+
case 2160: return '2160p (4K)'
|
|
242
|
+
default: return `${short}p`
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Short helper line under an fps tier button — a plain-English hint, empty
|
|
247
|
+
* for the neutral default (30). */
|
|
248
|
+
function fpsHint(fps: number): string {
|
|
249
|
+
if (fps === 24) return 'cinema'
|
|
250
|
+
if (fps === 60) return 'smooth'
|
|
251
|
+
return ''
|
|
252
|
+
}
|
|
253
|
+
|
|
72
254
|
const POLL_INTERVAL_MS = 2500
|
|
73
255
|
|
|
74
256
|
/**
|
|
@@ -83,11 +265,11 @@ const MAX_POLL_FAILURES = 12
|
|
|
83
265
|
|
|
84
266
|
// ── Stepper ───────────────────────────────────────────────────────────────────
|
|
85
267
|
|
|
86
|
-
function PhaseStepper({ current }: { current: RenderPhase }) {
|
|
268
|
+
function PhaseStepper({ current, phases }: { current: RenderPhase; phases: RenderPhase[] }) {
|
|
87
269
|
const currentIdx = phaseIndex(current)
|
|
88
270
|
return (
|
|
89
271
|
<div className="flex flex-col gap-3">
|
|
90
|
-
{
|
|
272
|
+
{phases.map((phase) => {
|
|
91
273
|
const idx = phaseIndex(phase)
|
|
92
274
|
// `done` sits past every stepper phase, so a done status marks them all complete.
|
|
93
275
|
const complete = idx < currentIdx
|
|
@@ -145,18 +327,159 @@ function LogLine({ text }: { text: string }) {
|
|
|
145
327
|
)
|
|
146
328
|
}
|
|
147
329
|
|
|
148
|
-
|
|
330
|
+
// ── Progress ────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Best-effort render progress (0..1) for the SSE/log transport, where the phase
|
|
334
|
+
* is pinned to 'rendering' and only log lines arrive. It reads the two phases
|
|
335
|
+
* that carry a real running counter, so the bar tracks TOTAL work rather than
|
|
336
|
+
* leaping on a keyword:
|
|
337
|
+
*
|
|
338
|
+
* • Frame-baking (renderer.js): `encoded <job> (jobsDone/jobs.length done)` —
|
|
339
|
+
* every overlay/caption element (chunked) is one job; jobsDone/total drives
|
|
340
|
+
* 0.05 → 0.55. The "done" in "(N/M done)" is a per-JOB counter, NOT the
|
|
341
|
+
* render finishing — matching it as completion is what pinned the bar at
|
|
342
|
+
* ~98% for the entire render.
|
|
343
|
+
* • Composition (compose.js): `[montaj compose] segment i/N` — drives
|
|
344
|
+
* 0.55 → 0.90.
|
|
345
|
+
*
|
|
346
|
+
* Counter-less markers (composition start, concat, SDR derive, cleanup) nudge
|
|
347
|
+
* the bar between those bands. Monotonic (`Math.max`), so a counter resetting
|
|
348
|
+
* between phases never walks it backward. Returns null before any line lands.
|
|
349
|
+
*/
|
|
350
|
+
export function parseLogProgress(logs: string[]): number | null {
|
|
351
|
+
if (logs.length === 0) return null
|
|
352
|
+
let p = 0.02 // a line exists, so the engine has started
|
|
353
|
+
for (const line of logs) {
|
|
354
|
+
// Frame-baking overall counter: "encoded <job> (jobsDone/total done)".
|
|
355
|
+
const baked = line.match(/\((\d+)\s*\/\s*(\d+)\s+done\)/i)
|
|
356
|
+
if (baked) {
|
|
357
|
+
const n = Number(baked[1]), d = Number(baked[2])
|
|
358
|
+
if (d > 0 && n <= d) p = Math.max(p, 0.05 + 0.50 * (n / d))
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
// Composition per-segment counter: "[montaj compose] segment i/N".
|
|
362
|
+
const comp = line.match(/segment\s+(\d+)\s*\/\s*(\d+)/i)
|
|
363
|
+
if (comp && /compos/i.test(line)) {
|
|
364
|
+
const n = Number(comp[1]), d = Number(comp[2])
|
|
365
|
+
if (d > 0 && n <= d) p = Math.max(p, 0.55 + 0.35 * (n / d))
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
// Counter-less phase markers.
|
|
369
|
+
if (/composing final video/i.test(line)) p = Math.max(p, 0.55)
|
|
370
|
+
else if (/concatenat/i.test(line)) p = Math.max(p, 0.90)
|
|
371
|
+
else if (/deriving|\bsdr\b/i.test(line)) p = Math.max(p, 0.92)
|
|
372
|
+
else if (/intermediate files cleaned/i.test(line)) p = Math.max(p, 0.96)
|
|
373
|
+
else if (/rendering .*\bframes\)/i.test(line)) p = Math.max(p, 0.05) // baking started
|
|
374
|
+
}
|
|
375
|
+
return p
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A slim progress bar. A number 0..1 draws a determinate fill; `null` draws an
|
|
380
|
+
* indeterminate sweep (keyframes inlined so the bar is self-contained in any
|
|
381
|
+
* host, no Tailwind config dependency).
|
|
382
|
+
*/
|
|
383
|
+
function ProgressBar({ value }: { value: number | null }) {
|
|
384
|
+
const pct = value === null ? null : Math.max(0, Math.min(1, value)) * 100
|
|
385
|
+
return (
|
|
386
|
+
<div className="w-full max-w-xs" role="progressbar" aria-label="Render progress"
|
|
387
|
+
aria-valuenow={pct === null ? undefined : Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
|
|
388
|
+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--editor-text)]/10">
|
|
389
|
+
{pct === null ? (
|
|
390
|
+
<>
|
|
391
|
+
<style>{'@keyframes montajRenderIndet{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}'}</style>
|
|
392
|
+
<div className="h-full w-1/3 rounded-full bg-[var(--editor-accent)]"
|
|
393
|
+
style={{ animation: 'montajRenderIndet 1.2s ease-in-out infinite' }} />
|
|
394
|
+
</>
|
|
395
|
+
) : (
|
|
396
|
+
<div className="h-full rounded-full bg-[var(--editor-accent)] transition-[width] duration-500 ease-out"
|
|
397
|
+
style={{ width: `${pct}%` }} />
|
|
398
|
+
)}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, onRenderComplete, exportActions, progressView, preRenderOptions, mode = 'dark' }: RenderModalProps<P>) {
|
|
149
405
|
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
150
406
|
const [phase, setPhase] = useState<RenderPhase>('preparing')
|
|
151
407
|
const [logs, setLogs] = useState<string[]>([])
|
|
152
408
|
const [media, setMedia] = useState<RenderMedia[] | null>(null)
|
|
153
409
|
const [outputPath, setOutput] = useState<string | null>(null)
|
|
410
|
+
// Extra files beyond the primary (a `both` export's derived SDR sibling).
|
|
411
|
+
const [extraOutputs, setExtraOutputs] = useState<string[]>([])
|
|
154
412
|
const [errorMsg, setError] = useState<string | null>(null)
|
|
155
413
|
const logRef = useRef<HTMLDivElement>(null)
|
|
156
414
|
const cancelledRef = useRef(false)
|
|
157
415
|
const unmountedRef = useRef(false)
|
|
158
416
|
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
159
417
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
418
|
+
// Guards onRenderComplete so it fires exactly once per render attempt even
|
|
419
|
+
// though the poll loop can tick again after 'done' (see the note at
|
|
420
|
+
// `pollTimerRef.current = setInterval(...)` below) and even across both
|
|
421
|
+
// completion transports (poll vs. SSE).
|
|
422
|
+
const renderCompleteFiredRef = useRef(false)
|
|
423
|
+
|
|
424
|
+
// ── Pre-render options (the Export dialog) ─────────────────────────────────
|
|
425
|
+
// `started` gates the render effect. Hosts that pass `preRenderOptions` open
|
|
426
|
+
// on the dialog (montaj always does — for HDR and SDR alike); hosts that omit
|
|
427
|
+
// it keep the historical fire-on-mount behavior exactly. The HDR-only controls
|
|
428
|
+
// (Format / Image color / SDR curve) stay gated on `isHdr` inside the dialog.
|
|
429
|
+
const showOptions = !!preRenderOptions
|
|
430
|
+
const isHdr = !!preRenderOptions?.isHdr
|
|
431
|
+
const durationSec = preRenderOptions?.durationSec ?? 0
|
|
432
|
+
// Cover preview follows the project's shape. Portrait projects constrain by
|
|
433
|
+
// height (so a tall cover can't overrun the dialog); landscape fills the column.
|
|
434
|
+
const coverAspect = preRenderOptions?.aspectRatio && preRenderOptions.aspectRatio > 0
|
|
435
|
+
? preRenderOptions.aspectRatio
|
|
436
|
+
: 16 / 9
|
|
437
|
+
const coverPortrait = coverAspect < 1
|
|
438
|
+
const [started, setStarted] = useState(!showOptions)
|
|
439
|
+
const [exportChoice, setExport] = useState<RenderExport>('auto')
|
|
440
|
+
const [sdrCurve, setSdrCurve] = useState<SdrCurve>(DEFAULT_SDR_CURVE)
|
|
441
|
+
const [advancedOpen, setAdvanced] = useState(false)
|
|
442
|
+
const [imageColorOpen, setImageColor] = useState(false)
|
|
443
|
+
const [showLog, setShowLog] = useState(false)
|
|
444
|
+
const [thumbs, setThumbs] = useState<Record<string, string>>({})
|
|
445
|
+
const [thumbsPending, setPending] = useState(false)
|
|
446
|
+
// The SDR curve whose thumbnail is opened full-size in the lightbox (see
|
|
447
|
+
// below), so the small grid preview can be inspected at detail and paged
|
|
448
|
+
// between. Null = closed.
|
|
449
|
+
const [expandedCurveId, setExpandedCurveId] = useState<string | null>(null)
|
|
450
|
+
// Chosen once per mount so both curves are sampled on the same frame. HDR-only
|
|
451
|
+
// — the curve compare (and thus this sample) doesn't exist for SDR projects.
|
|
452
|
+
const [sampleAt] = useState<number | null>(() =>
|
|
453
|
+
isHdr ? pickSampleTime(preRenderOptions?.keeps) : null)
|
|
454
|
+
const thumbsRequestedRef = useRef(false)
|
|
455
|
+
|
|
456
|
+
// ── Name + cover (every project) ────────────────────────────────────────────
|
|
457
|
+
// Name seeds the output filename; cover is the poster-frame timecode (project
|
|
458
|
+
// seconds). Cover defaults to a sampled keep frame, or the timeline midpoint.
|
|
459
|
+
const [name, setName] = useState<string>(preRenderOptions?.name ?? 'export')
|
|
460
|
+
const [coverTime, setCoverTime] = useState<number>(() => {
|
|
461
|
+
const picked = pickSampleTime(preRenderOptions?.keeps)
|
|
462
|
+
return picked ?? (durationSec > 0 ? durationSec / 2 : 0)
|
|
463
|
+
})
|
|
464
|
+
const [coverEditing, setCoverEditing] = useState(false)
|
|
465
|
+
const [coverUrl, setCoverUrl] = useState<string | null>(null)
|
|
466
|
+
const [coverLoading, setCoverLoading] = useState(false)
|
|
467
|
+
// "Edit cover" frame picker: evenly-spaced sample times (roughly one per 4s,
|
|
468
|
+
// clamped — each tile is a full composited sample_frame render, so the grid is
|
|
469
|
+
// bounded and loaded lazily). `coverTiles` maps tile index → resolved URL.
|
|
470
|
+
const [coverTiles, setCoverTiles] = useState<Record<number, string>>({})
|
|
471
|
+
const coverTileTimes = useMemo<number[]>(() => {
|
|
472
|
+
if (durationSec <= 0) return []
|
|
473
|
+
const n = 10 // fixed grid of 10 evenly-spaced frames (was ~one per 4s)
|
|
474
|
+
return Array.from({ length: n }, (_, i) => ((i + 0.5) / n) * durationSec)
|
|
475
|
+
}, [durationSec])
|
|
476
|
+
const coverTilesRequestedRef = useRef<string>('')
|
|
477
|
+
|
|
478
|
+
// The options the render was started with. Read through a ref inside the
|
|
479
|
+
// render effect (which must not re-fire when unrelated state changes) and
|
|
480
|
+
// held in state for the stepper, which needs to re-render on it.
|
|
481
|
+
const [renderOpts, setRenderOpts] = useState<RenderOptions | undefined>(undefined)
|
|
482
|
+
const renderOptsRef = useRef<RenderOptions | undefined>(undefined)
|
|
160
483
|
|
|
161
484
|
// `usePolling` is the data-transport decision (does this adapter expose the
|
|
162
485
|
// poll-based render API?) — independent of which progress UI we show.
|
|
@@ -166,7 +489,97 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
166
489
|
// 'logs' only renders content on the SSE transport, which streams log lines.
|
|
167
490
|
const view = progressView ?? 'phases'
|
|
168
491
|
|
|
492
|
+
// Curve thumbnails: one sample of the SAME frame per curve, fetched in
|
|
493
|
+
// parallel and folded in as they land. Deliberately fire-and-forget — the
|
|
494
|
+
// options UI is already on screen, an absent `getSampleFrame` or a failed
|
|
495
|
+
// call just leaves the picker showing labels, and nothing here can block or
|
|
496
|
+
// fail the render.
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
// Deferred until Advanced is actually open: the curve thumbnails only show
|
|
499
|
+
// inside that (collapsed-by-default) disclosure, so sampling them on mount
|
|
500
|
+
// spent two full frame renders on previews most exports never look at. Now
|
|
501
|
+
// the modal opens sampling just the cover; the curves sample the first time
|
|
502
|
+
// Advanced is expanded (once — `thumbsRequestedRef` dedups).
|
|
503
|
+
if (started || sampleAt === null || !advancedOpen) return
|
|
504
|
+
const getSampleFrame = adapter.getSampleFrame
|
|
505
|
+
if (!getSampleFrame) return
|
|
506
|
+
// Fetch each curve's thumbnail once. The ref guard dedups StrictMode's
|
|
507
|
+
// mount→cleanup→mount so dev doesn't sample every frame twice.
|
|
508
|
+
//
|
|
509
|
+
// Delivery is deliberately NOT gated on a per-invocation `alive` flag. Under
|
|
510
|
+
// StrictMode mount #1 fires the fetches and its cleanup would flip that flag
|
|
511
|
+
// to false, while mount #2 hits this dedup guard and returns without ever
|
|
512
|
+
// re-arming it — so the in-flight results land in a dead closure and no
|
|
513
|
+
// thumbnail ever paints (dev-only; production has no double-invoke). Because
|
|
514
|
+
// setThumbs/setPending are functional-merge updates they are safe to call
|
|
515
|
+
// from whichever mount owns the fetches; on a genuine unmount React 18 makes
|
|
516
|
+
// the setState a harmless no-op. So we just deliver.
|
|
517
|
+
if (thumbsRequestedRef.current) return
|
|
518
|
+
thumbsRequestedRef.current = true
|
|
519
|
+
|
|
520
|
+
let outstanding = SDR_CURVES.length
|
|
521
|
+
setPending(true)
|
|
522
|
+
const settle = () => { if (--outstanding === 0) setPending(false) }
|
|
523
|
+
|
|
524
|
+
for (const curve of SDR_CURVES) {
|
|
525
|
+
getSampleFrame(projectId, sampleAt, { sdrCurve: curve.id })
|
|
526
|
+
.then(({ url }) => setThumbs(t => ({ ...t, [curve.id]: url })))
|
|
527
|
+
.catch(() => {})
|
|
528
|
+
.finally(settle)
|
|
529
|
+
}
|
|
530
|
+
}, [started, sampleAt, adapter, projectId, advancedOpen])
|
|
531
|
+
|
|
532
|
+
// Cover preview: sample the poster frame at `coverTime`, DEBOUNCED so dragging
|
|
533
|
+
// the slider doesn't spam the sampler. Fire-and-forget like the curve
|
|
534
|
+
// thumbnails — an absent `getSampleFrame` or a failed call just leaves the
|
|
535
|
+
// placeholder, and nothing here can block or fail the render. The `alive`
|
|
536
|
+
// flag + clearTimeout drop a stale drag's in-flight request (and make the
|
|
537
|
+
// debounce naturally StrictMode-safe: the first mount's timer is cleared
|
|
538
|
+
// before it can fire).
|
|
169
539
|
useEffect(() => {
|
|
540
|
+
if (started) return
|
|
541
|
+
const getSampleFrame = adapter.getSampleFrame
|
|
542
|
+
if (!getSampleFrame) return
|
|
543
|
+
let alive = true
|
|
544
|
+
setCoverLoading(true)
|
|
545
|
+
const timer = setTimeout(() => {
|
|
546
|
+
// Sample from the SDR proxy — the cover is a poster preview, not a
|
|
547
|
+
// colour-graded frame, so it doesn't need the master or a per-curve tone
|
|
548
|
+
// map (the real poster is made from the master at export). Much faster,
|
|
549
|
+
// and it means dragging the cover slider no longer re-samples on curve.
|
|
550
|
+
getSampleFrame(projectId, coverTime, { preferProxy: true })
|
|
551
|
+
.then(({ url }) => { if (alive) setCoverUrl(url) })
|
|
552
|
+
.catch(() => {})
|
|
553
|
+
.finally(() => { if (alive) setCoverLoading(false) })
|
|
554
|
+
}, 200)
|
|
555
|
+
return () => { alive = false; clearTimeout(timer) }
|
|
556
|
+
}, [started, coverTime, adapter, projectId])
|
|
557
|
+
|
|
558
|
+
// Cover frame grid: sampled lazily once the picker opens, one composited frame
|
|
559
|
+
// per tile from the SDR proxy (fast; these are only for picking a timestamp),
|
|
560
|
+
// folded in as each lands. Keyed by tile count so re-opening the picker reuses
|
|
561
|
+
// what's cached. Fire-and-forget — a failed/absent sampler just leaves that
|
|
562
|
+
// tile on its loader.
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (started || !coverEditing) return
|
|
565
|
+
const getSampleFrame = adapter.getSampleFrame
|
|
566
|
+
if (!getSampleFrame || coverTileTimes.length === 0) return
|
|
567
|
+
const key = `${coverTileTimes.length}`
|
|
568
|
+
if (coverTilesRequestedRef.current === key) return
|
|
569
|
+
coverTilesRequestedRef.current = key
|
|
570
|
+
setCoverTiles({})
|
|
571
|
+
let alive = true
|
|
572
|
+
coverTileTimes.forEach((t, i) => {
|
|
573
|
+
getSampleFrame(projectId, t, { preferProxy: true })
|
|
574
|
+
.then(({ url }) => { if (alive) setCoverTiles(prev => ({ ...prev, [i]: url })) })
|
|
575
|
+
.catch(() => {})
|
|
576
|
+
})
|
|
577
|
+
return () => { alive = false }
|
|
578
|
+
}, [started, coverEditing, coverTileTimes, adapter, projectId])
|
|
579
|
+
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (!started) return
|
|
582
|
+
|
|
170
583
|
// React StrictMode in dev fires mount → cleanup → mount synchronously to
|
|
171
584
|
// catch effects that aren't idempotent. Triggering a render is the textbook
|
|
172
585
|
// non-idempotent effect (spawns a subprocess), so we have to handle it
|
|
@@ -186,12 +599,13 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
186
599
|
|
|
187
600
|
unmountedRef.current = false
|
|
188
601
|
cancelledRef.current = false
|
|
602
|
+
renderCompleteFiredRef.current = false
|
|
189
603
|
|
|
190
604
|
void (async () => {
|
|
191
605
|
try {
|
|
192
606
|
if (usePolling) {
|
|
193
607
|
// Async, poll-based render: kick once, then poll status until terminal.
|
|
194
|
-
await adapter.renderAsync!(projectId)
|
|
608
|
+
await adapter.renderAsync!(projectId, renderOptsRef.current)
|
|
195
609
|
if (unmountedRef.current || cancelledRef.current) return
|
|
196
610
|
|
|
197
611
|
let pollFailures = 0
|
|
@@ -227,6 +641,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
227
641
|
stopPolling()
|
|
228
642
|
setMedia(snap.media ?? null)
|
|
229
643
|
setStatus('done')
|
|
644
|
+
fireRenderComplete()
|
|
230
645
|
} else if (snap.status === 'error') {
|
|
231
646
|
stopPolling()
|
|
232
647
|
setError(snap.error ?? 'Render failed.')
|
|
@@ -247,7 +662,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
247
662
|
pollTimerRef.current = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
|
|
248
663
|
} else {
|
|
249
664
|
// Fallback for older hosts: consume the SSE render stream.
|
|
250
|
-
for await (const ev of adapter.render(projectId)) {
|
|
665
|
+
for await (const ev of adapter.render(projectId, renderOptsRef.current)) {
|
|
251
666
|
if (unmountedRef.current || cancelledRef.current) break
|
|
252
667
|
if (ev.type === 'log') {
|
|
253
668
|
// Streaming hosts have no phase signal. Accumulate the line for
|
|
@@ -257,7 +672,9 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
257
672
|
setPhase('rendering')
|
|
258
673
|
} else if (ev.type === 'done') {
|
|
259
674
|
setOutput(ev.outputPath)
|
|
675
|
+
setExtraOutputs(ev.outputPaths?.slice(1) ?? [])
|
|
260
676
|
setStatus('done')
|
|
677
|
+
fireRenderComplete()
|
|
261
678
|
} else {
|
|
262
679
|
setError(ev.message)
|
|
263
680
|
setStatus('error')
|
|
@@ -281,6 +698,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
281
698
|
}
|
|
282
699
|
}
|
|
283
700
|
|
|
701
|
+
// See `renderCompleteFiredRef`'s declaration: guards against firing more
|
|
702
|
+
// than once for a single render attempt — both because the poll loop can
|
|
703
|
+
// tick again after 'done' (a stray `setInterval` re-arm right after the
|
|
704
|
+
// immediate first tick already resolved) and because callers only care
|
|
705
|
+
// about the transition into 'done', not every render that reaches it.
|
|
706
|
+
function fireRenderComplete() {
|
|
707
|
+
if (renderCompleteFiredRef.current) return
|
|
708
|
+
renderCompleteFiredRef.current = true
|
|
709
|
+
onRenderComplete?.()
|
|
710
|
+
}
|
|
711
|
+
|
|
284
712
|
function scheduleCleanup() {
|
|
285
713
|
// Defer the actual teardown. StrictMode's transient unmount fires before
|
|
286
714
|
// the next mount; setTimeout(0) puts the teardown after both, giving the
|
|
@@ -292,21 +720,39 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
292
720
|
stopPolling()
|
|
293
721
|
}, 0)
|
|
294
722
|
}
|
|
295
|
-
}, [projectId, adapter])
|
|
723
|
+
}, [projectId, adapter, started])
|
|
296
724
|
|
|
297
725
|
// Auto-scroll the log panel as lines stream in.
|
|
298
726
|
useEffect(() => {
|
|
299
727
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
300
728
|
}, [logs])
|
|
301
729
|
|
|
302
|
-
// Escape to close
|
|
730
|
+
// Escape to close when done/error, and from the options panel — nothing has
|
|
731
|
+
// been started there, so backing out is free.
|
|
303
732
|
useEffect(() => {
|
|
304
733
|
const onKey = (e: KeyboardEvent) => {
|
|
305
|
-
|
|
734
|
+
// Lightbox open: Escape closes it, arrows page between the loaded curve
|
|
735
|
+
// previews. It captures these keys so they don't also close the dialog.
|
|
736
|
+
if (expandedCurveId) {
|
|
737
|
+
if (e.key === 'Escape') { setExpandedCurveId(null); return }
|
|
738
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
739
|
+
e.preventDefault()
|
|
740
|
+
const avail = SDR_CURVES.filter(c => thumbs[c.id])
|
|
741
|
+
if (avail.length > 1) {
|
|
742
|
+
const cur = avail.findIndex(c => c.id === expandedCurveId)
|
|
743
|
+
const step = e.key === 'ArrowRight' ? 1 : -1
|
|
744
|
+
setExpandedCurveId(avail[(cur + step + avail.length) % avail.length].id)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
if (e.key !== 'Escape') return
|
|
750
|
+
if (!started) (onCancel ?? onClose)()
|
|
751
|
+
else if (status !== 'running') onClose()
|
|
306
752
|
}
|
|
307
753
|
document.addEventListener('keydown', onKey)
|
|
308
754
|
return () => document.removeEventListener('keydown', onKey)
|
|
309
|
-
}, [status, onClose])
|
|
755
|
+
}, [status, started, onClose, onCancel, expandedCurveId, thumbs])
|
|
310
756
|
|
|
311
757
|
function handleCancel() {
|
|
312
758
|
cancelledRef.current = true
|
|
@@ -317,6 +763,474 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
317
763
|
;(onCancel ?? onClose)()
|
|
318
764
|
}
|
|
319
765
|
|
|
766
|
+
function handleStart() {
|
|
767
|
+
const opts: RenderOptions = {
|
|
768
|
+
export: exportChoice,
|
|
769
|
+
sdrCurve,
|
|
770
|
+
name: sanitizeOutputName(name),
|
|
771
|
+
cover: coverTime,
|
|
772
|
+
}
|
|
773
|
+
// The ref is what the render effect reads (it must not re-fire on unrelated
|
|
774
|
+
// state); the state copy drives the stepper's phase list.
|
|
775
|
+
renderOptsRef.current = opts
|
|
776
|
+
setRenderOpts(opts)
|
|
777
|
+
setStarted(true)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Export dialog ───────────────────────────────────────────────────────────
|
|
781
|
+
if (!started) {
|
|
782
|
+
const curveInfo = SDR_CURVES.find(c => c.id === sdrCurve) ?? SDR_CURVES[0]
|
|
783
|
+
const outputName = sanitizeOutputName(name)
|
|
784
|
+
// Lightbox nav: only curves whose thumbnail has loaded are pageable.
|
|
785
|
+
const expandedCurve = expandedCurveId ? SDR_CURVES.find(c => c.id === expandedCurveId) ?? null : null
|
|
786
|
+
const expandedUrl = expandedCurveId ? thumbs[expandedCurveId] : undefined
|
|
787
|
+
const navCurves = SDR_CURVES.filter(c => thumbs[c.id])
|
|
788
|
+
const canNavCurves = navCurves.length > 1
|
|
789
|
+
const goCurve = (step: number) => {
|
|
790
|
+
const cur = navCurves.findIndex(c => c.id === expandedCurveId)
|
|
791
|
+
if (cur >= 0) setExpandedCurveId(navCurves[(cur + step + navCurves.length) % navCurves.length].id)
|
|
792
|
+
}
|
|
793
|
+
return (<>
|
|
794
|
+
{createPortal(
|
|
795
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
|
|
796
|
+
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
|
797
|
+
|
|
798
|
+
{/* Header */}
|
|
799
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
|
800
|
+
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Export</h2>
|
|
801
|
+
<button
|
|
802
|
+
onClick={handleCancel}
|
|
803
|
+
aria-label="Close"
|
|
804
|
+
className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
|
|
805
|
+
>
|
|
806
|
+
×
|
|
807
|
+
</button>
|
|
808
|
+
</div>
|
|
809
|
+
|
|
810
|
+
{/* Two-column body: cover on the left, settings on the right. */}
|
|
811
|
+
<div className="flex flex-col md:flex-row gap-5 px-5 py-4 max-h-[70vh] overflow-y-auto">
|
|
812
|
+
|
|
813
|
+
{/* Left — cover preview + editor */}
|
|
814
|
+
<div className="md:w-64 shrink-0 flex flex-col gap-2">
|
|
815
|
+
<div
|
|
816
|
+
className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 flex items-center justify-center"
|
|
817
|
+
style={coverPortrait
|
|
818
|
+
? { aspectRatio: coverAspect, height: 'min(46vh, 22rem)' }
|
|
819
|
+
: { aspectRatio: coverAspect, width: '100%' }}
|
|
820
|
+
>
|
|
821
|
+
{coverUrl ? (
|
|
822
|
+
<img
|
|
823
|
+
src={coverUrl}
|
|
824
|
+
alt="Cover frame"
|
|
825
|
+
className="w-full h-full object-cover"
|
|
826
|
+
/>
|
|
827
|
+
) : coverLoading ? (
|
|
828
|
+
<Loader size="md" label="Loading cover" />
|
|
829
|
+
) : (
|
|
830
|
+
<span className="text-[11px] text-[var(--editor-text)]/40">No preview</span>
|
|
831
|
+
)}
|
|
832
|
+
</div>
|
|
833
|
+
{adapter.getSampleFrame && (
|
|
834
|
+
<>
|
|
835
|
+
<button
|
|
836
|
+
onClick={() => setCoverEditing(o => !o)}
|
|
837
|
+
aria-expanded={coverEditing}
|
|
838
|
+
className="self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
|
|
839
|
+
>
|
|
840
|
+
{coverEditing ? 'Done' : 'Edit cover'}
|
|
841
|
+
</button>
|
|
842
|
+
{coverEditing && (
|
|
843
|
+
coverTileTimes.length > 0 ? (
|
|
844
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1" role="radiogroup" aria-label="Cover frame">
|
|
845
|
+
{coverTileTimes.map((t, i) => {
|
|
846
|
+
const url = coverTiles[i]
|
|
847
|
+
const nearest = coverTileTimes.reduce((best, tt, j) =>
|
|
848
|
+
Math.abs(tt - coverTime) < Math.abs(coverTileTimes[best] - coverTime) ? j : best, 0)
|
|
849
|
+
const active = i === nearest
|
|
850
|
+
return (
|
|
851
|
+
<button
|
|
852
|
+
key={i}
|
|
853
|
+
role="radio"
|
|
854
|
+
aria-checked={active}
|
|
855
|
+
onClick={() => setCoverTime(t)}
|
|
856
|
+
title={formatTimecode(t)}
|
|
857
|
+
className={`relative shrink-0 overflow-hidden rounded border transition-colors ${
|
|
858
|
+
active
|
|
859
|
+
? 'border-[var(--editor-accent)] ring-1 ring-[var(--editor-accent)]'
|
|
860
|
+
: 'border-white/10 hover:border-white/40'
|
|
861
|
+
}`}
|
|
862
|
+
style={{ aspectRatio: coverAspect, height: 72 }}
|
|
863
|
+
>
|
|
864
|
+
{url
|
|
865
|
+
? <img src={url} alt="" className="h-full w-full object-cover" />
|
|
866
|
+
: <span className="flex h-full w-full items-center justify-center bg-black/40"><Loader size="sm" /></span>}
|
|
867
|
+
</button>
|
|
868
|
+
)
|
|
869
|
+
})}
|
|
870
|
+
</div>
|
|
871
|
+
) : (
|
|
872
|
+
<p className="text-[11px] text-[var(--editor-text)]/40">No frames to pick from.</p>
|
|
873
|
+
)
|
|
874
|
+
)}
|
|
875
|
+
</>
|
|
876
|
+
)}
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
{/* Right — settings */}
|
|
880
|
+
<div className="flex-1 min-w-0 flex flex-col gap-4">
|
|
881
|
+
{/* Name */}
|
|
882
|
+
<label className="flex flex-col gap-1.5">
|
|
883
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Name</span>
|
|
884
|
+
<input
|
|
885
|
+
type="text"
|
|
886
|
+
value={name}
|
|
887
|
+
onChange={(e) => setName(e.target.value)}
|
|
888
|
+
placeholder="export"
|
|
889
|
+
className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] transition-colors"
|
|
890
|
+
/>
|
|
891
|
+
</label>
|
|
892
|
+
|
|
893
|
+
{/* Save to */}
|
|
894
|
+
<div className="flex flex-col gap-1">
|
|
895
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Save to</span>
|
|
896
|
+
<span className="text-xs font-mono text-[var(--editor-text)]/70 truncate">output/{outputName}.mp4</span>
|
|
897
|
+
<span className="text-[11px] text-[var(--editor-text)]/45">Download after export.</span>
|
|
898
|
+
</div>
|
|
899
|
+
|
|
900
|
+
{/* Resolution — source-capped tier picker. Only rendered when the
|
|
901
|
+
host supplies a `resolution` control; the tile whose short
|
|
902
|
+
side equals the CURRENT project resolution is pre-selected.
|
|
903
|
+
That selected state is the only affordance — no "recommended"
|
|
904
|
+
badge (per operator: the pre-selection is enough, and the
|
|
905
|
+
badge overflowed the tile). */}
|
|
906
|
+
{preRenderOptions?.resolution && (() => {
|
|
907
|
+
const res = preRenderOptions.resolution
|
|
908
|
+
const currentShort = Math.min(...res.value)
|
|
909
|
+
return (
|
|
910
|
+
<div className="flex flex-col gap-2">
|
|
911
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Resolution</p>
|
|
912
|
+
<div role="radiogroup" aria-label="Resolution" className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
913
|
+
{res.available.map(([w, h]) => {
|
|
914
|
+
const short = Math.min(w, h)
|
|
915
|
+
const active = short === currentShort
|
|
916
|
+
return (
|
|
917
|
+
<button
|
|
918
|
+
key={`${w}x${h}`}
|
|
919
|
+
role="radio"
|
|
920
|
+
aria-checked={active}
|
|
921
|
+
onClick={() => res.set([w, h])}
|
|
922
|
+
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
923
|
+
active
|
|
924
|
+
? 'border-violet-400/60 bg-violet-400/10'
|
|
925
|
+
: 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
|
|
926
|
+
}`}
|
|
927
|
+
>
|
|
928
|
+
<span className="text-xs font-semibold text-[var(--editor-text)]">{resLabel([w, h])}</span>
|
|
929
|
+
<span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{w} × {h}</span>
|
|
930
|
+
</button>
|
|
931
|
+
)
|
|
932
|
+
})}
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
)
|
|
936
|
+
})()}
|
|
937
|
+
|
|
938
|
+
{/* Frame rate — source-capped tier picker, same idiom as Resolution. */}
|
|
939
|
+
{preRenderOptions?.fps && (() => {
|
|
940
|
+
const fps = preRenderOptions.fps
|
|
941
|
+
return (
|
|
942
|
+
<div className="flex flex-col gap-2">
|
|
943
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Frame rate</p>
|
|
944
|
+
<div role="radiogroup" aria-label="Frame rate" className="grid grid-cols-3 gap-2">
|
|
945
|
+
{fps.available.map(f => {
|
|
946
|
+
const active = f === fps.value
|
|
947
|
+
const hint = fpsHint(f)
|
|
948
|
+
return (
|
|
949
|
+
<button
|
|
950
|
+
key={f}
|
|
951
|
+
role="radio"
|
|
952
|
+
aria-checked={active}
|
|
953
|
+
onClick={() => fps.set(f)}
|
|
954
|
+
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
955
|
+
active
|
|
956
|
+
? 'border-violet-400/60 bg-violet-400/10'
|
|
957
|
+
: 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
|
|
958
|
+
}`}
|
|
959
|
+
>
|
|
960
|
+
<span className="text-xs font-semibold text-[var(--editor-text)]">{f} fps</span>
|
|
961
|
+
<span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{hint}</span>
|
|
962
|
+
</button>
|
|
963
|
+
)
|
|
964
|
+
})}
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
)
|
|
968
|
+
})()}
|
|
969
|
+
|
|
970
|
+
{/* HDR-only controls: export format, image color, tone-curve compare. */}
|
|
971
|
+
{isHdr && (
|
|
972
|
+
<>
|
|
973
|
+
{/* Format */}
|
|
974
|
+
<div className="flex flex-col gap-2">
|
|
975
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Format</p>
|
|
976
|
+
<div role="radiogroup" aria-label="Format" className="grid grid-cols-3 gap-2">
|
|
977
|
+
{EXPORT_CHOICES.map(choice => {
|
|
978
|
+
const active = choice.id === exportChoice
|
|
979
|
+
return (
|
|
980
|
+
<button
|
|
981
|
+
key={choice.id}
|
|
982
|
+
role="radio"
|
|
983
|
+
aria-checked={active}
|
|
984
|
+
onClick={() => setExport(choice.id)}
|
|
985
|
+
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
986
|
+
active
|
|
987
|
+
? 'border-violet-400/60 bg-violet-400/10'
|
|
988
|
+
: 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
|
|
989
|
+
}`}
|
|
990
|
+
>
|
|
991
|
+
<span className="text-xs font-semibold text-[var(--editor-text)]">{choice.label}</span>
|
|
992
|
+
<span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{choice.blurb}</span>
|
|
993
|
+
</button>
|
|
994
|
+
)
|
|
995
|
+
})}
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
|
|
999
|
+
{/* Image color — a disclosure matching the Advanced one below: a
|
|
1000
|
+
plain chevron toggle opening a 2-col grid of tones, each with a
|
|
1001
|
+
per-tone example image. Persists through the host's normal
|
|
1002
|
+
settings path (an editor mutation that saves + undoes). */}
|
|
1003
|
+
{(() => {
|
|
1004
|
+
const it = preRenderOptions?.imageTone
|
|
1005
|
+
if (!it) return null
|
|
1006
|
+
const tone = it.value ?? DEFAULT_IMAGE_TONE
|
|
1007
|
+
const toneInfo = IMAGE_TONES.find(t => t.id === tone) ?? IMAGE_TONES[0]
|
|
1008
|
+
return (
|
|
1009
|
+
<div className="flex flex-col gap-3 border-t border-[var(--editor-border)] pt-3">
|
|
1010
|
+
<button
|
|
1011
|
+
onClick={() => setImageColor(o => !o)}
|
|
1012
|
+
aria-expanded={imageColorOpen}
|
|
1013
|
+
className="flex items-center gap-1.5 self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
|
|
1014
|
+
>
|
|
1015
|
+
{imageColorOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
1016
|
+
Image color
|
|
1017
|
+
</button>
|
|
1018
|
+
|
|
1019
|
+
{imageColorOpen ? (
|
|
1020
|
+
<div className="flex flex-col gap-2">
|
|
1021
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
|
|
1022
|
+
How photos and logos convert for HDR
|
|
1023
|
+
</p>
|
|
1024
|
+
<div role="radiogroup" aria-label="Image color" className="grid grid-cols-2 gap-2">
|
|
1025
|
+
{IMAGE_TONES.map(t => {
|
|
1026
|
+
const active = t.id === tone
|
|
1027
|
+
return (
|
|
1028
|
+
<button
|
|
1029
|
+
key={t.id}
|
|
1030
|
+
role="radio"
|
|
1031
|
+
aria-checked={active}
|
|
1032
|
+
onClick={() => it.set(t.id)}
|
|
1033
|
+
className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
|
|
1034
|
+
active
|
|
1035
|
+
? 'border-violet-400/60 bg-violet-400/10'
|
|
1036
|
+
: 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
|
|
1037
|
+
}`}
|
|
1038
|
+
>
|
|
1039
|
+
<img
|
|
1040
|
+
src={TONE_EXAMPLES[t.id]}
|
|
1041
|
+
alt={`${t.label} example`}
|
|
1042
|
+
className="w-full aspect-video rounded object-cover border border-[var(--editor-border)]"
|
|
1043
|
+
/>
|
|
1044
|
+
<span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
|
|
1045
|
+
{t.label}
|
|
1046
|
+
{t.id === DEFAULT_IMAGE_TONE && (
|
|
1047
|
+
<span className="text-[9px] font-normal px-1 py-px rounded bg-[var(--editor-text)]/10 text-[var(--editor-text)]/55">default</span>
|
|
1048
|
+
)}
|
|
1049
|
+
</span>
|
|
1050
|
+
<span className="text-[10px] leading-snug text-[var(--editor-text)]/60">{t.summary}</span>
|
|
1051
|
+
</button>
|
|
1052
|
+
)
|
|
1053
|
+
})}
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
) : (
|
|
1057
|
+
<p className="text-[11px] text-[var(--editor-text)]/45">
|
|
1058
|
+
Photos and logos use the {toneInfo.label} conversion. Open to change.
|
|
1059
|
+
</p>
|
|
1060
|
+
)}
|
|
1061
|
+
</div>
|
|
1062
|
+
)
|
|
1063
|
+
})()}
|
|
1064
|
+
|
|
1065
|
+
{/* Advanced disclosure — SDR tone-curve compare. */}
|
|
1066
|
+
<div className="flex flex-col gap-3 border-t border-[var(--editor-border)] pt-3">
|
|
1067
|
+
<button
|
|
1068
|
+
onClick={() => setAdvanced(o => !o)}
|
|
1069
|
+
aria-expanded={advancedOpen}
|
|
1070
|
+
className="flex items-center gap-1.5 self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
|
|
1071
|
+
>
|
|
1072
|
+
{advancedOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
1073
|
+
Advanced
|
|
1074
|
+
</button>
|
|
1075
|
+
|
|
1076
|
+
{advancedOpen ? (
|
|
1077
|
+
<div className="flex flex-col gap-2">
|
|
1078
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
|
|
1079
|
+
Tone curve for SDR
|
|
1080
|
+
</p>
|
|
1081
|
+
<div role="radiogroup" aria-label="Tone curve for SDR" className="grid grid-cols-2 gap-2">
|
|
1082
|
+
{SDR_CURVES.map(curve => {
|
|
1083
|
+
const active = curve.id === sdrCurve
|
|
1084
|
+
const thumb = thumbs[curve.id]
|
|
1085
|
+
return (
|
|
1086
|
+
<button
|
|
1087
|
+
key={curve.id}
|
|
1088
|
+
role="radio"
|
|
1089
|
+
aria-checked={active}
|
|
1090
|
+
onClick={() => setSdrCurve(curve.id)}
|
|
1091
|
+
className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
|
|
1092
|
+
active
|
|
1093
|
+
? 'border-violet-400/60 bg-violet-400/10'
|
|
1094
|
+
: 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
|
|
1095
|
+
}`}
|
|
1096
|
+
>
|
|
1097
|
+
{thumb ? (
|
|
1098
|
+
// Shown at the SOURCE aspect ratio (not a fixed
|
|
1099
|
+
// 16:9) so a vertical project reads as vertical.
|
|
1100
|
+
// The hover "expand" control opens it full-size
|
|
1101
|
+
// in the lightbox; it stops propagation so a
|
|
1102
|
+
// click on it inspects rather than selects the
|
|
1103
|
+
// curve.
|
|
1104
|
+
<span className="relative block w-full">
|
|
1105
|
+
<img
|
|
1106
|
+
src={thumb}
|
|
1107
|
+
alt={`${curve.label} sample frame`}
|
|
1108
|
+
style={{ aspectRatio: coverAspect }}
|
|
1109
|
+
className="w-full rounded object-cover border border-[var(--editor-border)]"
|
|
1110
|
+
/>
|
|
1111
|
+
<span
|
|
1112
|
+
role="button"
|
|
1113
|
+
tabIndex={0}
|
|
1114
|
+
aria-label={`Expand ${curve.label} preview`}
|
|
1115
|
+
onClick={(e) => { e.stopPropagation(); setExpandedCurveId(curve.id) }}
|
|
1116
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setExpandedCurveId(curve.id) } }}
|
|
1117
|
+
className="absolute top-1 right-1 flex items-center justify-center rounded bg-black/55 p-1 text-white/85 hover:text-white cursor-pointer transition-colors"
|
|
1118
|
+
>
|
|
1119
|
+
<Maximize2 size={12} />
|
|
1120
|
+
</span>
|
|
1121
|
+
</span>
|
|
1122
|
+
) : thumbsPending ? (
|
|
1123
|
+
<span style={{ aspectRatio: coverAspect }} className="w-full rounded border border-[var(--editor-border)] bg-[var(--editor-text)]/5 flex items-center justify-center">
|
|
1124
|
+
<Loader size="sm" />
|
|
1125
|
+
</span>
|
|
1126
|
+
) : null}
|
|
1127
|
+
<span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
|
|
1128
|
+
{curve.label}
|
|
1129
|
+
{curve.id === DEFAULT_SDR_CURVE && (
|
|
1130
|
+
<span className="text-[9px] font-normal px-1 py-px rounded bg-[var(--editor-text)]/10 text-[var(--editor-text)]/55">default</span>
|
|
1131
|
+
)}
|
|
1132
|
+
</span>
|
|
1133
|
+
<span className="text-[10px] leading-snug text-[var(--editor-text)]/60">{curve.blurb}</span>
|
|
1134
|
+
</button>
|
|
1135
|
+
)
|
|
1136
|
+
})}
|
|
1137
|
+
</div>
|
|
1138
|
+
<p
|
|
1139
|
+
data-testid="sdr-honesty-line"
|
|
1140
|
+
className={`text-[11px] leading-snug ${
|
|
1141
|
+
sdrCurve === DEFAULT_SDR_CURVE
|
|
1142
|
+
? 'text-[var(--editor-text)]/55'
|
|
1143
|
+
: (mode === 'light' ? 'text-amber-700' : 'text-amber-400/90')
|
|
1144
|
+
}`}
|
|
1145
|
+
>
|
|
1146
|
+
{honestyLine(sdrCurve)}
|
|
1147
|
+
</p>
|
|
1148
|
+
</div>
|
|
1149
|
+
) : (
|
|
1150
|
+
<p className="text-[11px] text-[var(--editor-text)]/45">
|
|
1151
|
+
SDR files use the {curveInfo.label} look. Open Advanced to compare curves.
|
|
1152
|
+
</p>
|
|
1153
|
+
)}
|
|
1154
|
+
</div>
|
|
1155
|
+
</>
|
|
1156
|
+
)}
|
|
1157
|
+
</div>
|
|
1158
|
+
</div>
|
|
1159
|
+
|
|
1160
|
+
{/* Footer — duration on the left, actions on the right. */}
|
|
1161
|
+
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
1162
|
+
<span className="text-xs text-[var(--editor-text)]/50 tabular-nums">
|
|
1163
|
+
{durationSec > 0 ? formatTimecode(durationSec) : ''}
|
|
1164
|
+
</span>
|
|
1165
|
+
<div className="flex items-center gap-2">
|
|
1166
|
+
<button
|
|
1167
|
+
onClick={handleCancel}
|
|
1168
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
|
|
1169
|
+
>
|
|
1170
|
+
Cancel
|
|
1171
|
+
</button>
|
|
1172
|
+
<button
|
|
1173
|
+
onClick={handleStart}
|
|
1174
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] font-medium hover:opacity-90 transition-colors"
|
|
1175
|
+
>
|
|
1176
|
+
Export
|
|
1177
|
+
</button>
|
|
1178
|
+
</div>
|
|
1179
|
+
</div>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>,
|
|
1182
|
+
document.body,
|
|
1183
|
+
)}
|
|
1184
|
+
{expandedCurve && expandedUrl && createPortal(
|
|
1185
|
+
<div
|
|
1186
|
+
className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6"
|
|
1187
|
+
onClick={() => setExpandedCurveId(null)}
|
|
1188
|
+
role="dialog"
|
|
1189
|
+
aria-label={`${expandedCurve.label} preview`}
|
|
1190
|
+
>
|
|
1191
|
+
<div className="relative flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
|
1192
|
+
{canNavCurves && (
|
|
1193
|
+
<button
|
|
1194
|
+
onClick={() => goCurve(-1)}
|
|
1195
|
+
aria-label="Previous curve"
|
|
1196
|
+
className="absolute -left-3 sm:-left-14 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-2 text-white/80 hover:text-white transition-colors"
|
|
1197
|
+
>
|
|
1198
|
+
<ChevronLeft size={22} />
|
|
1199
|
+
</button>
|
|
1200
|
+
)}
|
|
1201
|
+
{/* Clicking the image itself pages to the next curve (wraps) — the
|
|
1202
|
+
quickest A/B compare; the backdrop or Esc closes. */}
|
|
1203
|
+
<img
|
|
1204
|
+
src={expandedUrl}
|
|
1205
|
+
alt={`${expandedCurve.label} sample frame`}
|
|
1206
|
+
onClick={() => { if (canNavCurves) goCurve(1) }}
|
|
1207
|
+
className={`max-h-[82vh] max-w-[88vw] rounded-lg object-contain shadow-2xl ${canNavCurves ? 'cursor-pointer' : ''}`}
|
|
1208
|
+
/>
|
|
1209
|
+
{canNavCurves && (
|
|
1210
|
+
<button
|
|
1211
|
+
onClick={() => goCurve(1)}
|
|
1212
|
+
aria-label="Next curve"
|
|
1213
|
+
className="absolute -right-3 sm:-right-14 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-2 text-white/80 hover:text-white transition-colors"
|
|
1214
|
+
>
|
|
1215
|
+
<ChevronRight size={22} />
|
|
1216
|
+
</button>
|
|
1217
|
+
)}
|
|
1218
|
+
</div>
|
|
1219
|
+
<div className="flex items-center gap-2 text-xs" onClick={(e) => e.stopPropagation()}>
|
|
1220
|
+
<span className="font-semibold text-white/90">{expandedCurve.label}</span>
|
|
1221
|
+
{expandedCurve.id === DEFAULT_SDR_CURVE && (
|
|
1222
|
+
<span className="text-[10px] px-1 py-px rounded bg-white/15 text-white/70">default</span>
|
|
1223
|
+
)}
|
|
1224
|
+
</div>
|
|
1225
|
+
<p className="text-[11px] text-white/40">
|
|
1226
|
+
{canNavCurves ? 'Click the image or press ← → to compare · Esc or click outside to close' : 'Esc or click outside to close'}
|
|
1227
|
+
</p>
|
|
1228
|
+
</div>,
|
|
1229
|
+
document.body,
|
|
1230
|
+
)}
|
|
1231
|
+
</>)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
320
1234
|
if (status === 'done') {
|
|
321
1235
|
// Prefer the promoted R2 media (poll path); fall back to a workspace path
|
|
322
1236
|
// resolved via fileUrl (SSE path); else show a graceful no-player view.
|
|
@@ -369,9 +1283,20 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
369
1283
|
download={downloadName}
|
|
370
1284
|
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-green-800/60 border border-green-700 text-green-200 hover:bg-green-700/60 transition-colors font-medium"
|
|
371
1285
|
>
|
|
372
|
-
Download
|
|
1286
|
+
{extraOutputs.length > 0 ? 'Download HDR master' : 'Download'}
|
|
373
1287
|
</a>
|
|
374
1288
|
)}
|
|
1289
|
+
{/* A `both` export's extra files (the derived SDR sibling). */}
|
|
1290
|
+
{extraOutputs.map((p) => (
|
|
1291
|
+
<a
|
|
1292
|
+
key={p}
|
|
1293
|
+
href={adapter.fileUrl(p)}
|
|
1294
|
+
download={basename(p)}
|
|
1295
|
+
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-green-800 text-green-200/90 hover:bg-green-900/40 transition-colors font-medium"
|
|
1296
|
+
>
|
|
1297
|
+
Download SDR version
|
|
1298
|
+
</a>
|
|
1299
|
+
))}
|
|
375
1300
|
<button
|
|
376
1301
|
onClick={onClose}
|
|
377
1302
|
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
|
|
@@ -387,61 +1312,109 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
387
1312
|
}
|
|
388
1313
|
|
|
389
1314
|
return createPortal(
|
|
390
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
391
|
-
<div className=
|
|
1315
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
|
|
1316
|
+
<div className="w-full max-w-md bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
|
392
1317
|
|
|
393
1318
|
{/* Header */}
|
|
394
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
1319
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
|
395
1320
|
<div className="flex items-center gap-2.5">
|
|
396
|
-
{status === 'running' && <span className="w-2 h-2 rounded-full bg-
|
|
1321
|
+
{status === 'running' && <span className="w-2 h-2 rounded-full bg-[var(--editor-accent)] animate-pulse" />}
|
|
397
1322
|
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
398
|
-
<
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
</h2>
|
|
402
|
-
</div>
|
|
1323
|
+
<h2 className="text-sm font-semibold text-[var(--editor-text)]">
|
|
1324
|
+
{status === 'running' ? 'Rendering…' : 'Render failed'}
|
|
1325
|
+
</h2>
|
|
403
1326
|
</div>
|
|
404
1327
|
{status !== 'running' && (
|
|
405
1328
|
<button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
406
1329
|
)}
|
|
407
1330
|
</div>
|
|
408
1331
|
|
|
409
|
-
{
|
|
410
|
-
|
|
411
|
-
<div className="
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
Copy
|
|
418
|
-
</button>
|
|
419
|
-
<div
|
|
420
|
-
ref={logRef}
|
|
421
|
-
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
422
|
-
>
|
|
423
|
-
{logs.length === 0 && status === 'running' && (
|
|
424
|
-
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
425
|
-
)}
|
|
426
|
-
{logs.map((line, i) => (
|
|
427
|
-
<LogLine key={i} text={line} />
|
|
428
|
-
))}
|
|
429
|
-
{status === 'error' && errorMsg && (
|
|
430
|
-
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
431
|
-
)}
|
|
1332
|
+
{status === 'running' ? (
|
|
1333
|
+
/* Stylized loading state: equalizer + phase + progress sweep. */
|
|
1334
|
+
<div className="flex flex-col items-center gap-6 px-6 py-9">
|
|
1335
|
+
<Loader size="lg" />
|
|
1336
|
+
|
|
1337
|
+
<div className="flex flex-col items-center gap-1 text-center">
|
|
1338
|
+
<h3 className="text-base font-semibold text-[var(--editor-text)]">Exporting your video</h3>
|
|
1339
|
+
<p className="text-xs text-[var(--editor-text)]/50">This can take a few minutes for longer videos.</p>
|
|
432
1340
|
</div>
|
|
1341
|
+
|
|
1342
|
+
{/* Progress bar — always visible while rendering, above the stepper
|
|
1343
|
+
(poll hosts) or the render-log disclosure (SSE hosts). Poll hosts
|
|
1344
|
+
have a real `phase`; the SSE log transport pins phase to
|
|
1345
|
+
'rendering', so there we infer progress from the log stream. */}
|
|
1346
|
+
<ProgressBar value={
|
|
1347
|
+
view === 'logs'
|
|
1348
|
+
? parseLogProgress(logs)
|
|
1349
|
+
: (() => {
|
|
1350
|
+
const ph = stepperPhases(renderOpts)
|
|
1351
|
+
const i = ph.indexOf(phase)
|
|
1352
|
+
return i >= 0 ? i / ph.length : (phase === 'done' ? 1 : null)
|
|
1353
|
+
})()
|
|
1354
|
+
} />
|
|
1355
|
+
|
|
1356
|
+
{/* Granular phase progress (poll hosts advance through phases). */}
|
|
1357
|
+
{view !== 'logs' && (
|
|
1358
|
+
<div className="w-full max-w-xs rounded-xl border border-white/10 bg-[#0e131f] px-4 py-3">
|
|
1359
|
+
<PhaseStepper current={phase} phases={stepperPhases(renderOpts)} />
|
|
1360
|
+
</div>
|
|
1361
|
+
)}
|
|
1362
|
+
|
|
1363
|
+
{/* Raw render log, collapsed by default (montaj-native / SSE). */}
|
|
1364
|
+
{view === 'logs' && (
|
|
1365
|
+
<div className="w-full">
|
|
1366
|
+
<button
|
|
1367
|
+
onClick={() => setShowLog(o => !o)}
|
|
1368
|
+
aria-expanded={showLog}
|
|
1369
|
+
className="mx-auto flex items-center gap-1 text-[11px] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 transition-colors"
|
|
1370
|
+
>
|
|
1371
|
+
{showLog ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
1372
|
+
{showLog ? 'Hide render log' : 'Show render log'}
|
|
1373
|
+
</button>
|
|
1374
|
+
{showLog && (
|
|
1375
|
+
<div className="relative mt-2">
|
|
1376
|
+
<button
|
|
1377
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n'))}
|
|
1378
|
+
className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors"
|
|
1379
|
+
title="Copy logs"
|
|
1380
|
+
>
|
|
1381
|
+
Copy
|
|
1382
|
+
</button>
|
|
1383
|
+
<div
|
|
1384
|
+
ref={logRef}
|
|
1385
|
+
className="flex h-52 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[var(--editor-text)]/80"
|
|
1386
|
+
>
|
|
1387
|
+
{logs.length === 0
|
|
1388
|
+
? <span className="italic text-[var(--editor-text)]/40">Starting render engine…</span>
|
|
1389
|
+
: logs.map((line, i) => <LogLine key={i} text={line} />)}
|
|
1390
|
+
</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
)}
|
|
1393
|
+
</div>
|
|
1394
|
+
)}
|
|
433
1395
|
</div>
|
|
434
1396
|
) : (
|
|
1397
|
+
/* Error state. */
|
|
435
1398
|
<div className="px-5 py-5">
|
|
436
|
-
{
|
|
437
|
-
|
|
438
|
-
<
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1399
|
+
{view === 'logs' ? (
|
|
1400
|
+
<div className="relative">
|
|
1401
|
+
<button
|
|
1402
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
1403
|
+
className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors"
|
|
1404
|
+
title="Copy logs"
|
|
1405
|
+
>
|
|
1406
|
+
Copy
|
|
1407
|
+
</button>
|
|
1408
|
+
<div
|
|
1409
|
+
ref={logRef}
|
|
1410
|
+
className="flex h-72 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[var(--editor-text)]/80"
|
|
1411
|
+
>
|
|
1412
|
+
{logs.map((line, i) => <LogLine key={i} text={line} />)}
|
|
1413
|
+
{errorMsg && <span className="mt-1 text-red-400">{errorMsg}</span>}
|
|
1414
|
+
</div>
|
|
1415
|
+
</div>
|
|
443
1416
|
) : (
|
|
444
|
-
<p className=
|
|
1417
|
+
<p className={`text-sm whitespace-pre-wrap break-words ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
|
|
445
1418
|
{errorMsg ?? 'Render failed.'}
|
|
446
1419
|
</p>
|
|
447
1420
|
)}
|
|
@@ -449,18 +1422,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
449
1422
|
)}
|
|
450
1423
|
|
|
451
1424
|
{/* Footer */}
|
|
452
|
-
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-
|
|
1425
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-white/10">
|
|
453
1426
|
{status === 'running' ? (
|
|
454
1427
|
<button
|
|
455
1428
|
onClick={handleCancel}
|
|
456
|
-
className=
|
|
1429
|
+
className={`text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[var(--editor-text)]/80 transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
|
|
457
1430
|
>
|
|
458
1431
|
Cancel
|
|
459
1432
|
</button>
|
|
460
1433
|
) : (
|
|
461
1434
|
<button
|
|
462
1435
|
onClick={onClose}
|
|
463
|
-
className="text-sm px-4 py-1.5 rounded-md bg-[
|
|
1436
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[var(--editor-text)] hover:opacity-90 transition-colors"
|
|
464
1437
|
>
|
|
465
1438
|
Close
|
|
466
1439
|
</button>
|