@bycrux/editor 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ControlsInfoModal.tsx +1 -1
- package/src/__tests__/portal-roots-set-editor-text.test.ts +55 -0
- package/src/carousel/CarouselRenderModal.tsx +7 -7
- package/src/carousel/OverlayPicker.tsx +1 -1
- package/src/crop/VideoSourceCropModal.tsx +2 -2
- package/src/types.ts +2 -0
- package/src/video/AudioPolishModal.tsx +4 -4
- package/src/video/CaptionRegenModal.tsx +2 -2
- package/src/video/CommandPalette.tsx +2 -2
- package/src/video/ImageToneMenu.tsx +7 -8
- package/src/video/RenderModal.tsx +58 -60
- package/src/video/VersionCompare.tsx +3 -3
- package/src/video/VideoEditor.tsx +5 -0
- package/src/video/__tests__/RenderModal.hostTheme.test.tsx +85 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +6 -0
- package/src/video/__tests__/export-limits.test.ts +27 -2
- package/src/video/__tests__/use-report-context.test.tsx +22 -1
- package/src/video/export-limits.ts +22 -1
- package/src/video/preview/PreviewPlayer.tsx +14 -0
- package/src/video/preview/SocialPreviewMenu.tsx +1 -1
- package/src/video/preview/__tests__/PreviewPlayer.playing.test.tsx +62 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +1 -1
- package/src/video/use-report-context.ts +10 -7
package/package.json
CHANGED
|
@@ -110,7 +110,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
|
|
|
110
110
|
role="dialog"
|
|
111
111
|
aria-modal="true"
|
|
112
112
|
aria-label={title}
|
|
113
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
113
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-[var(--editor-text)]"
|
|
114
114
|
onClick={onClose}
|
|
115
115
|
>
|
|
116
116
|
<div
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
// See ./node-shims.d.ts for why the `node:*` imports typecheck here.
|
|
6
|
+
|
|
7
|
+
// Anything this package portals to `document.body` lands OUTSIDE the editor
|
|
8
|
+
// root, so it no longer inherits the editor's `text-[var(--editor-text)]`.
|
|
9
|
+
// It inherits the HOST page's text colour instead: in the Montaj desktop app
|
|
10
|
+
// that is `text-gray-900` (#111827), which is exactly the dark theme's
|
|
11
|
+
// `--editor-surface`. Any child without its own text class (or whose class the
|
|
12
|
+
// host's Tailwind failed to emit) then renders dark on dark, which is how the
|
|
13
|
+
// Export dialog's Cancel label and tier dims went invisible.
|
|
14
|
+
//
|
|
15
|
+
// Guard: in every file that calls `createPortal`, every `fixed` overlay root
|
|
16
|
+
// must set its own text colour, so portalled content inherits the editor's
|
|
17
|
+
// text, never the host's.
|
|
18
|
+
|
|
19
|
+
const SRC_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
20
|
+
const TEXT_COLOUR = /(^|\s)text-(\[var\(--editor-text\)\]|white)(\s|$)/
|
|
21
|
+
|
|
22
|
+
function collectSourceFiles(dir: string, out: string[] = []): string[] {
|
|
23
|
+
for (const entry of readdirSync(dir)) {
|
|
24
|
+
if (entry === 'node_modules' || entry === '__tests__') continue
|
|
25
|
+
const full = join(dir, entry)
|
|
26
|
+
if (statSync(full).isDirectory()) collectSourceFiles(full, out)
|
|
27
|
+
else if (/\.tsx$/.test(entry) && !/\.test\.tsx$/.test(entry)) out.push(full)
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('portal roots set the editor text colour', () => {
|
|
33
|
+
it('every `fixed` root in a portalling component carries a text colour', () => {
|
|
34
|
+
const offenders: string[] = []
|
|
35
|
+
let checked = 0
|
|
36
|
+
for (const file of collectSourceFiles(SRC_ROOT)) {
|
|
37
|
+
const src = readFileSync(file, 'utf8')
|
|
38
|
+
if (!src.includes('createPortal(')) continue
|
|
39
|
+
// Static className strings and the static head of template literals.
|
|
40
|
+
const re = /className=(?:"([^"]*)"|\{`([^`]*)`\})/g
|
|
41
|
+
let m: RegExpExecArray | null
|
|
42
|
+
while ((m = re.exec(src)) !== null) {
|
|
43
|
+
const cls = (m[1] ?? m[2]).replace(/\$\{[^}]*\}/g, ' ')
|
|
44
|
+
if (!/(^|\s)fixed(\s|$)/.test(cls)) continue
|
|
45
|
+
checked += 1
|
|
46
|
+
if (!TEXT_COLOUR.test(cls)) {
|
|
47
|
+
const line = src.slice(0, m.index).split('\n').length
|
|
48
|
+
offenders.push(`${relative(SRC_ROOT, file)}:${line}: ${cls.trim()}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
expect(checked).toBeGreaterThan(5)
|
|
53
|
+
expect(offenders).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -70,7 +70,7 @@ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark
|
|
|
70
70
|
else if (/error|fail/i.test(t)) color = mode === 'light' ? 'text-red-600' : 'text-red-400'
|
|
71
71
|
|
|
72
72
|
const prefix = text.startsWith('[render]')
|
|
73
|
-
? <span className="text-[color-mix(in_srgb,var(--editor-text)
|
|
73
|
+
? <span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">[render] </span>
|
|
74
74
|
: null
|
|
75
75
|
|
|
76
76
|
return (
|
|
@@ -240,7 +240,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
240
240
|
// Portal to document.body (see RenderModal): a transformed host ancestor
|
|
241
241
|
// would otherwise trap this `fixed` overlay and center the panel off-screen.
|
|
242
242
|
return createPortal(
|
|
243
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
243
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
|
|
244
244
|
<div className="w-[96vw] h-[96vh] bg-[var(--editor-bg)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
|
|
245
245
|
|
|
246
246
|
{/* Left — slide gallery */}
|
|
@@ -264,15 +264,15 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
264
264
|
className="block w-full h-auto"
|
|
265
265
|
style={{ aspectRatio: `${resolution[0]} / ${resolution[1]}` }}
|
|
266
266
|
/>
|
|
267
|
-
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-
|
|
267
|
+
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-white/90 font-mono flex justify-between">
|
|
268
268
|
<span>#{String(i + 1).padStart(2, '0')}</span>
|
|
269
|
-
<span className="text-
|
|
269
|
+
<span className="text-white/60">{slide.filename}</span>
|
|
270
270
|
</div>
|
|
271
271
|
</a>
|
|
272
272
|
))}
|
|
273
273
|
</div>
|
|
274
274
|
) : (
|
|
275
|
-
<p className="text-sm text-
|
|
275
|
+
<p className="text-sm text-white/60">Render complete.</p>
|
|
276
276
|
)}
|
|
277
277
|
</div>
|
|
278
278
|
|
|
@@ -311,7 +311,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
311
311
|
// Poll transport carries no per-line logs, so show a spinner + status text;
|
|
312
312
|
// the SSE transport (montaj-native) streams logs into the log panel.
|
|
313
313
|
return createPortal(
|
|
314
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
314
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
|
|
315
315
|
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
316
316
|
|
|
317
317
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
@@ -357,7 +357,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
357
357
|
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)] bg-[var(--editor-bg)] flex flex-col gap-0.5"
|
|
358
358
|
>
|
|
359
359
|
{logs.length === 0 && status === 'running' && (
|
|
360
|
-
<span className="text-[color-mix(in_srgb,var(--editor-text)
|
|
360
|
+
<span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">Starting render engine…</span>
|
|
361
361
|
)}
|
|
362
362
|
{logs.map((line, i) => (
|
|
363
363
|
<LogLine key={i} text={line} mode={mode} />
|
|
@@ -100,7 +100,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
|
|
|
100
100
|
|
|
101
101
|
return (
|
|
102
102
|
<div
|
|
103
|
-
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
103
|
+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 text-[var(--editor-text)]"
|
|
104
104
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
|
105
105
|
>
|
|
106
106
|
<div className="bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
|
@@ -156,7 +156,7 @@ export function VideoSourceCropModal({
|
|
|
156
156
|
|
|
157
157
|
return (
|
|
158
158
|
<div
|
|
159
|
-
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6"
|
|
159
|
+
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6 text-[var(--editor-text)]"
|
|
160
160
|
onPointerDown={onClose}
|
|
161
161
|
>
|
|
162
162
|
<div
|
|
@@ -165,7 +165,7 @@ export function VideoSourceCropModal({
|
|
|
165
165
|
>
|
|
166
166
|
<div className="flex items-center justify-between">
|
|
167
167
|
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Crop source</h2>
|
|
168
|
-
<span className="text-xs text-[color-mix(in_srgb,var(--editor-text)
|
|
168
|
+
<span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Pick the part of the footage to keep. Position and zoom live on the canvas</span>
|
|
169
169
|
</div>
|
|
170
170
|
|
|
171
171
|
{/* Source frame + crop window */}
|
package/src/types.ts
CHANGED
|
@@ -561,6 +561,8 @@ export interface EditorContext {
|
|
|
561
561
|
selectedIds: string[]
|
|
562
562
|
/** Selected caption segment id, if any. */
|
|
563
563
|
selectedCaptionId: string | null
|
|
564
|
+
/** Whether the preview is playing. Absent from hosts that do not know. */
|
|
565
|
+
playing?: boolean
|
|
564
566
|
}
|
|
565
567
|
|
|
566
568
|
/**
|
|
@@ -681,7 +681,7 @@ export default function AudioPolishModal<P extends Project = Project>({
|
|
|
681
681
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
682
682
|
|
|
683
683
|
const panel = (
|
|
684
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
|
684
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 text-[var(--editor-text)]">
|
|
685
685
|
<div
|
|
686
686
|
role="dialog"
|
|
687
687
|
aria-label="Polish audio"
|
|
@@ -747,7 +747,7 @@ export default function AudioPolishModal<P extends Project = Project>({
|
|
|
747
747
|
<option key={l.id} value={l.id}>{l.label}</option>
|
|
748
748
|
))}
|
|
749
749
|
</select>
|
|
750
|
-
<span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)
|
|
750
|
+
<span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] max-w-[15rem] leading-snug">
|
|
751
751
|
Set this before analysing. The wrong language makes real speech look like silence.
|
|
752
752
|
</span>
|
|
753
753
|
</label>
|
|
@@ -841,7 +841,7 @@ export default function AudioPolishModal<P extends Project = Project>({
|
|
|
841
841
|
<section key={clip.id} className="flex flex-col gap-2">
|
|
842
842
|
<header className="flex items-baseline justify-between gap-3 border-b border-[var(--editor-border)] pb-1">
|
|
843
843
|
<span className="text-xs font-semibold text-[var(--editor-text)] truncate">{clipLabel(clip)}</span>
|
|
844
|
-
<span className="text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)
|
|
844
|
+
<span className="text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] shrink-0">
|
|
845
845
|
{`${clock(clip.start)} to ${clock(clip.end)}`}
|
|
846
846
|
</span>
|
|
847
847
|
</header>
|
|
@@ -914,7 +914,7 @@ export default function AudioPolishModal<P extends Project = Project>({
|
|
|
914
914
|
|
|
915
915
|
{/* Removals */}
|
|
916
916
|
{rows.length === 0 ? (
|
|
917
|
-
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
917
|
+
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">No removals proposed for this clip.</p>
|
|
918
918
|
) : (
|
|
919
919
|
<ul className="flex flex-col gap-1">
|
|
920
920
|
{rows.map(row => (
|
|
@@ -144,7 +144,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
144
144
|
// Portal to document.body so a transformed host ancestor can't trap this
|
|
145
145
|
// `fixed` overlay and push the panel off-screen (see RenderModal).
|
|
146
146
|
return createPortal(
|
|
147
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
147
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
|
|
148
148
|
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
149
149
|
|
|
150
150
|
{/* Header */}
|
|
@@ -199,7 +199,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
199
199
|
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
200
200
|
>
|
|
201
201
|
{logs.length === 0 && status === 'running' && (
|
|
202
|
-
<span className="text-[color-mix(in_srgb,var(--editor-text)
|
|
202
|
+
<span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">Starting transcription…</span>
|
|
203
203
|
)}
|
|
204
204
|
{logs.map((line, i) => (
|
|
205
205
|
<LogLine key={i} text={line} mode={mode} />
|
|
@@ -94,7 +94,7 @@ export default function CommandPalette({ commands, initialMode = 'list', onGoToT
|
|
|
94
94
|
|
|
95
95
|
return createPortal(
|
|
96
96
|
<div
|
|
97
|
-
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/70 backdrop-blur-sm"
|
|
97
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/70 backdrop-blur-sm text-[var(--editor-text)]"
|
|
98
98
|
onClick={onClose}
|
|
99
99
|
>
|
|
100
100
|
<div
|
|
@@ -131,7 +131,7 @@ export default function CommandPalette({ commands, initialMode = 'list', onGoToT
|
|
|
131
131
|
</div>
|
|
132
132
|
<div className="max-h-80 overflow-y-auto py-1">
|
|
133
133
|
{filtered.length === 0 && (
|
|
134
|
-
<div className="px-4 py-3 text-xs text-[color-mix(in_srgb,var(--editor-text)
|
|
134
|
+
<div className="px-4 py-3 text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No matching commands</div>
|
|
135
135
|
)}
|
|
136
136
|
{filtered.map((cmd, i) => (
|
|
137
137
|
<button
|
|
@@ -16,9 +16,8 @@ export interface ImageToneMenuProps {
|
|
|
16
16
|
* For host chrome at the top of the page.
|
|
17
17
|
*/
|
|
18
18
|
variant?: 'icon' | 'header'
|
|
19
|
-
/** Editor theme mode — light/dark.
|
|
20
|
-
*
|
|
21
|
-
* dark, matching every existing caller. */
|
|
19
|
+
/** Editor theme mode — light/dark. Kept for API compatibility; the active state now
|
|
20
|
+
* reads the theme's accent tokens, which already adapt to light and dark. */
|
|
22
21
|
mode?: 'light' | 'dark'
|
|
23
22
|
}
|
|
24
23
|
|
|
@@ -29,7 +28,7 @@ export interface ImageToneMenuProps {
|
|
|
29
28
|
* caller gates on settings.colorSpace). Each option shows an example: the same
|
|
30
29
|
* generic photo converted through the actual render pipeline under that mode.
|
|
31
30
|
*/
|
|
32
|
-
export default function ImageToneMenu({ value, onChange, variant = 'icon'
|
|
31
|
+
export default function ImageToneMenu({ value, onChange, variant = 'icon' }: ImageToneMenuProps) {
|
|
33
32
|
const [open, setOpen] = useState(false)
|
|
34
33
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
35
34
|
|
|
@@ -78,7 +77,7 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
|
|
|
78
77
|
aria-haspopup="menu"
|
|
79
78
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
80
79
|
open
|
|
81
|
-
?
|
|
80
|
+
? 'text-[var(--editor-accent-text)] bg-[color-mix(in_srgb,var(--editor-accent)_15%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-accent)_25%,transparent)]'
|
|
82
81
|
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
|
|
83
82
|
}`}
|
|
84
83
|
>
|
|
@@ -89,11 +88,11 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
|
|
|
89
88
|
{open && (
|
|
90
89
|
<div
|
|
91
90
|
role="menu"
|
|
92
|
-
className={`${menuPosition} z-40 w-[300px] rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1`}
|
|
91
|
+
className={`${menuPosition} z-40 w-[300px] rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)] shadow-2xl p-2 flex flex-col gap-1`}
|
|
93
92
|
>
|
|
94
93
|
<p className="px-2 pt-1 pb-1.5 text-[11px] font-semibold text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]">
|
|
95
94
|
Image color mapping
|
|
96
|
-
<span className="block font-normal text-[10px] text-[color-mix(in_srgb,var(--editor-text)
|
|
95
|
+
<span className="block font-normal text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
97
96
|
How photos and logos are converted for the HDR render.
|
|
98
97
|
</span>
|
|
99
98
|
</p>
|
|
@@ -107,7 +106,7 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
|
|
|
107
106
|
onClick={() => { onChange(tone.id); setOpen(false) }}
|
|
108
107
|
className={`flex items-start gap-2.5 rounded-lg p-2 text-left transition-colors border ${
|
|
109
108
|
active
|
|
110
|
-
? 'border-
|
|
109
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
111
110
|
: 'border-transparent hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
112
111
|
}`}
|
|
113
112
|
>
|
|
@@ -115,12 +115,10 @@ interface RenderModalProps<P extends Project = Project> {
|
|
|
115
115
|
preRenderOptions?: PreRenderOptions
|
|
116
116
|
/** Editor theme mode — light/dark. The export dialog and progress/error
|
|
117
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* values are exactly what the old translucent blacks composited to over the
|
|
123
|
-
* dark `--editor-surface`, so dark mode is unchanged. Absent -> dark,
|
|
118
|
+
* in light mode. The render-log terminal boxes stay dark by design: they are
|
|
119
|
+
* painted an OPAQUE near-black (`#0a0e17`) with fixed white text, so they
|
|
120
|
+
* read the same under either theme. The phase stepper and footer buttons
|
|
121
|
+
* follow the theme tokens instead. Absent -> dark,
|
|
124
122
|
* matching every existing caller. */
|
|
125
123
|
mode?: 'light' | 'dark'
|
|
126
124
|
}
|
|
@@ -309,15 +307,15 @@ function PhaseStepper({ current, phases }: { current: RenderPhase; phases: Rende
|
|
|
309
307
|
|
|
310
308
|
function LogLine({ text }: { text: string }) {
|
|
311
309
|
const t = text.replace(/^\[montaj render\]\s*/, '')
|
|
312
|
-
let color = 'text-
|
|
310
|
+
let color = 'text-white/60'
|
|
313
311
|
if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
|
|
314
312
|
else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
|
|
315
313
|
else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
|
|
316
|
-
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-
|
|
314
|
+
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-white/55'
|
|
317
315
|
else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
|
|
318
316
|
|
|
319
317
|
const prefix = text.startsWith('[montaj render]')
|
|
320
|
-
? <span className="text-
|
|
318
|
+
? <span className="text-white/40">[render] </span>
|
|
321
319
|
: null
|
|
322
320
|
|
|
323
321
|
return (
|
|
@@ -792,16 +790,16 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
792
790
|
}
|
|
793
791
|
return (<>
|
|
794
792
|
{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-
|
|
793
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
|
|
794
|
+
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
|
797
795
|
|
|
798
796
|
{/* Header */}
|
|
799
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
797
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
800
798
|
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Export</h2>
|
|
801
799
|
<button
|
|
802
800
|
onClick={handleCancel}
|
|
803
801
|
aria-label="Close"
|
|
804
|
-
className="text-[color-mix(in_srgb,var(--editor-text)
|
|
802
|
+
className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
|
|
805
803
|
>
|
|
806
804
|
×
|
|
807
805
|
</button>
|
|
@@ -813,7 +811,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
813
811
|
{/* Left — cover preview + editor */}
|
|
814
812
|
<div className="md:w-64 shrink-0 flex flex-col gap-2">
|
|
815
813
|
<div
|
|
816
|
-
className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-
|
|
814
|
+
className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-[var(--editor-border)] bg-black/40 flex items-center justify-center"
|
|
817
815
|
style={coverPortrait
|
|
818
816
|
? { aspectRatio: coverAspect, height: 'min(46vh, 22rem)' }
|
|
819
817
|
: { aspectRatio: coverAspect, width: '100%' }}
|
|
@@ -827,7 +825,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
827
825
|
) : coverLoading ? (
|
|
828
826
|
<Loader size="md" label="Loading cover" />
|
|
829
827
|
) : (
|
|
830
|
-
<span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
828
|
+
<span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No preview</span>
|
|
831
829
|
)}
|
|
832
830
|
</div>
|
|
833
831
|
{adapter.getSampleFrame && (
|
|
@@ -857,7 +855,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
857
855
|
className={`relative shrink-0 overflow-hidden rounded border transition-colors ${
|
|
858
856
|
active
|
|
859
857
|
? 'border-[var(--editor-accent)] ring-1 ring-[var(--editor-accent)]'
|
|
860
|
-
: 'border-
|
|
858
|
+
: 'border-[var(--editor-border)] hover:border-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]'
|
|
861
859
|
}`}
|
|
862
860
|
style={{ aspectRatio: coverAspect, height: 72 }}
|
|
863
861
|
>
|
|
@@ -869,7 +867,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
869
867
|
})}
|
|
870
868
|
</div>
|
|
871
869
|
) : (
|
|
872
|
-
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
870
|
+
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No frames to pick from.</p>
|
|
873
871
|
)
|
|
874
872
|
)}
|
|
875
873
|
</>
|
|
@@ -880,7 +878,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
880
878
|
<div className="flex-1 min-w-0 flex flex-col gap-4">
|
|
881
879
|
{/* Name */}
|
|
882
880
|
<label className="flex flex-col gap-1.5">
|
|
883
|
-
<span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
881
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Name</span>
|
|
884
882
|
<input
|
|
885
883
|
type="text"
|
|
886
884
|
value={name}
|
|
@@ -892,9 +890,9 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
892
890
|
|
|
893
891
|
{/* Save to */}
|
|
894
892
|
<div className="flex flex-col gap-1">
|
|
895
|
-
<span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
893
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Save to</span>
|
|
896
894
|
<span className="text-xs font-mono text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] truncate">output/{outputName}.mp4</span>
|
|
897
|
-
<span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
895
|
+
<span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Download after export.</span>
|
|
898
896
|
</div>
|
|
899
897
|
|
|
900
898
|
{/* Resolution — source-capped tier picker. Only rendered when the
|
|
@@ -908,7 +906,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
908
906
|
const currentShort = Math.min(...res.value)
|
|
909
907
|
return (
|
|
910
908
|
<div className="flex flex-col gap-2">
|
|
911
|
-
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
909
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Resolution</p>
|
|
912
910
|
<div role="radiogroup" aria-label="Resolution" className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
913
911
|
{res.available.map(([w, h]) => {
|
|
914
912
|
const short = Math.min(w, h)
|
|
@@ -921,12 +919,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
921
919
|
onClick={() => res.set([w, h])}
|
|
922
920
|
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
923
921
|
active
|
|
924
|
-
? 'border-
|
|
922
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
925
923
|
: 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
926
924
|
}`}
|
|
927
925
|
>
|
|
928
926
|
<span className="text-xs font-semibold text-[var(--editor-text)]">{resLabel([w, h])}</span>
|
|
929
|
-
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)
|
|
927
|
+
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{w} × {h}</span>
|
|
930
928
|
</button>
|
|
931
929
|
)
|
|
932
930
|
})}
|
|
@@ -940,7 +938,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
940
938
|
const fps = preRenderOptions.fps
|
|
941
939
|
return (
|
|
942
940
|
<div className="flex flex-col gap-2">
|
|
943
|
-
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
941
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Frame rate</p>
|
|
944
942
|
<div role="radiogroup" aria-label="Frame rate" className="grid grid-cols-3 gap-2">
|
|
945
943
|
{fps.available.map(f => {
|
|
946
944
|
const active = f === fps.value
|
|
@@ -953,12 +951,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
953
951
|
onClick={() => fps.set(f)}
|
|
954
952
|
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
955
953
|
active
|
|
956
|
-
? 'border-
|
|
954
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
957
955
|
: 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
958
956
|
}`}
|
|
959
957
|
>
|
|
960
958
|
<span className="text-xs font-semibold text-[var(--editor-text)]">{f} fps</span>
|
|
961
|
-
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)
|
|
959
|
+
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{hint}</span>
|
|
962
960
|
</button>
|
|
963
961
|
)
|
|
964
962
|
})}
|
|
@@ -972,7 +970,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
972
970
|
<>
|
|
973
971
|
{/* Format */}
|
|
974
972
|
<div className="flex flex-col gap-2">
|
|
975
|
-
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
973
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Format</p>
|
|
976
974
|
<div role="radiogroup" aria-label="Format" className="grid grid-cols-3 gap-2">
|
|
977
975
|
{EXPORT_CHOICES.map(choice => {
|
|
978
976
|
const active = choice.id === exportChoice
|
|
@@ -984,12 +982,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
984
982
|
onClick={() => setExport(choice.id)}
|
|
985
983
|
className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
|
|
986
984
|
active
|
|
987
|
-
? 'border-
|
|
985
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
988
986
|
: 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
989
987
|
}`}
|
|
990
988
|
>
|
|
991
989
|
<span className="text-xs font-semibold text-[var(--editor-text)]">{choice.label}</span>
|
|
992
|
-
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)
|
|
990
|
+
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{choice.blurb}</span>
|
|
993
991
|
</button>
|
|
994
992
|
)
|
|
995
993
|
})}
|
|
@@ -1018,7 +1016,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1018
1016
|
|
|
1019
1017
|
{imageColorOpen ? (
|
|
1020
1018
|
<div className="flex flex-col gap-2">
|
|
1021
|
-
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
1019
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
1022
1020
|
How photos and logos convert for HDR
|
|
1023
1021
|
</p>
|
|
1024
1022
|
<div role="radiogroup" aria-label="Image color" className="grid grid-cols-2 gap-2">
|
|
@@ -1032,7 +1030,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1032
1030
|
onClick={() => it.set(t.id)}
|
|
1033
1031
|
className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
|
|
1034
1032
|
active
|
|
1035
|
-
? 'border-
|
|
1033
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
1036
1034
|
: 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
1037
1035
|
}`}
|
|
1038
1036
|
>
|
|
@@ -1044,7 +1042,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1044
1042
|
<span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
|
|
1045
1043
|
{t.label}
|
|
1046
1044
|
{t.id === DEFAULT_IMAGE_TONE && (
|
|
1047
|
-
<span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)
|
|
1045
|
+
<span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">default</span>
|
|
1048
1046
|
)}
|
|
1049
1047
|
</span>
|
|
1050
1048
|
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{t.summary}</span>
|
|
@@ -1054,7 +1052,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1054
1052
|
</div>
|
|
1055
1053
|
</div>
|
|
1056
1054
|
) : (
|
|
1057
|
-
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
1055
|
+
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
1058
1056
|
Photos and logos use the {toneInfo.label} conversion. Open to change.
|
|
1059
1057
|
</p>
|
|
1060
1058
|
)}
|
|
@@ -1075,7 +1073,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1075
1073
|
|
|
1076
1074
|
{advancedOpen ? (
|
|
1077
1075
|
<div className="flex flex-col gap-2">
|
|
1078
|
-
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)
|
|
1076
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
1079
1077
|
Tone curve for SDR
|
|
1080
1078
|
</p>
|
|
1081
1079
|
<div role="radiogroup" aria-label="Tone curve for SDR" className="grid grid-cols-2 gap-2">
|
|
@@ -1090,7 +1088,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1090
1088
|
onClick={() => setSdrCurve(curve.id)}
|
|
1091
1089
|
className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
|
|
1092
1090
|
active
|
|
1093
|
-
? 'border-
|
|
1091
|
+
? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
|
|
1094
1092
|
: 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
1095
1093
|
}`}
|
|
1096
1094
|
>
|
|
@@ -1127,7 +1125,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1127
1125
|
<span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
|
|
1128
1126
|
{curve.label}
|
|
1129
1127
|
{curve.id === DEFAULT_SDR_CURVE && (
|
|
1130
|
-
<span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)
|
|
1128
|
+
<span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">default</span>
|
|
1131
1129
|
)}
|
|
1132
1130
|
</span>
|
|
1133
1131
|
<span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{curve.blurb}</span>
|
|
@@ -1139,7 +1137,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1139
1137
|
data-testid="sdr-honesty-line"
|
|
1140
1138
|
className={`text-[11px] leading-snug ${
|
|
1141
1139
|
sdrCurve === DEFAULT_SDR_CURVE
|
|
1142
|
-
? 'text-[color-mix(in_srgb,var(--editor-text)
|
|
1140
|
+
? 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]'
|
|
1143
1141
|
: (mode === 'light' ? 'text-amber-700' : 'text-amber-400/90')
|
|
1144
1142
|
}`}
|
|
1145
1143
|
>
|
|
@@ -1147,7 +1145,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1147
1145
|
</p>
|
|
1148
1146
|
</div>
|
|
1149
1147
|
) : (
|
|
1150
|
-
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
1148
|
+
<p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
1151
1149
|
SDR files use the {curveInfo.label} look. Open Advanced to compare curves.
|
|
1152
1150
|
</p>
|
|
1153
1151
|
)}
|
|
@@ -1159,7 +1157,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1159
1157
|
|
|
1160
1158
|
{/* Footer — duration on the left, actions on the right. */}
|
|
1161
1159
|
<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-[color-mix(in_srgb,var(--editor-text)
|
|
1160
|
+
<span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] tabular-nums">
|
|
1163
1161
|
{durationSec > 0 ? formatTimecode(durationSec) : ''}
|
|
1164
1162
|
</span>
|
|
1165
1163
|
<div className="flex items-center gap-2">
|
|
@@ -1183,7 +1181,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1183
1181
|
)}
|
|
1184
1182
|
{expandedCurve && expandedUrl && createPortal(
|
|
1185
1183
|
<div
|
|
1186
|
-
className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6"
|
|
1184
|
+
className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6 text-white"
|
|
1187
1185
|
onClick={() => setExpandedCurveId(null)}
|
|
1188
1186
|
role="dialog"
|
|
1189
1187
|
aria-label={`${expandedCurve.label} preview`}
|
|
@@ -1243,7 +1241,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1243
1241
|
// block for this `fixed` overlay, sizing it to the scrolled page height and
|
|
1244
1242
|
// centering the panel off-screen below the fold.
|
|
1245
1243
|
return createPortal(
|
|
1246
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
1244
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
|
|
1247
1245
|
<div className="w-[96vw] h-[96vh] bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
|
|
1248
1246
|
|
|
1249
1247
|
{/* Left — video */}
|
|
@@ -1257,7 +1255,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1257
1255
|
className="h-full w-full object-contain"
|
|
1258
1256
|
/>
|
|
1259
1257
|
) : (
|
|
1260
|
-
<p className="text-sm text-
|
|
1258
|
+
<p className="text-sm text-white/60">Render complete.</p>
|
|
1261
1259
|
)}
|
|
1262
1260
|
</div>
|
|
1263
1261
|
|
|
@@ -1271,7 +1269,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1271
1269
|
<p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Saved to your library.</p>
|
|
1272
1270
|
</div>
|
|
1273
1271
|
</div>
|
|
1274
|
-
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)
|
|
1272
|
+
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
1275
1273
|
</div>
|
|
1276
1274
|
|
|
1277
1275
|
<div className="flex flex-col gap-3 p-5 flex-1">
|
|
@@ -1281,7 +1279,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1281
1279
|
<a
|
|
1282
1280
|
href={videoUrl}
|
|
1283
1281
|
download={downloadName}
|
|
1284
|
-
className=
|
|
1282
|
+
className={`w-full text-center text-sm px-4 py-2.5 rounded-lg border transition-colors font-medium ${mode === 'light' ? 'bg-green-600 border-green-700 text-white hover:bg-green-700' : 'bg-green-800/60 border-green-700 text-green-200 hover:bg-green-700/60'}`}
|
|
1285
1283
|
>
|
|
1286
1284
|
{extraOutputs.length > 0 ? 'Download HDR master' : 'Download'}
|
|
1287
1285
|
</a>
|
|
@@ -1292,7 +1290,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1292
1290
|
key={p}
|
|
1293
1291
|
href={adapter.fileUrl(p)}
|
|
1294
1292
|
download={basename(p)}
|
|
1295
|
-
className=
|
|
1293
|
+
className={`w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border transition-colors font-medium ${mode === 'light' ? 'border-green-600 text-green-700 hover:bg-green-50' : 'border-green-800 text-green-200/90 hover:bg-green-900/40'}`}
|
|
1296
1294
|
>
|
|
1297
1295
|
Download SDR version
|
|
1298
1296
|
</a>
|
|
@@ -1312,11 +1310,11 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1312
1310
|
}
|
|
1313
1311
|
|
|
1314
1312
|
return createPortal(
|
|
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-
|
|
1313
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
|
|
1314
|
+
<div className="w-full max-w-md bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
|
1317
1315
|
|
|
1318
1316
|
{/* Header */}
|
|
1319
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
1317
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
1320
1318
|
<div className="flex items-center gap-2.5">
|
|
1321
1319
|
{status === 'running' && <span className="w-2 h-2 rounded-full bg-[var(--editor-accent)] animate-pulse" />}
|
|
1322
1320
|
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
@@ -1325,7 +1323,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1325
1323
|
</h2>
|
|
1326
1324
|
</div>
|
|
1327
1325
|
{status !== 'running' && (
|
|
1328
|
-
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)
|
|
1326
|
+
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
1329
1327
|
)}
|
|
1330
1328
|
</div>
|
|
1331
1329
|
|
|
@@ -1336,7 +1334,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1336
1334
|
|
|
1337
1335
|
<div className="flex flex-col items-center gap-1 text-center">
|
|
1338
1336
|
<h3 className="text-base font-semibold text-[var(--editor-text)]">Exporting your video</h3>
|
|
1339
|
-
<p className="text-xs text-[color-mix(in_srgb,var(--editor-text)
|
|
1337
|
+
<p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">This can take a few minutes for longer videos.</p>
|
|
1340
1338
|
</div>
|
|
1341
1339
|
|
|
1342
1340
|
{/* Progress bar — always visible while rendering, above the stepper
|
|
@@ -1355,7 +1353,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1355
1353
|
|
|
1356
1354
|
{/* Granular phase progress (poll hosts advance through phases). */}
|
|
1357
1355
|
{view !== 'logs' && (
|
|
1358
|
-
<div className="w-full max-w-xs rounded-xl border border-
|
|
1356
|
+
<div className="w-full max-w-xs rounded-xl border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-text)_4%,var(--editor-surface))] px-4 py-3">
|
|
1359
1357
|
<PhaseStepper current={phase} phases={stepperPhases(renderOpts)} />
|
|
1360
1358
|
</div>
|
|
1361
1359
|
)}
|
|
@@ -1366,7 +1364,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1366
1364
|
<button
|
|
1367
1365
|
onClick={() => setShowLog(o => !o)}
|
|
1368
1366
|
aria-expanded={showLog}
|
|
1369
|
-
className="mx-auto flex items-center gap-1 text-[11px] text-[color-mix(in_srgb,var(--editor-text)
|
|
1367
|
+
className="mx-auto flex items-center gap-1 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors"
|
|
1370
1368
|
>
|
|
1371
1369
|
{showLog ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
1372
1370
|
{showLog ? 'Hide render log' : 'Show render log'}
|
|
@@ -1375,17 +1373,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1375
1373
|
<div className="relative mt-2">
|
|
1376
1374
|
<button
|
|
1377
1375
|
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-
|
|
1376
|
+
className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-white/60 hover:text-white transition-colors"
|
|
1379
1377
|
title="Copy logs"
|
|
1380
1378
|
>
|
|
1381
1379
|
Copy
|
|
1382
1380
|
</button>
|
|
1383
1381
|
<div
|
|
1384
1382
|
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-
|
|
1383
|
+
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-white/80"
|
|
1386
1384
|
>
|
|
1387
1385
|
{logs.length === 0
|
|
1388
|
-
? <span className="italic text-
|
|
1386
|
+
? <span className="italic text-white/40">Starting render engine…</span>
|
|
1389
1387
|
: logs.map((line, i) => <LogLine key={i} text={line} />)}
|
|
1390
1388
|
</div>
|
|
1391
1389
|
</div>
|
|
@@ -1400,14 +1398,14 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1400
1398
|
<div className="relative">
|
|
1401
1399
|
<button
|
|
1402
1400
|
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-
|
|
1401
|
+
className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-white/60 hover:text-white transition-colors"
|
|
1404
1402
|
title="Copy logs"
|
|
1405
1403
|
>
|
|
1406
1404
|
Copy
|
|
1407
1405
|
</button>
|
|
1408
1406
|
<div
|
|
1409
1407
|
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-
|
|
1408
|
+
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-white/80"
|
|
1411
1409
|
>
|
|
1412
1410
|
{logs.map((line, i) => <LogLine key={i} text={line} />)}
|
|
1413
1411
|
{errorMsg && <span className="mt-1 text-red-400">{errorMsg}</span>}
|
|
@@ -1422,18 +1420,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
1422
1420
|
)}
|
|
1423
1421
|
|
|
1424
1422
|
{/* Footer */}
|
|
1425
|
-
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-
|
|
1423
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
1426
1424
|
{status === 'running' ? (
|
|
1427
1425
|
<button
|
|
1428
1426
|
onClick={handleCancel}
|
|
1429
|
-
className={`text-sm px-4 py-1.5 rounded-md bg-[
|
|
1427
|
+
className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] 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'}`}
|
|
1430
1428
|
>
|
|
1431
1429
|
Cancel
|
|
1432
1430
|
</button>
|
|
1433
1431
|
) : (
|
|
1434
1432
|
<button
|
|
1435
1433
|
onClick={onClose}
|
|
1436
|
-
className="text-sm px-4 py-1.5 rounded-md bg-[
|
|
1434
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
|
|
1437
1435
|
>
|
|
1438
1436
|
Close
|
|
1439
1437
|
</button>
|
|
@@ -169,11 +169,11 @@ export default function VersionCompare({
|
|
|
169
169
|
}, [onClose])
|
|
170
170
|
|
|
171
171
|
return createPortal(
|
|
172
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
|
|
173
|
-
<div className="w-full max-w-5xl bg-[var(--editor-surface)] border border-
|
|
172
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
|
|
173
|
+
<div className="w-full max-w-5xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
|
174
174
|
|
|
175
175
|
{/* Header */}
|
|
176
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
176
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
177
177
|
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Compare versions</h2>
|
|
178
178
|
<button
|
|
179
179
|
onClick={onClose}
|
|
@@ -844,6 +844,9 @@ function ReviewSurface<P extends Project>({
|
|
|
844
844
|
// one caption id in `selectedIds` — taking the first is correct, the
|
|
845
845
|
// preview only ever shows one box.
|
|
846
846
|
const selectedCaptionId = selectedIds.find(id => captionIdSet.has(id)) ?? null
|
|
847
|
+
// Transport state for the context report. PreviewPlayer pushes it on a real
|
|
848
|
+
// play/pause change only, so this re-renders the surface per toggle, never per frame.
|
|
849
|
+
const [previewPlaying, setPreviewPlaying] = useState(false)
|
|
847
850
|
// Publish what we are looking at, so an agent can resolve "this section".
|
|
848
851
|
// No-ops entirely on a host that does not implement reportContext.
|
|
849
852
|
useReportContext({
|
|
@@ -852,6 +855,7 @@ function ReviewSurface<P extends Project>({
|
|
|
852
855
|
clock,
|
|
853
856
|
selectedIds,
|
|
854
857
|
selectedCaptionId,
|
|
858
|
+
playing: previewPlaying,
|
|
855
859
|
})
|
|
856
860
|
// Selecting a caption anywhere (timeline, preview box, or the caption list —
|
|
857
861
|
// all funnel through `selectedIds` → `selectedCaptionId`) jumps the left
|
|
@@ -2115,6 +2119,7 @@ function ReviewSurface<P extends Project>({
|
|
|
2115
2119
|
onCaptionSegmentChange={handleCaptionSegmentChange}
|
|
2116
2120
|
engine={engine}
|
|
2117
2121
|
transportRef={transportRef}
|
|
2122
|
+
onPlayingChange={setPreviewPlaying}
|
|
2118
2123
|
scrubHandleRef={scrubHandleRef}
|
|
2119
2124
|
hoverScrub={hoverScrub}
|
|
2120
2125
|
// Social-media preview chrome (mirrors CapCut's "Preview your
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
|
2
|
+
import { render, screen, within, cleanup } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, Project, ImageElement } from '../../types'
|
|
4
|
+
import RenderModal from '../RenderModal'
|
|
5
|
+
|
|
6
|
+
// The Export dialog portals to document.body, outside the editor root. A host
|
|
7
|
+
// whose page text is dark (the desktop app's `text-gray-900`) must not leak
|
|
8
|
+
// that colour into the dialog: the portal root sets the editor's own text
|
|
9
|
+
// colour, and every control carries an explicit themed colour.
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup)
|
|
12
|
+
|
|
13
|
+
const HOST_DARK_TEXT = 'rgb(17, 24, 39)'
|
|
14
|
+
let prevColor = ''
|
|
15
|
+
beforeEach(() => { prevColor = document.body.style.color; document.body.style.color = HOST_DARK_TEXT })
|
|
16
|
+
afterEach(() => { document.body.style.color = prevColor })
|
|
17
|
+
|
|
18
|
+
function adapter(): EditorAdapter<Project> {
|
|
19
|
+
return {
|
|
20
|
+
loadProject: vi.fn(),
|
|
21
|
+
saveProject: vi.fn(),
|
|
22
|
+
subscribe: () => () => {},
|
|
23
|
+
render: vi.fn(async function* () {}),
|
|
24
|
+
renderAsync: vi.fn(async () => ({ status: 'running' })),
|
|
25
|
+
getRenderStatus: vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const })),
|
|
26
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
27
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
28
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
29
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
30
|
+
uploadFile: vi.fn(async () => ''),
|
|
31
|
+
fileUrl: (p: string) => `/files?path=${p}`,
|
|
32
|
+
} as unknown as EditorAdapter<Project>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderExport() {
|
|
36
|
+
return render(
|
|
37
|
+
<RenderModal
|
|
38
|
+
adapter={adapter()}
|
|
39
|
+
projectId="vid-1"
|
|
40
|
+
onClose={vi.fn()}
|
|
41
|
+
preRenderOptions={{
|
|
42
|
+
isHdr: false,
|
|
43
|
+
keeps: [{ start: 0, end: 12 }],
|
|
44
|
+
resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set: vi.fn() },
|
|
45
|
+
}}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const THEMED_TEXT = /(^|\s)text-\[(var\(--editor-text\)|color-mix\(in_srgb,var\(--editor-text\)_\d+%,transparent\))\]/
|
|
51
|
+
|
|
52
|
+
describe('Export dialog in a dark-text host', () => {
|
|
53
|
+
it('the portal root sets the editor text colour', () => {
|
|
54
|
+
renderExport()
|
|
55
|
+
const cancel = screen.getByRole('button', { name: 'Cancel' })
|
|
56
|
+
const root = cancel.closest('.fixed')
|
|
57
|
+
expect(root).not.toBeNull()
|
|
58
|
+
expect(root!.className).toMatch(/(^|\s)text-\[var\(--editor-text\)\](\s|$)/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('Cancel has a non-empty label and an explicit themed text colour', () => {
|
|
62
|
+
renderExport()
|
|
63
|
+
const cancel = screen.getByRole('button', { name: 'Cancel' })
|
|
64
|
+
expect(cancel.textContent?.trim()).toBe('Cancel')
|
|
65
|
+
expect(cancel.className).toMatch(THEMED_TEXT)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('every resolution tile, 720p included, shows its dims in a themed colour', () => {
|
|
69
|
+
renderExport()
|
|
70
|
+
const group = screen.getByRole('radiogroup', { name: 'Resolution' })
|
|
71
|
+
const tile720 = within(group).getByRole('radio', { name: /720p/ })
|
|
72
|
+
const dims720 = within(tile720).getByText('720 × 1280')
|
|
73
|
+
expect(dims720.className).toMatch(THEMED_TEXT)
|
|
74
|
+
const tile1080 = within(group).getByRole('radio', { name: /1080p/ })
|
|
75
|
+
expect(within(tile1080).getByText('1080 × 1920').className).toMatch(THEMED_TEXT)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('the selected tile uses the editor accent, not a hard-coded violet', () => {
|
|
79
|
+
renderExport()
|
|
80
|
+
const group = screen.getByRole('radiogroup', { name: 'Resolution' })
|
|
81
|
+
const active = within(group).getByRole('radio', { checked: true })
|
|
82
|
+
expect(active.className).toContain('border-[var(--editor-accent)]')
|
|
83
|
+
expect(active.className).not.toMatch(/violet|indigo/)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -22,6 +22,12 @@ describe('VideoEditor context reporting', () => {
|
|
|
22
22
|
expect(call).toMatch(/adapter/)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
it('reports the transport state PreviewPlayer pushes on play/pause', () => {
|
|
26
|
+
const call = src.match(/useReportContext\(\{[\s\S]*?\}\)/)?.[0] ?? ''
|
|
27
|
+
expect(call).toMatch(/playing: previewPlaying/)
|
|
28
|
+
expect(src).toMatch(/onPlayingChange=\{setPreviewPlaying\}/)
|
|
29
|
+
})
|
|
30
|
+
|
|
25
31
|
// D1: captions share `selectedIds` with clips/audio now, so `selectedCaptionId`
|
|
26
32
|
// must be DERIVED from that array rather than tracked as its own `useState` —
|
|
27
33
|
// a second selection model is exactly the bug a future refactor could
|
|
@@ -163,8 +163,12 @@ describe('availableResolutionTiers', () => {
|
|
|
163
163
|
})
|
|
164
164
|
|
|
165
165
|
describe('availableFpsTiers', () => {
|
|
166
|
-
it('returns [24, 30] when settings.fps is 30', () => {
|
|
167
|
-
|
|
166
|
+
it('returns [24, 30] when settings.fps is 30 and the project has a video item', () => {
|
|
167
|
+
const p = makeProject({
|
|
168
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
169
|
+
tracks: vtracks([videoClip()]),
|
|
170
|
+
})
|
|
171
|
+
expect(availableFpsTiers(p)).toEqual([24, 30])
|
|
168
172
|
})
|
|
169
173
|
|
|
170
174
|
it('returns [24, 30, 60] when settings.fps is 60', () => {
|
|
@@ -172,6 +176,27 @@ describe('availableFpsTiers', () => {
|
|
|
172
176
|
24, 30, 60,
|
|
173
177
|
])
|
|
174
178
|
})
|
|
179
|
+
|
|
180
|
+
it('offers every tier at fps 30 when the project has no video items (overlay/canvas-only)', () => {
|
|
181
|
+
const p = makeProject({
|
|
182
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
183
|
+
tracks: vtracks([{ id: 'o1', type: 'overlay', start: 0, end: 5 }]),
|
|
184
|
+
})
|
|
185
|
+
expect(availableFpsTiers(p)).toEqual([24, 30, 60])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('offers every tier at fps 30 when tracks is empty (no items of any kind)', () => {
|
|
189
|
+
const p = makeProject({ settings: { resolution: [1080, 1920], fps: 30 } })
|
|
190
|
+
expect(availableFpsTiers(p)).toEqual([24, 30, 60])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('still caps to [24, 30] for a video item missing source dims (has-video check ignores dims)', () => {
|
|
194
|
+
const p = makeProject({
|
|
195
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
196
|
+
tracks: vtracks([videoClip()]),
|
|
197
|
+
})
|
|
198
|
+
expect(availableFpsTiers(p)).toEqual([24, 30])
|
|
199
|
+
})
|
|
175
200
|
})
|
|
176
201
|
|
|
177
202
|
describe('currentResolutionTier', () => {
|
|
@@ -26,7 +26,7 @@ describe('useReportContext', () => {
|
|
|
26
26
|
selectedIds: [], selectedCaptionId: null,
|
|
27
27
|
}))
|
|
28
28
|
expect(reportContext).toHaveBeenCalledWith('p1', {
|
|
29
|
-
playheadSec: 4, selectedIds: [], selectedCaptionId: null,
|
|
29
|
+
playheadSec: 4, selectedIds: [], selectedCaptionId: null, playing: false,
|
|
30
30
|
})
|
|
31
31
|
})
|
|
32
32
|
|
|
@@ -71,6 +71,27 @@ describe('useReportContext', () => {
|
|
|
71
71
|
expect(reportContext.mock.calls[0][1].selectedIds).toEqual(['c3'])
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
+
it('reports play and pause immediately, carrying the playhead', () => {
|
|
75
|
+
const reportContext = vi.fn().mockResolvedValue(undefined)
|
|
76
|
+
const clock = createPlaybackClock()
|
|
77
|
+
const adapter = { reportContext } as never
|
|
78
|
+
const { rerender } = renderHook(
|
|
79
|
+
(props: { playing: boolean }) => useReportContext({
|
|
80
|
+
adapter, projectId: 'p1', clock,
|
|
81
|
+
selectedIds: [], selectedCaptionId: null, playing: props.playing,
|
|
82
|
+
}),
|
|
83
|
+
{ initialProps: { playing: false } },
|
|
84
|
+
)
|
|
85
|
+
reportContext.mockClear()
|
|
86
|
+
rerender({ playing: true })
|
|
87
|
+
expect(reportContext).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(reportContext.mock.calls[0][1].playing).toBe(true)
|
|
89
|
+
act(() => { clock.set(2.5) })
|
|
90
|
+
rerender({ playing: false })
|
|
91
|
+
expect(reportContext).toHaveBeenCalledTimes(2)
|
|
92
|
+
expect(reportContext.mock.calls[1][1]).toMatchObject({ playing: false, playheadSec: 2.5 })
|
|
93
|
+
})
|
|
94
|
+
|
|
74
95
|
it('swallows a rejected report — context sync never breaks the editor', async () => {
|
|
75
96
|
const reportContext = vi.fn().mockRejectedValue(new Error('serve is down'))
|
|
76
97
|
const clock = createPlaybackClock()
|
|
@@ -31,6 +31,21 @@ function maxSourceShortSide(project: Project): number | undefined {
|
|
|
31
31
|
return max
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Whether the project has any video item at all (regardless of whether its
|
|
35
|
+
* source dims are known). Canvas/animation projects — overlays, images,
|
|
36
|
+
* audio, no footage — have no source fps to protect and render cleanly at
|
|
37
|
+
* any fps, so `availableFpsTiers` uses this (not `maxSourceShortSide`, which
|
|
38
|
+
* also returns undefined for a video item with unknown dims) to decide
|
|
39
|
+
* whether the fps cap applies at all. */
|
|
40
|
+
function hasVideoItem(project: Project): boolean {
|
|
41
|
+
for (const items of trackItems(project)) {
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.type === 'video') return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
/** Converts a short-side tier to [w,h], preserving the project's aspect and
|
|
35
50
|
* orientation. Long side is rounded to the nearest EVEN integer (encoders
|
|
36
51
|
* demand even dims). */
|
|
@@ -79,8 +94,14 @@ export function availableResolutionTiers(project: Project): Array<[number, numbe
|
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
/** Every fps tier offerable in the export dialog, filtered to the source cap
|
|
82
|
-
* and ordered ascending. Always non-empty.
|
|
97
|
+
* and ordered ascending. Always non-empty.
|
|
98
|
+
*
|
|
99
|
+
* The cap only protects source footage, so a project with no video items at
|
|
100
|
+
* all — overlays/images/audio only, e.g. a canvas/animation project — has
|
|
101
|
+
* nothing to protect and offers every standard tier uncapped. */
|
|
83
102
|
export function availableFpsTiers(project: Project): number[] {
|
|
103
|
+
if (!hasVideoItem(project)) return [...STANDARD_FPS_TIERS]
|
|
104
|
+
|
|
84
105
|
const cap = maxExportFps(project)
|
|
85
106
|
const tiers = STANDARD_FPS_TIERS.filter(f => f <= cap)
|
|
86
107
|
return tiers.length > 0 ? tiers : [cap]
|
|
@@ -85,6 +85,11 @@ interface PreviewPlayerProps {
|
|
|
85
85
|
* engine). Absent → no-op; nothing reads or writes it.
|
|
86
86
|
*/
|
|
87
87
|
transportRef?: MutableRefObject<TransportHandle | null>
|
|
88
|
+
/**
|
|
89
|
+
* Called with the transport's playing state whenever it changes (and once on
|
|
90
|
+
* mount), from whichever playback path is active. Absent → no-op.
|
|
91
|
+
*/
|
|
92
|
+
onPlayingChange?: (playing: boolean) => void
|
|
88
93
|
/**
|
|
89
94
|
* The audible drag-scrub source's engine seam — mirrors `transportRef`.
|
|
90
95
|
* Absent → no-op; nothing reads or writes it.
|
|
@@ -314,6 +319,7 @@ function PreviewSurface({
|
|
|
314
319
|
onCaptionSegmentChange,
|
|
315
320
|
engine,
|
|
316
321
|
transportRef,
|
|
322
|
+
onPlayingChange,
|
|
317
323
|
scrubHandleRef,
|
|
318
324
|
socialPreview,
|
|
319
325
|
muted,
|
|
@@ -378,6 +384,14 @@ function PreviewSurface({
|
|
|
378
384
|
return () => { transportRef.current = null }
|
|
379
385
|
}, [transportRef, togglePlay, isPlaying, setRate])
|
|
380
386
|
|
|
387
|
+
// Push play/pause to the host. Held in a ref so a new callback identity
|
|
388
|
+
// never re-fires it; only a real `isPlaying` change does.
|
|
389
|
+
const onPlayingChangeRef = useRef(onPlayingChange)
|
|
390
|
+
onPlayingChangeRef.current = onPlayingChange
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
onPlayingChangeRef.current?.(isPlaying)
|
|
393
|
+
}, [isPlaying])
|
|
394
|
+
|
|
381
395
|
// The scrub seam: only the engine path can decode a grain, so the legacy
|
|
382
396
|
// `<video>` fallback simply never fills this — that absence is the audible
|
|
383
397
|
// drag-scrub's capability gate. `acquireDemux` is a stable-identity callback
|
|
@@ -145,7 +145,7 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
|
|
|
145
145
|
ref={popoverRef}
|
|
146
146
|
role="menu"
|
|
147
147
|
aria-label="Preview for social media"
|
|
148
|
-
className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1"
|
|
148
|
+
className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1 text-[var(--editor-text)]"
|
|
149
149
|
style={{
|
|
150
150
|
left: position?.left ?? -9999,
|
|
151
151
|
top: position?.top ?? -9999,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `onPlayingChange` — PreviewPlayer pushes the ACTIVE transport's playing
|
|
3
|
+
* state to the host (VideoEditor feeds it to the agent context report). Driven
|
|
4
|
+
* through the same `transportRef` seam the keymap uses, on a canvas project so
|
|
5
|
+
* play/pause is a pure state flip with no media element in the way.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
8
|
+
import { render, act } from '@testing-library/react'
|
|
9
|
+
import type { MutableRefObject } from 'react'
|
|
10
|
+
import PreviewPlayer, { type TransportHandle } from '../PreviewPlayer'
|
|
11
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
12
|
+
import type { EditorProject as Project } from '../../../schema'
|
|
13
|
+
|
|
14
|
+
function makeCanvasProject(): Project {
|
|
15
|
+
return {
|
|
16
|
+
id: 'p-canvas',
|
|
17
|
+
status: 'draft',
|
|
18
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
19
|
+
tracks: [[{ id: 'o1', type: 'overlay', src: 'a.jsx', start: 0, end: 4, props: {} }]],
|
|
20
|
+
} as unknown as Project
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
25
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
26
|
+
observe() {}
|
|
27
|
+
unobserve() {}
|
|
28
|
+
disconnect() {}
|
|
29
|
+
}
|
|
30
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
31
|
+
state = 'running'
|
|
32
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
33
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
34
|
+
get destination() { return {} }
|
|
35
|
+
resume() { return Promise.resolve() }
|
|
36
|
+
close() {}
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
40
|
+
|
|
41
|
+
describe('PreviewPlayer onPlayingChange', () => {
|
|
42
|
+
it('reports paused on mount, then each real play/pause change', () => {
|
|
43
|
+
const transportRef: MutableRefObject<TransportHandle | null> = { current: null }
|
|
44
|
+
const onPlayingChange = vi.fn()
|
|
45
|
+
render(
|
|
46
|
+
<PreviewPlayer
|
|
47
|
+
project={makeCanvasProject()}
|
|
48
|
+
clock={createPlaybackClock(0)}
|
|
49
|
+
compileOverlay={async () => (() => null) as never}
|
|
50
|
+
fileUrl={(p) => p}
|
|
51
|
+
transportRef={transportRef}
|
|
52
|
+
onPlayingChange={onPlayingChange}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
expect(onPlayingChange.mock.calls).toEqual([[false]])
|
|
56
|
+
act(() => { transportRef.current!.togglePlay() })
|
|
57
|
+
expect(onPlayingChange.mock.lastCall).toEqual([true])
|
|
58
|
+
act(() => { transportRef.current!.togglePlay() })
|
|
59
|
+
expect(onPlayingChange.mock.lastCall).toEqual([false])
|
|
60
|
+
expect(onPlayingChange).toHaveBeenCalledTimes(3)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -174,7 +174,7 @@ export default function TrackSettingsPopover({
|
|
|
174
174
|
ref={popoverRef}
|
|
175
175
|
role="dialog"
|
|
176
176
|
aria-label={title}
|
|
177
|
-
className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3"
|
|
177
|
+
className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3 text-[var(--editor-text)]"
|
|
178
178
|
style={{
|
|
179
179
|
left: position?.left ?? -9999,
|
|
180
180
|
top: position?.top ?? -9999,
|
|
@@ -23,6 +23,8 @@ interface Options {
|
|
|
23
23
|
clock: PlaybackClock
|
|
24
24
|
selectedIds: string[]
|
|
25
25
|
selectedCaptionId: string | null
|
|
26
|
+
/** Transport state. Reported like selection: at once, on change. */
|
|
27
|
+
playing?: boolean
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -36,12 +38,12 @@ interface Options {
|
|
|
36
38
|
* selection.
|
|
37
39
|
*/
|
|
38
40
|
export function useReportContext({
|
|
39
|
-
adapter, projectId, clock, selectedIds, selectedCaptionId,
|
|
41
|
+
adapter, projectId, clock, selectedIds, selectedCaptionId, playing = false,
|
|
40
42
|
}: Options): void {
|
|
41
43
|
// Kept in a ref so the interval effect never re-subscribes on a selection
|
|
42
44
|
// change — it always reads the latest values without depending on them.
|
|
43
|
-
const latest = useRef({ selectedIds, selectedCaptionId })
|
|
44
|
-
latest.current = { selectedIds, selectedCaptionId }
|
|
45
|
+
const latest = useRef({ selectedIds, selectedCaptionId, playing })
|
|
46
|
+
latest.current = { selectedIds, selectedCaptionId, playing }
|
|
45
47
|
|
|
46
48
|
const send = useRef<(ctx: EditorContext) => void>(() => {})
|
|
47
49
|
send.current = (ctx: EditorContext) => {
|
|
@@ -58,18 +60,19 @@ export function useReportContext({
|
|
|
58
60
|
playheadSec: clock.get(),
|
|
59
61
|
selectedIds: latest.current.selectedIds,
|
|
60
62
|
selectedCaptionId: latest.current.selectedCaptionId,
|
|
63
|
+
playing: latest.current.playing,
|
|
61
64
|
})
|
|
62
65
|
emit()
|
|
63
66
|
const id = setInterval(emit, REPORT_INTERVAL_MS)
|
|
64
67
|
return () => clearInterval(id)
|
|
65
68
|
}, [adapter, projectId, clock])
|
|
66
69
|
|
|
67
|
-
// Selection, immediately on change. Skips the mount emit
|
|
68
|
-
// against what the interval effect already sent.
|
|
70
|
+
// Selection and play/pause, immediately on change. Skips the mount emit
|
|
71
|
+
// above by comparing against what the interval effect already sent.
|
|
69
72
|
const mounted = useRef(false)
|
|
70
73
|
useEffect(() => {
|
|
71
74
|
if (!adapter.reportContext) return
|
|
72
75
|
if (!mounted.current) { mounted.current = true; return }
|
|
73
|
-
send.current({ playheadSec: clock.get(), selectedIds, selectedCaptionId })
|
|
74
|
-
}, [adapter, clock, selectedIds, selectedCaptionId])
|
|
76
|
+
send.current({ playheadSec: clock.get(), selectedIds, selectedCaptionId, playing })
|
|
77
|
+
}, [adapter, clock, selectedIds, selectedCaptionId, playing])
|
|
75
78
|
}
|