@bycrux/editor 1.2.2 → 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 +5 -5
- package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
- package/src/__tests__/node-shims.d.ts +19 -0
- package/src/__tests__/portal-roots-set-editor-text.test.ts +55 -0
- package/src/carousel/AddElementMenu.tsx +1 -1
- package/src/carousel/CarouselEditor.tsx +11 -11
- package/src/carousel/CarouselRenderModal.tsx +14 -14
- package/src/carousel/OverlayPicker.tsx +4 -4
- package/src/carousel/SlidePropertyPanel.tsx +6 -6
- package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
- package/src/components/FilmstripScrubber.tsx +1 -1
- package/src/crop/VideoSourceCropModal.tsx +5 -5
- package/src/state/__tests__/use-project-sync.test.tsx +76 -0
- package/src/state/use-project-sync.ts +21 -1
- package/src/text/FontPicker.tsx +3 -3
- package/src/text/TextFormattingToolbar.tsx +3 -3
- package/src/types.ts +18 -0
- package/src/ui/Loader.tsx +1 -1
- package/src/ui/NumberField.tsx +3 -3
- package/src/ui/SwatchInput.tsx +1 -1
- package/src/ui/Tooltip.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/label.tsx +1 -1
- package/src/ui/textarea.tsx +1 -1
- package/src/video/AudioPolishModal.tsx +22 -22
- package/src/video/CaptionListPanel.test.tsx +8 -7
- package/src/video/CaptionListPanel.tsx +17 -17
- package/src/video/CaptionRegenModal.tsx +8 -8
- package/src/video/CaptionStyleGallery.tsx +1 -1
- package/src/video/CommandPalette.tsx +8 -8
- package/src/video/ImageToneMenu.tsx +13 -14
- package/src/video/OverlayInspector.tsx +7 -7
- package/src/video/RenderModal.tsx +76 -78
- package/src/video/VersionCompare.tsx +10 -10
- package/src/video/VersionPanel.tsx +2 -2
- package/src/video/VideoEditor.tsx +48 -23
- package/src/video/__tests__/RenderModal.hostTheme.test.tsx +85 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +6 -0
- package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
- package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
- package/src/video/__tests__/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/panels/ClipPropertiesPanel.tsx +4 -4
- package/src/video/panels/OverlayContentPanel.tsx +5 -5
- package/src/video/preview/OverlayItemsLayer.tsx +7 -2
- package/src/video/preview/PreviewPlayer.tsx +14 -0
- package/src/video/preview/SocialPreviewMenu.tsx +6 -6
- package/src/video/preview/__tests__/PreviewPlayer.playing.test.tsx +62 -0
- package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +2 -2
- package/src/video/timeline/SpeedControl.tsx +3 -3
- package/src/video/timeline/Timeline.tsx +3 -3
- package/src/video/timeline/TrackGutter.tsx +6 -6
- package/src/video/timeline/TrackSettingsPopover.tsx +4 -4
- package/src/video/timeline/VolumeControl.tsx +3 -3
- package/src/video/use-report-context.ts +10 -7
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import VideoEditor from '../VideoEditor'
|
|
5
|
+
|
|
6
|
+
// ── Save-error banner ────────────────────────────────────────────────────────
|
|
7
|
+
// `sync.lastError` (use-project-sync.ts) used to go unrendered in the video
|
|
8
|
+
// editor: a failed save rolled the edit back with no visible sign anything
|
|
9
|
+
// went wrong. CarouselEditor already surfaces it as a dismissible banner
|
|
10
|
+
// (CarouselEditor.tsx ~584-590); this mirrors that pattern in VideoEditor.
|
|
11
|
+
//
|
|
12
|
+
// "M drops a marker" (VideoEditor.keymap.test.tsx) is reused as the trigger —
|
|
13
|
+
// the simplest real `sync.mutate` call reachable without selecting a clip —
|
|
14
|
+
// so this test can focus on the banner itself rather than on how to cause a
|
|
15
|
+
// mutation.
|
|
16
|
+
|
|
17
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
18
|
+
return {
|
|
19
|
+
id: 'vid-1',
|
|
20
|
+
name: 'Test Video',
|
|
21
|
+
status: 'draft',
|
|
22
|
+
editingPrompt: '',
|
|
23
|
+
projectType: 'video',
|
|
24
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
25
|
+
tracks: [
|
|
26
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
27
|
+
],
|
|
28
|
+
audio: { tracks: [] },
|
|
29
|
+
assets: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
} as Project
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeFailingSaveAdapter(): EditorAdapter<Project> {
|
|
35
|
+
return {
|
|
36
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
37
|
+
saveProject: vi.fn(async () => { throw new Error('save boom') }),
|
|
38
|
+
subscribe: () => () => {},
|
|
39
|
+
render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
|
|
40
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
41
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
42
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
43
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
44
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
45
|
+
fileUrl: (path: string) => path,
|
|
46
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
47
|
+
restoreVersion: vi.fn(async () => makeVideoProject()),
|
|
48
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
49
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
50
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
56
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
58
|
+
observe() {}
|
|
59
|
+
unobserve() {}
|
|
60
|
+
disconnect() {}
|
|
61
|
+
}
|
|
62
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
63
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
64
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
65
|
+
state = 'running'
|
|
66
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
67
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
68
|
+
get destination() { return {} }
|
|
69
|
+
close() {}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
afterEach(() => vi.restoreAllMocks())
|
|
73
|
+
|
|
74
|
+
describe('VideoEditor — save-error banner', () => {
|
|
75
|
+
it('shows sync.lastError when a save rejects, and dismiss clears it', async () => {
|
|
76
|
+
const adapter = makeFailingSaveAdapter()
|
|
77
|
+
render(
|
|
78
|
+
<VideoEditor
|
|
79
|
+
project={makeVideoProject()}
|
|
80
|
+
adapter={adapter}
|
|
81
|
+
onProjectChange={vi.fn()}
|
|
82
|
+
slots={{ exportActions: <div /> }}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
await screen.findByLabelText('Preview axis')
|
|
86
|
+
|
|
87
|
+
// No error yet.
|
|
88
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
89
|
+
|
|
90
|
+
// Drop a marker — the simplest sync.mutate a mounted editor can reach
|
|
91
|
+
// without first selecting a clip. Its save rejects per the adapter above.
|
|
92
|
+
await act(async () => {
|
|
93
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const banner = await waitFor(() => screen.getByText('save boom'))
|
|
97
|
+
expect(banner).toBeTruthy()
|
|
98
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
99
|
+
|
|
100
|
+
const dismissBtn = screen.getByText('dismiss')
|
|
101
|
+
await act(async () => { fireEvent.click(dismissBtn) })
|
|
102
|
+
|
|
103
|
+
expect(screen.queryByText('save boom')).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -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]
|
|
@@ -68,7 +68,7 @@ export interface ClipPropertiesPanelProps {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
71
|
-
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]
|
|
71
|
+
const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
72
72
|
|
|
73
73
|
function basename(path: string): string {
|
|
74
74
|
return path.split('/').pop() ?? path
|
|
@@ -107,7 +107,7 @@ function CollapsibleSection({
|
|
|
107
107
|
type="button"
|
|
108
108
|
aria-expanded={!collapsed}
|
|
109
109
|
onClick={onToggle}
|
|
110
|
-
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]
|
|
110
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] transition-colors hover:text-[var(--editor-text)]"
|
|
111
111
|
>
|
|
112
112
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
113
113
|
<span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
|
|
@@ -358,7 +358,7 @@ function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSl
|
|
|
358
358
|
<div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
|
|
359
359
|
<Crop size={20} />
|
|
360
360
|
</div>
|
|
361
|
-
<p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]
|
|
361
|
+
<p className="max-w-[220px] text-xs leading-relaxed text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
362
362
|
Reframe this clip by cropping its source video. Pick the part of the frame to keep.
|
|
363
363
|
</p>
|
|
364
364
|
<button
|
|
@@ -678,7 +678,7 @@ export default function ClipPropertiesPanel({
|
|
|
678
678
|
if (!selection) {
|
|
679
679
|
return (
|
|
680
680
|
<div className={SECTION_CLASS}>
|
|
681
|
-
<div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]
|
|
681
|
+
<div className="px-3 py-6 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
682
682
|
Select a clip to edit its properties.
|
|
683
683
|
</div>
|
|
684
684
|
</div>
|
|
@@ -65,7 +65,7 @@ export interface OverlayContentPanelProps {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
68
|
-
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]
|
|
68
|
+
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Image prop control: a thumbnail preview plus a file picker that uploads the
|
|
@@ -128,16 +128,16 @@ function ImageField({
|
|
|
128
128
|
className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
|
|
129
129
|
/>
|
|
130
130
|
) : (
|
|
131
|
-
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]
|
|
131
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
|
|
132
132
|
none
|
|
133
133
|
</div>
|
|
134
134
|
)}
|
|
135
135
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
136
|
-
<span className="truncate font-mono text-xs text-[var(--editor-text)]
|
|
136
|
+
<span className="truncate font-mono text-xs text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]" title={value}>
|
|
137
137
|
{value ? value.split('/').pop() : '—'}
|
|
138
138
|
</span>
|
|
139
139
|
{uploadFile ? (
|
|
140
|
-
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]
|
|
140
|
+
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]">
|
|
141
141
|
{busy ? 'Uploading…' : 'Change…'}
|
|
142
142
|
<input
|
|
143
143
|
type="file"
|
|
@@ -324,7 +324,7 @@ export default function OverlayContentPanel({
|
|
|
324
324
|
<div className={SECTION_CLASS}>
|
|
325
325
|
<div className="flex flex-col gap-3 p-2">
|
|
326
326
|
{fields.length === 0 ? (
|
|
327
|
-
<p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]
|
|
327
|
+
<p className="px-1 py-4 text-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
|
|
328
328
|
{item
|
|
329
329
|
? 'This overlay has no editable content.'
|
|
330
330
|
: 'Select an overlay to edit its content.'}
|
|
@@ -113,13 +113,18 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, speed = 1, isPlayi
|
|
|
113
113
|
// already recurses — without this, image paths nested in array/object props
|
|
114
114
|
// (e.g. players[].src, items[].src) reach <img> raw as /var/hub-scratch/... and
|
|
115
115
|
// render blank in preview even though they render correctly in the final MP4.
|
|
116
|
-
// Already-proxied /api/ URLs are left untouched (idempotent).
|
|
116
|
+
// Already-proxied /api/ URLs are left untouched (idempotent). On Windows an
|
|
117
|
+
// absolute path is a drive-letter one (C:\ or C:/); this runs in the browser,
|
|
118
|
+
// so that is a regex, not Node's path module. UNC (\\host) is not rewritten.
|
|
119
|
+
const DRIVE_ABS_PATH = /^[A-Za-z]:[\\/]/
|
|
120
|
+
|
|
117
121
|
export function resolveOverlayPropPaths(
|
|
118
122
|
value: unknown,
|
|
119
123
|
fileUrl: (path: string) => string,
|
|
120
124
|
): unknown {
|
|
121
125
|
if (typeof value === 'string') {
|
|
122
|
-
|
|
126
|
+
const abs = (value.startsWith('/') && !value.startsWith('/api/')) || DRIVE_ABS_PATH.test(value)
|
|
127
|
+
return abs ? fileUrl(value) : value
|
|
123
128
|
}
|
|
124
129
|
if (Array.isArray(value)) {
|
|
125
130
|
return value.map((v) => resolveOverlayPropPaths(v, fileUrl))
|
|
@@ -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,
|
|
@@ -157,8 +157,8 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
|
|
|
157
157
|
}}
|
|
158
158
|
>
|
|
159
159
|
<p className="px-2 pt-1 pb-1.5">
|
|
160
|
-
<span className="block text-[11px] font-semibold text-[var(--editor-text)]
|
|
161
|
-
<span className="block text-[10px] text-[var(--editor-text)]
|
|
160
|
+
<span className="block text-[11px] font-semibold text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)]">Preview for social media</span>
|
|
161
|
+
<span className="block text-[10px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">What you see may vary depending on your device.</span>
|
|
162
162
|
</p>
|
|
163
163
|
|
|
164
164
|
{PLATFORM_OPTIONS.map(opt => {
|
|
@@ -175,7 +175,7 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
|
|
|
175
175
|
className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
|
|
176
176
|
active
|
|
177
177
|
? 'text-[var(--editor-text)] bg-sky-400/10'
|
|
178
|
-
: 'text-[var(--editor-text)]
|
|
178
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
179
179
|
}`}
|
|
180
180
|
>
|
|
181
181
|
<span className="flex items-center gap-2">
|
|
@@ -199,11 +199,11 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
|
|
|
199
199
|
className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
|
|
200
200
|
value === null
|
|
201
201
|
? 'text-[var(--editor-text)] bg-sky-400/10'
|
|
202
|
-
: 'text-[var(--editor-text)]
|
|
202
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
|
|
203
203
|
}`}
|
|
204
204
|
>
|
|
205
205
|
<span className="flex items-center gap-2">
|
|
206
|
-
<PlatformGlyph icon={Slash} badgeClassName="bg-[var(--editor-text)]
|
|
206
|
+
<PlatformGlyph icon={Slash} badgeClassName="bg-[color-mix(in_srgb,var(--editor-text)_15%,transparent)]" />
|
|
207
207
|
<span>None</span>
|
|
208
208
|
</span>
|
|
209
209
|
{value === null && <Check size={13} className={mode === 'light' ? 'text-sky-600' : 'text-sky-400'} />}
|
|
@@ -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
|
+
})
|
|
@@ -50,4 +50,25 @@ describe('resolveOverlayPropPaths', () => {
|
|
|
50
50
|
expect(out.duration).toBe(90)
|
|
51
51
|
expect(out.nested.deep[0].src).toBe(fileUrl('/var/x.png'))
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
// Windows: an absolute image prop is a drive-letter path. It must reach
|
|
55
|
+
// fileUrl like a posix absolute path does; a UNC path stays refused.
|
|
56
|
+
it('rewrites drive-letter absolute paths in either slash style', () => {
|
|
57
|
+
expect(resolveOverlayPropPaths('C:\\a\\x.png', fileUrl)).toBe(fileUrl('C:\\a\\x.png'))
|
|
58
|
+
expect(resolveOverlayPropPaths('C:/a/x.png', fileUrl)).toBe(fileUrl('C:/a/x.png'))
|
|
59
|
+
expect(resolveOverlayPropPaths('d:\\x.png', fileUrl)).toBe(fileUrl('d:\\x.png'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves UNC, drive-relative and relative paths untouched', () => {
|
|
63
|
+
for (const v of ['\\\\server\\x', 'C:x.png', 'a\\x.png', 'a/x.png']) {
|
|
64
|
+
expect(resolveOverlayPropPaths(v, fileUrl)).toBe(v)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps posix behaviour exactly: /abs and //host rewritten, /api/ untouched', () => {
|
|
69
|
+
expect(resolveOverlayPropPaths('/abs/x.png', fileUrl)).toBe(fileUrl('/abs/x.png'))
|
|
70
|
+
expect(resolveOverlayPropPaths('//host/x.png', fileUrl)).toBe(fileUrl('//host/x.png'))
|
|
71
|
+
expect(resolveOverlayPropPaths('/api/x', fileUrl)).toBe('/api/x')
|
|
72
|
+
expect(resolveOverlayPropPaths('/apix', fileUrl)).toBe(fileUrl('/apix'))
|
|
73
|
+
})
|
|
53
74
|
})
|
|
@@ -22,7 +22,7 @@ export function EditableSegment({ seg, onEdit }: { seg: CaptionSegment; onEdit:
|
|
|
22
22
|
suppressContentEditableWarning
|
|
23
23
|
onBlur={handleBlur}
|
|
24
24
|
onKeyDown={handleKeyDown}
|
|
25
|
-
className="cursor-text rounded px-0.5 hover:bg-[var(--editor-text)]
|
|
25
|
+
className="cursor-text rounded px-0.5 hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)] focus:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] focus:outline-none focus:ring-1 focus:ring-purple-500/40"
|
|
26
26
|
>
|
|
27
27
|
{seg.text}
|
|
28
28
|
</span>
|
|
@@ -34,14 +34,14 @@ export default function Scrubber({ onOpenGoToTime }: ScrubberProps) {
|
|
|
34
34
|
/* `cursor-pointer`: the row is a strip of timeline background, and
|
|
35
35
|
Timeline's container click seeks through it (see `handleContainerClick`),
|
|
36
36
|
so it should look live rather than dead. */
|
|
37
|
-
<div className="flex items-center justify-between text-[10px] font-mono text-[var(--editor-text)]
|
|
37
|
+
<div className="flex items-center justify-between text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] cursor-pointer">
|
|
38
38
|
{onOpenGoToTime ? (
|
|
39
39
|
<Tooltip label="Go to time">
|
|
40
40
|
<button
|
|
41
41
|
type="button"
|
|
42
42
|
aria-label="Go to time"
|
|
43
43
|
onClick={(e) => { e.stopPropagation(); onOpenGoToTime() }}
|
|
44
|
-
className="hover:text-[var(--editor-text)]
|
|
44
|
+
className="hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors"
|
|
45
45
|
>
|
|
46
46
|
{formatTime(currentTime)}
|
|
47
47
|
</button>
|
|
@@ -52,9 +52,9 @@ export default function SpeedControl({
|
|
|
52
52
|
|
|
53
53
|
return (
|
|
54
54
|
<div className="flex flex-col gap-2">
|
|
55
|
-
<div className={`flex items-center text-[11px] text-[var(--editor-text)]
|
|
55
|
+
<div className={`flex items-center text-[11px] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] ${label ? 'justify-between' : 'justify-end'}`}>
|
|
56
56
|
{label && <span>{label}</span>}
|
|
57
|
-
<span className="font-mono text-[10px] text-[var(--editor-text)]
|
|
57
|
+
<span className="font-mono text-[10px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">{value.toFixed(2)}×</span>
|
|
58
58
|
</div>
|
|
59
59
|
<Slider
|
|
60
60
|
id={sliderId}
|
|
@@ -82,7 +82,7 @@ export default function SpeedControl({
|
|
|
82
82
|
className={`rounded-md border px-2 py-0.5 text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] ${
|
|
83
83
|
active
|
|
84
84
|
? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
|
|
85
|
-
: 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]
|
|
85
|
+
: 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
|
|
86
86
|
}`}
|
|
87
87
|
>
|
|
88
88
|
{p}×
|
|
@@ -940,7 +940,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
|
|
|
940
940
|
<div className="flex items-center justify-end gap-0.5 -mb-1 cursor-pointer">
|
|
941
941
|
<Tooltip label="Zoom out">
|
|
942
942
|
<button
|
|
943
|
-
className="text-[11px] leading-none text-[var(--editor-text)]
|
|
943
|
+
className="text-[11px] leading-none text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] w-5 h-5 flex items-center justify-center rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
|
|
944
944
|
aria-label="Zoom out"
|
|
945
945
|
onClick={(e) => { e.stopPropagation(); zoomControls.zoomOut() }}
|
|
946
946
|
>−</button>
|
|
@@ -948,7 +948,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
|
|
|
948
948
|
{zoomControls.badge}
|
|
949
949
|
<Tooltip label="Zoom in">
|
|
950
950
|
<button
|
|
951
|
-
className="text-[11px] leading-none text-[var(--editor-text)]
|
|
951
|
+
className="text-[11px] leading-none text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] w-5 h-5 flex items-center justify-center rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
|
|
952
952
|
aria-label="Zoom in"
|
|
953
953
|
onClick={(e) => { e.stopPropagation(); zoomControls.zoomIn() }}
|
|
954
954
|
>+</button>
|
|
@@ -956,7 +956,7 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
|
|
|
956
956
|
{zoomControls.showFit && (
|
|
957
957
|
<Tooltip label="Fit to view" className="ml-0.5">
|
|
958
958
|
<button
|
|
959
|
-
className="text-[10px] text-[var(--editor-text)]
|
|
959
|
+
className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] px-1.5 h-5 rounded hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] transition-colors"
|
|
960
960
|
aria-label="Fit to view"
|
|
961
961
|
onClick={(e) => { e.stopPropagation(); zoomControls.fit() }}
|
|
962
962
|
>fit</button>
|
|
@@ -118,7 +118,7 @@ function RailCell({ height, accent, icon, label, action, dimmed, settingsButton
|
|
|
118
118
|
icons could be clicked — a decorative-looking glyph that happened to
|
|
119
119
|
open a dialog. Settings moved to the gear beside it. */}
|
|
120
120
|
<Tooltip label={label}>
|
|
121
|
-
<span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[var(--editor-text)]
|
|
121
|
+
<span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
|
|
122
122
|
{icon}
|
|
123
123
|
</span>
|
|
124
124
|
</Tooltip>
|
|
@@ -144,8 +144,8 @@ function RailCell({ height, accent, icon, label, action, dimmed, settingsButton
|
|
|
144
144
|
onClick={settingsButton.onClick}
|
|
145
145
|
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
146
146
|
settingsButton.open
|
|
147
|
-
? 'text-[var(--editor-text)]
|
|
148
|
-
: 'text-[var(--editor-text)]
|
|
147
|
+
? 'text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)] bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]'
|
|
148
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_90%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]'
|
|
149
149
|
}`}
|
|
150
150
|
>
|
|
151
151
|
<Settings2 size={13} />
|
|
@@ -179,7 +179,7 @@ function MuteToggle({ muted, onToggle, trackLabel, mode }: { muted: boolean; onT
|
|
|
179
179
|
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
180
180
|
muted
|
|
181
181
|
? (mode === 'light' ? 'text-amber-600 hover:text-amber-700' : 'text-amber-400/90 hover:text-amber-300')
|
|
182
|
-
: 'text-[var(--editor-text)]
|
|
182
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
|
|
183
183
|
}`}
|
|
184
184
|
>
|
|
185
185
|
{muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
|
|
@@ -206,7 +206,7 @@ function MagnetToggle({ magnetic, onToggle, trackLabel, mode }: { magnetic: bool
|
|
|
206
206
|
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
207
207
|
magnetic
|
|
208
208
|
? (mode === 'light' ? 'text-emerald-600 hover:text-emerald-700' : 'text-emerald-400/90 hover:text-emerald-300')
|
|
209
|
-
: 'text-[var(--editor-text)]
|
|
209
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
|
|
210
210
|
}`}
|
|
211
211
|
>
|
|
212
212
|
<Magnet size={13} />
|
|
@@ -227,7 +227,7 @@ function SkipToggle({ enabled, onToggle, trackLabel, mode }: { enabled: boolean;
|
|
|
227
227
|
onClick={onToggle}
|
|
228
228
|
className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
|
|
229
229
|
enabled
|
|
230
|
-
? 'text-[var(--editor-text)]
|
|
230
|
+
? 'text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]'
|
|
231
231
|
: (mode === 'light' ? 'text-amber-600/90 hover:text-amber-700' : 'text-amber-400/80 hover:text-amber-300')
|
|
232
232
|
}`}
|
|
233
233
|
>
|