@elah/editor 0.1.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/README.md +169 -0
- package/dist/editor/AssetPanel/AssetPanel.d.ts +6 -0
- package/dist/editor/AssetPanel/AssetPanel.js +272 -0
- package/dist/editor/AssetPanel/index.d.ts +2 -0
- package/dist/editor/AssetPanel/index.js +1 -0
- package/dist/editor/EditorProvider.d.ts +14 -0
- package/dist/editor/EditorProvider.js +80 -0
- package/dist/editor/ElementsPanel/ElementsPanel.d.ts +6 -0
- package/dist/editor/ElementsPanel/ElementsPanel.js +51 -0
- package/dist/editor/ElementsPanel/index.d.ts +2 -0
- package/dist/editor/ElementsPanel/index.js +1 -0
- package/dist/editor/Preview/MediaTransformOverlay.d.ts +1 -0
- package/dist/editor/Preview/MediaTransformOverlay.js +180 -0
- package/dist/editor/Preview/Preview.d.ts +18 -0
- package/dist/editor/Preview/Preview.js +72 -0
- package/dist/editor/Preview/StageBorder.d.ts +1 -0
- package/dist/editor/Preview/StageBorder.js +29 -0
- package/dist/editor/Preview/TextOverlay.d.ts +1 -0
- package/dist/editor/Preview/TextOverlay.js +246 -0
- package/dist/editor/Preview/TransitionOverlay.d.ts +6 -0
- package/dist/editor/Preview/TransitionOverlay.js +67 -0
- package/dist/editor/Preview/index.d.ts +2 -0
- package/dist/editor/Preview/index.js +1 -0
- package/dist/editor/index.d.ts +8 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/useResolvedScene.d.ts +2 -0
- package/dist/editor/useResolvedScene.js +25 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +34 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @elah/editor
|
|
2
|
+
|
|
3
|
+
Engine-first video timeline SDK for React. Internally layered as `core/` → `timeline/` → `editor/`.
|
|
4
|
+
|
|
5
|
+
See the repo root [README](../../README.md) for project overview and the [core architecture reference](src/core/Architecture.md) for a cold-start guide to the engine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
This package is part of the monorepo workspace:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Peer dependencies: `react`, `react-dom` (>= 18).
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { EditorProvider, Timeline } from '@elah/editor'
|
|
21
|
+
|
|
22
|
+
function App() {
|
|
23
|
+
return (
|
|
24
|
+
<EditorProvider fps={30}>
|
|
25
|
+
<Timeline style={{ height: 300 }} />
|
|
26
|
+
</EditorProvider>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Import media files
|
|
32
|
+
|
|
33
|
+
Register local files into the media library from a file input or drop handler:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { importFiles, useMediaLibraryStore } from '@elah/editor'
|
|
37
|
+
|
|
38
|
+
async function onFilesSelected(files: FileList | File[]) {
|
|
39
|
+
const list = Array.from(files)
|
|
40
|
+
const assets = await importFiles(list)
|
|
41
|
+
|
|
42
|
+
// Assets are in the store immediately; thumbnails arrive shortly after.
|
|
43
|
+
console.log(useMediaLibraryStore.getState().assets)
|
|
44
|
+
|
|
45
|
+
// Subscribe in React via useMediaLibrary() for UI updates.
|
|
46
|
+
return assets
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`importFiles`:
|
|
51
|
+
|
|
52
|
+
- Creates object URLs and probes duration/dimensions via DOM media elements
|
|
53
|
+
- Skips unsupported MIME types with a console warning
|
|
54
|
+
- Registers assets in `useMediaLibraryStore` synchronously
|
|
55
|
+
- Generates JPEG thumbnails on the main thread and patches `thumbnailUrl` asynchronously
|
|
56
|
+
|
|
57
|
+
## AssetPanel
|
|
58
|
+
|
|
59
|
+
Browse, drop, and drag media assets from a sidebar panel. Render as a sibling of `<Timeline>` inside `<EditorProvider>`:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { EditorProvider, Timeline, AssetPanel } from '@elah/editor'
|
|
63
|
+
|
|
64
|
+
function App() {
|
|
65
|
+
return (
|
|
66
|
+
<EditorProvider fps={30}>
|
|
67
|
+
<div style={{ display: 'flex', height: '100vh' }}>
|
|
68
|
+
<AssetPanel style={{ width: 220 }} />
|
|
69
|
+
<Timeline style={{ flex: 1 }} />
|
|
70
|
+
</div>
|
|
71
|
+
</EditorProvider>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Add** opens a file picker; **drop** onto the panel imports via `importFiles`
|
|
77
|
+
- Thumbnails appear asynchronously after import
|
|
78
|
+
- Drag a thumbnail onto a timeline track lane to create a clip (see Timeline drop below)
|
|
79
|
+
|
|
80
|
+
## Timeline drop
|
|
81
|
+
|
|
82
|
+
With `<AssetPanel>` and `<Timeline>` as siblings inside `<EditorProvider>`, drag a thumbnail onto any track lane:
|
|
83
|
+
|
|
84
|
+
- Drop position becomes the clip `startFrame` (respects timeline zoom)
|
|
85
|
+
- Clip duration comes from the asset (`durationSec × project.fps`; images default to 5 seconds)
|
|
86
|
+
- Video/image assets go on video tracks; audio on audio tracks
|
|
87
|
+
- When snap is enabled (`usePlaybackStore.snapEnabled`), the drop snaps to the playhead and nearby clip edges
|
|
88
|
+
|
|
89
|
+
No extra wiring beyond `TrackRow` — `useTimelineDrop` is attached automatically per lane.
|
|
90
|
+
|
|
91
|
+
## Render pixels with `<Preview>`
|
|
92
|
+
|
|
93
|
+
`<Preview>` mounts the WebGL2 `GpuRenderer`, drives the RAF loop, and paints
|
|
94
|
+
interactive transform overlays — drag / uniform-scale for video & image clips
|
|
95
|
+
(`MediaTransformOverlay`), and drag / resize / inline-edit for text clips
|
|
96
|
+
(`TextOverlay`) — plus the project's audio.
|
|
97
|
+
Inject a **demuxer factory** so the SDK never hard-depends on a decode backend:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { EditorProvider, Preview, createMediabunnyBackend } from '@elah/editor'
|
|
101
|
+
import * as mediabunny from 'mediabunny'
|
|
102
|
+
|
|
103
|
+
const demuxerFactory = () =>
|
|
104
|
+
createMediabunnyBackend(mediabunny, { blobResolver: (src) => fetch(src).then((r) => r.blob()) })
|
|
105
|
+
|
|
106
|
+
function App() {
|
|
107
|
+
return (
|
|
108
|
+
<EditorProvider fps={30}>
|
|
109
|
+
<Preview demuxerFactory={demuxerFactory} style={{ height: 480 }} />
|
|
110
|
+
</EditorProvider>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Omit `demuxerFactory` for a synthetic dev preview (no media files, no mediabunny).
|
|
116
|
+
For a lower-level renderer handle, `GpuRenderer` is exported directly. See
|
|
117
|
+
[`src/core/renderer/README.md`](src/core/renderer/README.md).
|
|
118
|
+
|
|
119
|
+
## Export to MP4
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { exportVideo } from '@elah/editor'
|
|
123
|
+
|
|
124
|
+
const blob = await exportVideo(engine.getProject(), {
|
|
125
|
+
videoBitrate: 8_000_000,
|
|
126
|
+
onProgress: ({ frame, totalFrames }) => setPct(Math.round((frame / totalFrames) * 100)),
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Runs in a worker; reuses `resolveTimeline` + the renderer's placement math. See
|
|
131
|
+
[`src/core/export/README.md`](src/core/export/README.md).
|
|
132
|
+
|
|
133
|
+
## Package layout
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/
|
|
137
|
+
core/ types, engine, playback, resolver, stores, assets, media, export, debug, actions
|
|
138
|
+
timeline/ Timeline UI + hooks
|
|
139
|
+
editor/ EditorProvider, AssetPanel, Preview, useResolvedScene
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Keyboard shortcuts
|
|
143
|
+
|
|
144
|
+
The `<Timeline>` component registers these global keyboard shortcuts when focused:
|
|
145
|
+
|
|
146
|
+
| Key | Action |
|
|
147
|
+
|---|---|
|
|
148
|
+
| **Space** | Play / pause |
|
|
149
|
+
| **S** | Split selected clip at playhead |
|
|
150
|
+
| **Delete** / **Backspace** | Delete selected clip(s) |
|
|
151
|
+
| **Ctrl/Cmd + C** | Copy selected clip(s) to clipboard |
|
|
152
|
+
| **Ctrl/Cmd + V** | Paste copied clip(s) — placed at current playhead position, same track |
|
|
153
|
+
| **Ctrl/Cmd + Z** | Undo |
|
|
154
|
+
| **Ctrl/Cmd + Shift + Z** / **Ctrl/Cmd + Y** | Redo |
|
|
155
|
+
| **Ctrl/Cmd + scroll** | Zoom in / out |
|
|
156
|
+
| **← / →** | Step one frame back / forward |
|
|
157
|
+
|
|
158
|
+
**Right-click** any clip to open a context menu with a **Delete** option.
|
|
159
|
+
|
|
160
|
+
## Scripts
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm run typecheck # from packages/editor
|
|
164
|
+
npm run test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
To be decided — see root README.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { importFiles, MEDIA_DRAG_MIME, useMediaLibrary, useMediaLibraryStore, } from '@elah/core';
|
|
5
|
+
const KIND_ICONS = {
|
|
6
|
+
video: '▶',
|
|
7
|
+
audio: '♪',
|
|
8
|
+
image: '◻',
|
|
9
|
+
};
|
|
10
|
+
const KIND_TAG = {
|
|
11
|
+
video: { label: 'VIDEO', color: '#93C5FD', bg: 'rgba(37, 99, 235, 0.2)' },
|
|
12
|
+
audio: { label: 'AUDIO', color: '#86EFAC', bg: 'rgba(22, 163, 74, 0.2)' },
|
|
13
|
+
image: { label: 'IMAGE', color: '#FCD34D', bg: 'rgba(245, 158, 11, 0.2)' },
|
|
14
|
+
};
|
|
15
|
+
function formatDuration(sec) {
|
|
16
|
+
if (!Number.isFinite(sec) || sec <= 0)
|
|
17
|
+
return '—';
|
|
18
|
+
const m = Math.floor(sec / 60);
|
|
19
|
+
const s = Math.floor(sec % 60);
|
|
20
|
+
return m > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${s}s`;
|
|
21
|
+
}
|
|
22
|
+
const THUMB_SIZE = 52;
|
|
23
|
+
const TOAST_DISMISS_MS = 3000;
|
|
24
|
+
function formatFileNames(files, maxNames = 3) {
|
|
25
|
+
const names = files.map((file) => file.name);
|
|
26
|
+
if (names.length <= maxNames)
|
|
27
|
+
return names.join(', ');
|
|
28
|
+
const shown = names.slice(0, maxNames).join(', ');
|
|
29
|
+
return `${shown} +${names.length - maxNames} more`;
|
|
30
|
+
}
|
|
31
|
+
function buildImportToast(skipped) {
|
|
32
|
+
if (skipped.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
const duplicates = skipped.filter((entry) => entry.reason === 'duplicate');
|
|
35
|
+
const unsupported = skipped.filter((entry) => entry.reason === 'unsupported');
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (duplicates.length > 0) {
|
|
38
|
+
lines.push(`Skipped ${duplicates.length} duplicate file${duplicates.length === 1 ? '' : 's'}: ${formatFileNames(duplicates.map((entry) => entry.file))}`);
|
|
39
|
+
}
|
|
40
|
+
if (unsupported.length > 0) {
|
|
41
|
+
lines.push(`Skipped ${unsupported.length} unsupported file${unsupported.length === 1 ? '' : 's'}: ${formatFileNames(unsupported.map((entry) => entry.file))}`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
message: lines.join('\n'),
|
|
45
|
+
tone: unsupported.length > 0 ? 'warn' : 'info',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function AssetThumbnail({ asset, onDelete }) {
|
|
49
|
+
const [ctxMenu, setCtxMenu] = useState(null);
|
|
50
|
+
const onDragStart = useCallback((e) => {
|
|
51
|
+
const payload = { kind: 'media-asset', assetId: asset.id };
|
|
52
|
+
e.dataTransfer.setData(MEDIA_DRAG_MIME, JSON.stringify(payload));
|
|
53
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
54
|
+
}, [asset.id]);
|
|
55
|
+
const handleContextMenu = useCallback((e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
59
|
+
}, []);
|
|
60
|
+
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
|
61
|
+
const handleDelete = useCallback(() => {
|
|
62
|
+
onDelete(asset.id);
|
|
63
|
+
setCtxMenu(null);
|
|
64
|
+
}, [asset.id, onDelete]);
|
|
65
|
+
const tag = KIND_TAG[asset.kind];
|
|
66
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { draggable: true, className: "elah-media-card", onDragStart: onDragStart, onContextMenu: handleContextMenu, title: asset.name, style: {
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: 10,
|
|
70
|
+
padding: '8px 10px',
|
|
71
|
+
borderRadius: 8,
|
|
72
|
+
cursor: 'grab',
|
|
73
|
+
userSelect: 'none',
|
|
74
|
+
background: '#171D2B',
|
|
75
|
+
border: '1px solid #232938',
|
|
76
|
+
transition: 'background 0.15s, border-color 0.15s',
|
|
77
|
+
}, children: [_jsx("div", { style: {
|
|
78
|
+
position: 'relative',
|
|
79
|
+
width: THUMB_SIZE,
|
|
80
|
+
height: THUMB_SIZE,
|
|
81
|
+
flexShrink: 0,
|
|
82
|
+
background: '#06070A',
|
|
83
|
+
borderRadius: 6,
|
|
84
|
+
border: '1px solid #1A1F2B',
|
|
85
|
+
overflow: 'hidden',
|
|
86
|
+
}, children: asset.thumbnailUrl ? (_jsx("img", { src: asset.thumbnailUrl, alt: "", draggable: false, style: {
|
|
87
|
+
width: '100%',
|
|
88
|
+
height: '100%',
|
|
89
|
+
objectFit: 'cover',
|
|
90
|
+
display: 'block',
|
|
91
|
+
} })) : (_jsx("div", { style: {
|
|
92
|
+
width: '100%',
|
|
93
|
+
height: '100%',
|
|
94
|
+
display: 'flex',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
fontSize: 20,
|
|
98
|
+
color: '#555',
|
|
99
|
+
}, children: KIND_ICONS[asset.kind] })) }), _jsxs("div", { style: {
|
|
100
|
+
display: 'flex',
|
|
101
|
+
flexDirection: 'column',
|
|
102
|
+
gap: 3,
|
|
103
|
+
minWidth: 0,
|
|
104
|
+
}, children: [_jsx("span", { style: {
|
|
105
|
+
fontSize: 11,
|
|
106
|
+
color: '#F3F4F6',
|
|
107
|
+
overflow: 'hidden',
|
|
108
|
+
textOverflow: 'ellipsis',
|
|
109
|
+
whiteSpace: 'nowrap',
|
|
110
|
+
}, children: asset.name }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
|
|
111
|
+
fontSize: 8,
|
|
112
|
+
fontWeight: 700,
|
|
113
|
+
letterSpacing: '0.06em',
|
|
114
|
+
padding: '2px 5px',
|
|
115
|
+
borderRadius: 3,
|
|
116
|
+
color: tag.color,
|
|
117
|
+
background: tag.bg,
|
|
118
|
+
}, children: tag.label }), _jsx("span", { style: { fontSize: 10, color: '#6B7280', fontFamily: 'ui-monospace, monospace' }, children: formatDuration(asset.durationSec) })] })] })] }), ctxMenu && createPortal(_jsxs(_Fragment, { children: [_jsx("div", { style: { position: 'fixed', inset: 0, zIndex: 9998 }, onMouseDown: closeCtxMenu }), _jsx("div", { style: {
|
|
119
|
+
position: 'fixed',
|
|
120
|
+
top: ctxMenu.y,
|
|
121
|
+
left: ctxMenu.x,
|
|
122
|
+
zIndex: 9999,
|
|
123
|
+
background: '#1E2433',
|
|
124
|
+
border: '1px solid #2D3548',
|
|
125
|
+
borderRadius: 6,
|
|
126
|
+
padding: '4px 0',
|
|
127
|
+
minWidth: 140,
|
|
128
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
|
129
|
+
fontFamily: 'sans-serif',
|
|
130
|
+
}, children: _jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleDelete, style: {
|
|
131
|
+
display: 'block',
|
|
132
|
+
width: '100%',
|
|
133
|
+
padding: '7px 14px',
|
|
134
|
+
textAlign: 'left',
|
|
135
|
+
background: 'none',
|
|
136
|
+
border: 'none',
|
|
137
|
+
color: '#FF6B6B',
|
|
138
|
+
fontSize: 13,
|
|
139
|
+
cursor: 'pointer',
|
|
140
|
+
letterSpacing: '0.01em',
|
|
141
|
+
}, onMouseEnter: (e) => {
|
|
142
|
+
;
|
|
143
|
+
e.currentTarget.style.background = 'rgba(255,107,107,0.12)';
|
|
144
|
+
}, onMouseLeave: (e) => {
|
|
145
|
+
;
|
|
146
|
+
e.currentTarget.style.background = 'none';
|
|
147
|
+
}, children: "Delete" }) })] }), document.body)] }));
|
|
148
|
+
}
|
|
149
|
+
export function AssetPanel({ style, className }) {
|
|
150
|
+
const { assets } = useMediaLibrary();
|
|
151
|
+
const removeAsset = useMediaLibraryStore((s) => s.removeAsset);
|
|
152
|
+
const fileInputRef = useRef(null);
|
|
153
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
154
|
+
const [importing, setImporting] = useState(false);
|
|
155
|
+
const [toast, setToast] = useState(null);
|
|
156
|
+
const handleDeleteAsset = useCallback((id) => {
|
|
157
|
+
removeAsset(id);
|
|
158
|
+
}, [removeAsset]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!toast)
|
|
161
|
+
return;
|
|
162
|
+
const timer = globalThis.setTimeout(() => setToast(null), TOAST_DISMISS_MS);
|
|
163
|
+
return () => globalThis.clearTimeout(timer);
|
|
164
|
+
}, [toast]);
|
|
165
|
+
const handleFiles = useCallback(async (files) => {
|
|
166
|
+
const list = Array.from(files);
|
|
167
|
+
if (list.length === 0)
|
|
168
|
+
return;
|
|
169
|
+
setImporting(true);
|
|
170
|
+
try {
|
|
171
|
+
const { skipped } = await importFiles(list);
|
|
172
|
+
setToast(buildImportToast(skipped));
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
setImporting(false);
|
|
176
|
+
}
|
|
177
|
+
}, []);
|
|
178
|
+
const onBrowseClick = useCallback(() => {
|
|
179
|
+
fileInputRef.current?.click();
|
|
180
|
+
}, []);
|
|
181
|
+
const onFileInputChange = useCallback((e) => {
|
|
182
|
+
const files = e.target.files;
|
|
183
|
+
if (files && files.length > 0) {
|
|
184
|
+
void handleFiles(files);
|
|
185
|
+
}
|
|
186
|
+
e.target.value = '';
|
|
187
|
+
}, [handleFiles]);
|
|
188
|
+
const onDragOver = useCallback((e) => {
|
|
189
|
+
if (e.dataTransfer.types.includes('Files')) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
192
|
+
setIsDragOver(true);
|
|
193
|
+
}
|
|
194
|
+
}, []);
|
|
195
|
+
const onDragLeave = useCallback((e) => {
|
|
196
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
197
|
+
setIsDragOver(false);
|
|
198
|
+
}
|
|
199
|
+
}, []);
|
|
200
|
+
const onDrop = useCallback((e) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
setIsDragOver(false);
|
|
203
|
+
const files = e.dataTransfer.files;
|
|
204
|
+
if (files.length > 0) {
|
|
205
|
+
void handleFiles(files);
|
|
206
|
+
}
|
|
207
|
+
}, [handleFiles]);
|
|
208
|
+
return (_jsxs("div", { className: className, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, style: {
|
|
209
|
+
display: 'flex',
|
|
210
|
+
flexDirection: 'column',
|
|
211
|
+
height: '100%',
|
|
212
|
+
background: 'transparent',
|
|
213
|
+
borderRight: 'none',
|
|
214
|
+
...style,
|
|
215
|
+
}, children: [_jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: "video/*,audio/*,image/*", style: { display: 'none' }, onChange: onFileInputChange, "data-testid": "asset-file-input" }), _jsxs("div", { style: {
|
|
216
|
+
display: 'flex',
|
|
217
|
+
alignItems: 'center',
|
|
218
|
+
justifyContent: 'space-between',
|
|
219
|
+
padding: '10px 12px',
|
|
220
|
+
borderBottom: '1px solid #232938',
|
|
221
|
+
flexShrink: 0,
|
|
222
|
+
}, children: [_jsx("span", { style: { fontSize: 10, fontWeight: 700, color: '#6B7280', letterSpacing: '0.08em' }, children: "MEDIA" }), _jsx("button", { type: "button", onClick: onBrowseClick, disabled: importing, style: {
|
|
223
|
+
padding: '4px 12px',
|
|
224
|
+
fontSize: 11,
|
|
225
|
+
fontWeight: 600,
|
|
226
|
+
background: importing ? '#121722' : '#171D2B',
|
|
227
|
+
color: importing ? '#6B7280' : '#E11D48',
|
|
228
|
+
border: '1px solid #232938',
|
|
229
|
+
borderRadius: 6,
|
|
230
|
+
cursor: importing ? 'wait' : 'pointer',
|
|
231
|
+
}, children: importing ? '…' : '+ Add' })] }), _jsxs("div", { style: {
|
|
232
|
+
flex: 1,
|
|
233
|
+
overflow: 'auto',
|
|
234
|
+
padding: 8,
|
|
235
|
+
outline: isDragOver ? '2px dashed #E11D48' : 'none',
|
|
236
|
+
outlineOffset: -4,
|
|
237
|
+
borderRadius: 4,
|
|
238
|
+
position: 'relative',
|
|
239
|
+
}, children: [toast && (_jsx("div", { role: "status", style: {
|
|
240
|
+
position: 'absolute',
|
|
241
|
+
top: 8,
|
|
242
|
+
left: 8,
|
|
243
|
+
right: 8,
|
|
244
|
+
zIndex: 2,
|
|
245
|
+
padding: '8px 10px',
|
|
246
|
+
borderRadius: 6,
|
|
247
|
+
fontSize: 10,
|
|
248
|
+
fontFamily: 'monospace',
|
|
249
|
+
lineHeight: 1.4,
|
|
250
|
+
whiteSpace: 'pre-line',
|
|
251
|
+
color: toast.tone === 'warn' ? '#f5d0a9' : '#c8d8f0',
|
|
252
|
+
background: toast.tone === 'warn' ? '#3a2418' : '#1a2433',
|
|
253
|
+
border: `1px solid ${toast.tone === 'warn' ? '#7a4a2a' : '#355070'}`,
|
|
254
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.35)',
|
|
255
|
+
}, children: toast.message })), assets.length === 0 ? (_jsxs("div", { style: {
|
|
256
|
+
display: 'flex',
|
|
257
|
+
flexDirection: 'column',
|
|
258
|
+
alignItems: 'center',
|
|
259
|
+
justifyContent: 'center',
|
|
260
|
+
minHeight: 120,
|
|
261
|
+
padding: 16,
|
|
262
|
+
textAlign: 'center',
|
|
263
|
+
color: '#6B7280',
|
|
264
|
+
fontSize: 11,
|
|
265
|
+
border: '1px dashed #232938',
|
|
266
|
+
borderRadius: 8,
|
|
267
|
+
}, children: [_jsx("span", { style: { marginBottom: 8, fontSize: 24, opacity: 0.5 }, children: "\u2193" }), "Drop files here", _jsx("br", {}), "or click Add"] })) : (_jsx("div", { style: {
|
|
268
|
+
display: 'flex',
|
|
269
|
+
flexDirection: 'column',
|
|
270
|
+
gap: 6,
|
|
271
|
+
}, children: assets.map((asset) => (_jsx(AssetThumbnail, { asset: asset, onDelete: handleDeleteAsset }, asset.id))) }))] })] }));
|
|
272
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AssetPanel } from './AssetPanel';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { InitialTrackConfig } from '@elah/core';
|
|
3
|
+
export interface EditorProviderProps {
|
|
4
|
+
fps: number;
|
|
5
|
+
stage?: {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
defaultTrackHeight?: number;
|
|
10
|
+
maxHistorySize?: number;
|
|
11
|
+
initialTracks?: InitialTrackConfig[];
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
export declare function EditorProvider({ fps, stage, defaultTrackHeight, maxHistorySize, initialTracks, children, }: EditorProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo } from 'react';
|
|
3
|
+
import { TimelineEngine, PlaybackEngine, useTracksStore, usePlaybackStore, useTransitionsStore, EditorContext, installTraceGlobal, trace, } from '@elah/core';
|
|
4
|
+
export function EditorProvider({ fps, stage, defaultTrackHeight, maxHistorySize, initialTracks, children, }) {
|
|
5
|
+
const engine = useMemo(() => new TimelineEngine({
|
|
6
|
+
fps,
|
|
7
|
+
stage,
|
|
8
|
+
defaultTrackHeight,
|
|
9
|
+
maxHistorySize,
|
|
10
|
+
initialTracks,
|
|
11
|
+
}), []);
|
|
12
|
+
const playback = useMemo(() => new PlaybackEngine({
|
|
13
|
+
fps,
|
|
14
|
+
getTotalFrames: () => useTracksStore.getState().totalFrames,
|
|
15
|
+
}), []);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const syncAll = () => {
|
|
18
|
+
const project = engine.getProject();
|
|
19
|
+
useTracksStore.getState().sync(project, {
|
|
20
|
+
canUndo: engine.canUndo(),
|
|
21
|
+
canRedo: engine.canRedo(),
|
|
22
|
+
});
|
|
23
|
+
useTransitionsStore.getState().sync(project);
|
|
24
|
+
};
|
|
25
|
+
engine.on('change', syncAll);
|
|
26
|
+
engine.on('history:change', syncAll);
|
|
27
|
+
syncAll();
|
|
28
|
+
return () => {
|
|
29
|
+
engine.off('change', syncAll);
|
|
30
|
+
engine.off('history:change', syncAll);
|
|
31
|
+
};
|
|
32
|
+
}, [engine]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
return playback.subscribe((snapshot) => {
|
|
35
|
+
const pb = usePlaybackStore.getState();
|
|
36
|
+
if (snapshot.currentFrame !== pb.currentFrame) {
|
|
37
|
+
pb.setCurrentFrame(snapshot.currentFrame);
|
|
38
|
+
}
|
|
39
|
+
if (snapshot.isPlaying && !pb.isPlaying)
|
|
40
|
+
pb.play();
|
|
41
|
+
else if (!snapshot.isPlaying && pb.isPlaying)
|
|
42
|
+
pb.pause();
|
|
43
|
+
});
|
|
44
|
+
}, [playback]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
installTraceGlobal();
|
|
47
|
+
const s0 = usePlaybackStore.getState();
|
|
48
|
+
playback.setPlaybackRate(s0.playbackRate);
|
|
49
|
+
playback.setLoop(s0.loop);
|
|
50
|
+
if (s0.isPlaying)
|
|
51
|
+
playback.play();
|
|
52
|
+
return usePlaybackStore.subscribe((state, prev) => {
|
|
53
|
+
if (state.isPlaying !== prev.isPlaying) {
|
|
54
|
+
if (state.isPlaying)
|
|
55
|
+
playback.play();
|
|
56
|
+
else
|
|
57
|
+
playback.pause();
|
|
58
|
+
}
|
|
59
|
+
if (state.currentFrameEpoch !== prev.currentFrameEpoch) {
|
|
60
|
+
const willSeek = state.currentFrame !== playback.currentFrame;
|
|
61
|
+
trace('SEEK_GATE', {
|
|
62
|
+
storeFrame: state.currentFrame,
|
|
63
|
+
engineFrame: playback.currentFrame,
|
|
64
|
+
willSeek,
|
|
65
|
+
});
|
|
66
|
+
if (willSeek)
|
|
67
|
+
playback.seek(state.currentFrame);
|
|
68
|
+
}
|
|
69
|
+
if (state.playbackRate !== prev.playbackRate) {
|
|
70
|
+
playback.setPlaybackRate(state.playbackRate);
|
|
71
|
+
}
|
|
72
|
+
if (state.loop !== prev.loop) {
|
|
73
|
+
playback.setLoop(state.loop);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}, [playback]);
|
|
77
|
+
useEffect(() => () => playback.destroy(), [playback]);
|
|
78
|
+
const value = useMemo(() => ({ engine, playback }), [engine, playback]);
|
|
79
|
+
return (_jsx(EditorContext.Provider, { value: value, children: children }));
|
|
80
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { ELEMENT_DRAG_MIME } from '@elah/timeline';
|
|
4
|
+
export function ElementsPanel({ style, className }) {
|
|
5
|
+
const onTextDragStart = useCallback((e) => {
|
|
6
|
+
const payload = { kind: 'element', element: 'text' };
|
|
7
|
+
e.dataTransfer.setData(ELEMENT_DRAG_MIME, JSON.stringify(payload));
|
|
8
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
9
|
+
}, []);
|
|
10
|
+
return (_jsxs("div", { className: className, style: {
|
|
11
|
+
display: 'flex',
|
|
12
|
+
flexDirection: 'column',
|
|
13
|
+
background: 'transparent',
|
|
14
|
+
borderBottom: '1px solid #232938',
|
|
15
|
+
...style,
|
|
16
|
+
}, children: [_jsx("div", { style: {
|
|
17
|
+
padding: '10px 12px',
|
|
18
|
+
borderBottom: '1px solid #232938',
|
|
19
|
+
flexShrink: 0,
|
|
20
|
+
}, children: _jsx("span", { style: {
|
|
21
|
+
fontSize: 10,
|
|
22
|
+
fontWeight: 700,
|
|
23
|
+
color: '#6B7280',
|
|
24
|
+
letterSpacing: '0.08em',
|
|
25
|
+
}, children: "ELEMENTS" }) }), _jsx("div", { style: { padding: 10 }, children: _jsxs("div", { draggable: true, className: "elah-element-card", onDragStart: onTextDragStart, title: "Drag onto the Text track", style: {
|
|
26
|
+
display: 'flex',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: 10,
|
|
29
|
+
padding: '10px 12px',
|
|
30
|
+
borderRadius: 8,
|
|
31
|
+
cursor: 'grab',
|
|
32
|
+
userSelect: 'none',
|
|
33
|
+
background: '#171D2B',
|
|
34
|
+
border: '1px solid #232938',
|
|
35
|
+
transition: 'background 0.15s, border-color 0.15s',
|
|
36
|
+
}, children: [_jsx("span", { style: {
|
|
37
|
+
width: 32,
|
|
38
|
+
height: 32,
|
|
39
|
+
flexShrink: 0,
|
|
40
|
+
display: 'flex',
|
|
41
|
+
alignItems: 'center',
|
|
42
|
+
justifyContent: 'center',
|
|
43
|
+
borderRadius: 6,
|
|
44
|
+
background: 'rgba(147, 51, 234, 0.25)',
|
|
45
|
+
border: '1px solid rgba(147, 51, 234, 0.4)',
|
|
46
|
+
color: '#C4B5FD',
|
|
47
|
+
fontWeight: 700,
|
|
48
|
+
fontFamily: 'Georgia, serif',
|
|
49
|
+
fontSize: 17,
|
|
50
|
+
}, children: "T" }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }, children: [_jsx("span", { style: { fontSize: 12, color: '#F3F4F6', fontWeight: 500 }, children: "Text" }), _jsx("span", { style: { fontSize: 10, color: '#6B7280' }, children: "+ Drag onto timeline" })] })] }) })] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ElementsPanel } from './ElementsPanel';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function MediaTransformOverlay(): import("react/jsx-runtime").JSX.Element;
|