@elah/editor 0.4.0 → 0.6.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 CHANGED
@@ -1,177 +1,240 @@
1
- # @elah/editor
2
-
3
- The full Elah video editor SDK for React. Combines the core engine, timeline UI, WebGL2 renderer, media library, and export pipeline into a single package.
4
-
5
- Ships `EditorProvider`, `Preview` (WebGL2 canvas + interactive transform overlays), `Timeline`, `AssetPanel`, `SourcePanel`, and `ElementsPanel`, and re-exports the entire `@elah/core` and `@elah/timeline` API. Supports video, image, text, **shape**, and **freehand** clips, **multi-track audio**, and MP4 export.
6
-
7
- [![npm](https://img.shields.io/npm/v/@elah/editor)](https://www.npmjs.com/package/@elah/editor)
8
- [![gzip size](https://img.shields.io/badge/gzip-~63%20KiB%20full%20SDK-brightgreen)](../../BUNDLE_STRATEGY.md)
9
- [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/elahlabs/elah/blob/main/LICENSE)
10
-
11
- ---
12
-
13
- ## Install
14
-
15
- ```bash
16
- npm install @elah/editor
17
- ```
18
-
19
- Peer dependencies: `react`, `react-dom` >= 18, `lucide-react` >= 0.400.0 (used by the
20
- bundled `@elah/timeline` UI for clip icons — install it alongside `react`/`react-dom`
21
- even though nothing in your own code imports it).
22
-
23
- **Bundle size:** ~10 KiB gzipped for the editor layer (51 KiB raw); ~63 KiB gzipped for the full SDK graph (`core` + `timeline` + `editor`, 330 KiB raw). `mediabunny` is injected, never bundled — see [BUNDLE_STRATEGY.md](../../BUNDLE_STRATEGY.md).
24
-
25
- ---
26
-
27
- ## Quick start
28
-
29
- ```tsx
30
- import { EditorProvider, Timeline } from '@elah/editor'
31
-
32
- function App() {
33
- return (
34
- <EditorProvider fps={30}>
35
- <Timeline style={{ height: 300 }} />
36
- </EditorProvider>
37
- )
38
- }
39
- ```
40
-
41
- ---
42
-
43
- ## Styling
44
-
45
- Import the compiled stylesheets once. They are plain CSS — your app does **not**
46
- need Tailwind, and no utility class names leak into your global scope (preflight
47
- is disabled, so nothing resets your elements):
48
-
49
- ```ts
50
- import '@elah/timeline/styles.css'
51
- import '@elah/editor/styles.css'
52
- import '@elah/editor/styles/tokens.css' // --elah-* dark defaults (standalone)
53
- ```
54
-
55
- When embedding inside an app that already defines `.elah-root` (mapping `--elah-*`
56
- onto its own design system), skip `tokens.css`. Re-theme or white-label by
57
- overriding `--elah-*` variables in your own `.elah-root` scope — see
58
- [design-tokens.md](https://github.com/elahlabs/elah/blob/main/docs/design-tokens.md).
59
-
60
- ---
61
-
62
- ## With preview and asset panel
63
-
64
- ```tsx
65
- import { EditorProvider, Timeline, Preview, AssetPanel, createDefaultDemuxerFactory } from '@elah/editor'
66
-
67
- const demuxerFactory = createDefaultDemuxerFactory()
68
-
69
- function App() {
70
- return (
71
- <EditorProvider fps={30}>
72
- <div style={{ display: 'flex', height: '100vh' }}>
73
- <AssetPanel style={{ width: 220 }} />
74
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
75
- <Preview demuxerFactory={demuxerFactory} style={{ flex: 1 }} />
76
- <Timeline style={{ height: 300 }} />
77
- </div>
78
- </div>
79
- </EditorProvider>
80
- )
81
- }
82
- ```
83
-
84
- ---
85
-
86
- ## Import media
87
-
88
- ```ts
89
- import { importFiles, importUrl, useMediaLibrary } from '@elah/editor'
90
-
91
- await importFiles(Array.from(fileList)) // local files
92
- await importUrl('https://example.com/clip.mp4') // remote URL (also importBlob for blobs)
93
-
94
- // Subscribe in React — useMediaLibrary() takes no arguments and returns
95
- // { assets, getAsset, removeAsset, updateAsset } with assets in insertion order.
96
- const { assets } = useMediaLibrary()
97
- ```
98
-
99
- ### Programmatic insertion (no drag)
100
-
101
- ```ts
102
- import { insertMediaAsset } from '@elah/editor'
103
-
104
- // Place an imported asset onto the timeline — powers tap-to-add on touch.
105
- // Returns a typed InsertAssetResult ({ ok, kind, trackId, clipIds } | { ok:false, reason }).
106
- const result = await insertMediaAsset(engine, assetId, { desiredStartFrame: 0 })
107
- ```
108
-
109
- ---
110
-
111
- ## Export to MP4
112
-
113
- ```ts
114
- import { exportVideo } from '@elah/editor'
115
-
116
- const blob = await exportVideo(engine.getProject(), {
117
- videoBitrate: 8_000_000,
118
- onProgress: ({ frame, totalFrames }) => {
119
- console.log(`${Math.round((frame / totalFrames) * 100)}%`)
120
- },
121
- })
122
- ```
123
-
124
- Runs in a web worker. Reuses `resolveTimeline` + the GPU renderer's placement math.
125
-
126
- ---
127
-
128
- ## Keyboard shortcuts
129
-
130
- | Key | Action |
131
- |---|---|
132
- | `Space` | Play / pause |
133
- | `S` | Split clip at playhead |
134
- | `Delete` / `Backspace` | Delete selected clip(s) |
135
- | `Ctrl/Cmd + C` | Copy |
136
- | `Ctrl/Cmd + V` | Paste at playhead |
137
- | `Ctrl/Cmd + Z` | Undo |
138
- | `Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` | Redo |
139
- | `Ctrl/Cmd + scroll` | Zoom |
140
- | `← / →` | Step one frame |
141
-
142
- ---
143
-
144
- ## Package layers
145
-
146
- ```
147
- @elah/core — engine, playback, resolver, vanilla stores, media, export (framework-agnostic)
148
- @elah/react — React bindings: EditorContext, store hooks, audio hooks
149
- @elah/timeline — React timeline UI components and hooks (consumes @elah/core + @elah/react)
150
- @elah/editor — EditorProvider, Preview, AssetPanel + re-exports @elah/core, @elah/react, @elah/timeline
151
- @elah/cli — headless split/trim/build/export/serve on top of @elah/core
152
- ```
153
-
154
- Use `@elah/editor` for the full in-browser experience. Use `@elah/core` directly
155
- for headless or custom rendering pipelines, or pair it with `@elah/react` if you
156
- want the hooks without the timeline UI. Use `@elah/cli` for automation,
157
- AI-generation pipelines, and server-side rendering.
158
-
159
- **One active project per page.** `@elah/core`'s stores (`tracksStore`,
160
- `playbackStore`, `selectionStore`, `transitionsStore`, `mediaLibraryStore`) are
161
- module-level singletons, and `<EditorProvider>` wires the `TimelineEngine` /
162
- `PlaybackEngine` it creates into those same shared stores. Mounting a second
163
- `<EditorProvider>` on the same page (two independent projects at once) will have
164
- both instances overwrite each other's state — there is currently no per-instance
165
- scoping. If you need multiple concurrent editors, isolate each in its own
166
- tab/window/iframe.
167
-
168
- ---
169
-
170
- ## Links
171
-
172
- - [Website](https://www.elah.dev)
173
- - [GitHub](https://github.com/elahlabs/elah)
174
- - [Discord](https://discord.gg/8CeZ2XbPy)
175
- - [Headless CLI — @elah/cli](https://www.npmjs.com/package/@elah/cli)
176
- - [License](https://github.com/elahlabs/elah/blob/main/LICENSE)
177
- - [Commercial licensing](mailto:paul@elah.dev)
1
+ # @elah/editor
2
+
3
+ The full Elah video editor SDK for React. Combines the core engine, timeline UI, WebGL2 renderer, media library, and export pipeline into a single package.
4
+
5
+ Ships `EditorProvider`, `Preview` (WebGL2 canvas + interactive transform overlays), `Timeline`, `AssetPanel`, `SourcePanel`, and `ElementsPanel`, and re-exports the public `@elah/core`, `@elah/react`, and `@elah/timeline` API — so most apps only ever import from `@elah/editor`. (Renderer and debug internals are the exception; import those from `@elah/core` directly.) Supports video, image, text, **shape**, and **freehand** clips, **multiple video tracks**, **multi-track audio**, and MP4 export.
6
+
7
+ [![npm](https://img.shields.io/npm/v/@elah/editor)](https://www.npmjs.com/package/@elah/editor)
8
+ [![gzip size](https://img.shields.io/badge/gzip-~64%20KiB%20SDK-brightgreen)](../../BUNDLE_STRATEGY.md)
9
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/elahlabs/elah/blob/main/LICENSE)
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @elah/editor
17
+ ```
18
+
19
+ Peer dependencies: `react`, `react-dom` >= 18, `lucide-react` >= 0.400.0 (used by the
20
+ bundled `@elah/timeline` UI for clip icons — install it alongside `react`/`react-dom`
21
+ even though nothing in your own code imports it).
22
+
23
+ **Bundle size:** ~19 KiB gzipped for the editor layer; an app using the full SDK (`core` + `react` + `timeline` + `editor`) starts at ~64 KiB gzipped with the demuxer injected or omitted, and ~227 KiB if it calls `createDefaultDemuxerFactory()`, which pulls `mediabunny` (159 KiB) into the startup graph. Measured with code splitting — see [BUNDLE_STRATEGY.md](../../BUNDLE_STRATEGY.md).
24
+
25
+ ---
26
+
27
+ ## Quick start
28
+
29
+ ```tsx
30
+ import { EditorProvider, Timeline } from '@elah/editor'
31
+
32
+ function App() {
33
+ return (
34
+ <EditorProvider fps={30}>
35
+ <Timeline style={{ height: 300 }} />
36
+ </EditorProvider>
37
+ )
38
+ }
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Styling
44
+
45
+ Import the compiled stylesheets once. They are plain CSS — your app does **not**
46
+ need Tailwind, and no utility class names leak into your global scope (preflight
47
+ is disabled, so nothing resets your elements):
48
+
49
+ ```ts
50
+ import '@elah/timeline/styles.css'
51
+ import '@elah/editor/styles.css'
52
+ import '@elah/editor/styles/tokens.css' // --elah-* dark defaults (standalone)
53
+ ```
54
+
55
+ When embedding inside an app that already defines `.elah-root` (mapping `--elah-*`
56
+ onto its own design system), skip `tokens.css`. Re-theme or white-label by
57
+ overriding `--elah-*` variables in your own `.elah-root` scope — see
58
+ [design-tokens.md](https://github.com/elahlabs/elah/blob/main/docs/design-tokens.md).
59
+
60
+ ---
61
+
62
+ ## With preview and asset panel
63
+
64
+ ```tsx
65
+ import { EditorProvider, Timeline, Preview, AssetPanel, createDefaultDemuxerFactory } from '@elah/editor'
66
+
67
+ const demuxerFactory = createDefaultDemuxerFactory()
68
+
69
+ function App() {
70
+ return (
71
+ <EditorProvider fps={30}>
72
+ <div style={{ display: 'flex', height: '100vh' }}>
73
+ <AssetPanel style={{ width: 220 }} />
74
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
75
+ <Preview demuxerFactory={demuxerFactory} style={{ flex: 1 }} />
76
+ <Timeline style={{ height: 300 }} />
77
+ </div>
78
+ </div>
79
+ </EditorProvider>
80
+ )
81
+ }
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Import media
87
+
88
+ ```ts
89
+ import { importFiles, importUrl, importBlob, beginImportUrl, useMediaLibrary } from '@elah/editor'
90
+
91
+ await importFiles(Array.from(fileList)) // local files
92
+ await importUrl('https://example.com/clip.mp4') // remote URL (waits for metadata)
93
+ const pending = await beginImportUrl('https://example.com/clip.mp4') // returns at once, status: 'pending'
94
+ await importBlob(recordedBlob, { name: 'take-1.webm' })
95
+
96
+ // Subscribe in React — useMediaLibrary() takes no arguments and returns
97
+ // { assets, getAsset, removeAsset, updateAsset, importFiles, importUrl, importBlob }
98
+ // with assets in insertion order. `useAssets` is an alias for the same hook.
99
+ const { assets, importFiles: addFiles } = useMediaLibrary()
100
+ ```
101
+
102
+ `beginImportUrl` is for "add to timeline" from a remote gallery: place a clip right away
103
+ and let it resize itself once the real duration arrives. Pair it with
104
+ `growClipToAssetDuration` (see [`@elah/timeline`](https://www.npmjs.com/package/@elah/timeline)).
105
+ `determineAssetHasAudio` answers whether a video has an audio track.
106
+
107
+ ### Programmatic insertion (no drag)
108
+
109
+ ```ts
110
+ import { insertMediaAsset } from '@elah/editor'
111
+
112
+ // Place an imported asset onto the timeline — powers tap-to-add on touch.
113
+ // Returns a typed InsertAssetResult ({ ok, kind, trackId, clipIds } | { ok:false, reason }).
114
+ const result = await insertMediaAsset(engine, assetId, { desiredStartFrame: 0 })
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Multiple video tracks
120
+
121
+ `engine.addTrack('video')` always adds a lane. Video tracks composite in track order
122
+ (topmost lane draws on top) and a new one lands directly below the last video track.
123
+
124
+ ---
125
+
126
+ ## Preview overlays, crop and speed
127
+
128
+ `<Preview>` paints the interactive overlays for you. **Text** clips: drag, resize, a rotate knob
129
+ (Shift snaps to 15°) and inline edit. **Video and image** clips (`MediaTransformOverlay`): drag,
130
+ 8 resize handles with non-uniform scale (Shift keeps the aspect ratio), and a Resize/Crop toggle
131
+ that edits `Clip.crop`. There is no rotate handle for video or image clips; the box tilts with
132
+ `transform.rotation`, which you set through the engine. Clip speed (`engine.setClipSpeed`,
133
+ 0.25–4) and `Clip.cornerRadius` are engine-level fields.
134
+
135
+ ---
136
+
137
+ ## Text templates and frame sequences
138
+
139
+ ```ts
140
+ import { BUILT_IN_TEXT_TEMPLATES, applyTextTemplate, createFrameSequence, frameSequenceToProject } from '@elah/editor'
141
+
142
+ engine.updateClip(clip.id, clip.trackId, applyTextTemplate(BUILT_IN_TEXT_TEMPLATES[0], clip)) // one undo entry
143
+ ```
144
+
145
+ 14 built-in looks with layered entry/exit motion (`MotionSpec`). A template's `stagger` and
146
+ `tracking` are data that `applyTextTemplate` ignores, and rotation motion has no effect on shape
147
+ clips. Transitions are `fade`, `slide` and `wipe` (`slide` goes left only for `direction: 'left'`,
148
+ `wipe` ignores direction). Frame sequences, `snapshotMediaLibrary` / `hydrateMediaLibrary` and the
149
+ rest are documented in [`@elah/core`](https://www.npmjs.com/package/@elah/core); everything is
150
+ re-exported from this package.
151
+
152
+ ---
153
+
154
+ ## Save and restore
155
+
156
+ ```ts
157
+ import { serializeProject, deserializeProject } from '@elah/editor'
158
+
159
+ localStorage.setItem('project', serializeProject(engine))
160
+ deserializeProject(engine, localStorage.getItem('project')!) // throws, engine untouched, if unreadable
161
+ ```
162
+
163
+ For control over the refusal, read first, then load:
164
+ `engine.loadProject(readProjectDocument(JSON.parse(json)))`. `readProjectDocument` throws
165
+ `ProjectDocumentError` (`'unreadable'` or `'unsupported-version'`). Files imported from
166
+ disk are `blob:` URLs that do not survive a reload; after your media library is rebuilt,
167
+ `relinkProjectMedia(project, assets)` re-points clips at it and lists what is `missing`
168
+ (never dropped). Load its result with `{ transport: 'keep', history: 'keep' }` so the
169
+ playhead and undo stack stay put. See [`@elah/core`](https://www.npmjs.com/package/@elah/core).
170
+
171
+ ---
172
+
173
+ ## Export to MP4
174
+
175
+ ```ts
176
+ import { exportVideo } from '@elah/editor'
177
+
178
+ const blob = await exportVideo(engine.getProject(), {
179
+ videoBitrate: 8_000_000,
180
+ outputHeight: 1080, // the stage's short edge: 1920×1080 landscape or 1080×1920 portrait
181
+ onProgress: ({ frame, totalFrames }) => {
182
+ console.log(`${Math.round((frame / totalFrames) * 100)}%`)
183
+ },
184
+ })
185
+ ```
186
+
187
+ Runs in a web worker. Reuses `resolveTimeline` + the GPU renderer's placement math.
188
+
189
+ ---
190
+
191
+ ## Keyboard shortcuts
192
+
193
+ | Key | Action |
194
+ |---|---|
195
+ | `Space` | Play / pause |
196
+ | `S` | Split clip at playhead |
197
+ | `Delete` / `Backspace` | Delete selected clip(s) |
198
+ | `Ctrl/Cmd + C` | Copy |
199
+ | `Ctrl/Cmd + V` | Paste at playhead |
200
+ | `Ctrl/Cmd + Z` | Undo |
201
+ | `Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` | Redo |
202
+ | `Ctrl/Cmd + scroll` | Zoom |
203
+ | `← / →` | Step one frame |
204
+
205
+ ---
206
+
207
+ ## Package layers
208
+
209
+ ```
210
+ @elah/core — engine, playback, resolver, vanilla stores, media, export (framework-agnostic)
211
+ @elah/react — React bindings: EditorContext, store hooks, audio hooks
212
+ @elah/timeline — React timeline UI components and hooks (consumes @elah/core + @elah/react)
213
+ @elah/editor — EditorProvider, Preview, AssetPanel + re-exports @elah/core, @elah/react, @elah/timeline
214
+ @elah/cli — headless split/trim/build/export/serve on top of @elah/core
215
+ ```
216
+
217
+ Use `@elah/editor` for the full in-browser experience. Use `@elah/core` directly
218
+ for headless or custom rendering pipelines, or pair it with `@elah/react` if you
219
+ want the hooks without the timeline UI. Use `@elah/cli` for automation,
220
+ AI-generation pipelines, and server-side rendering.
221
+
222
+ **One active project per page.** `@elah/core`'s stores (`tracksStore`,
223
+ `playbackStore`, `selectionStore`, `transitionsStore`, `mediaLibraryStore`, `textStylePresetsStore`, `clipLoadStore`) are
224
+ module-level singletons, and `<EditorProvider>` wires the `TimelineEngine` /
225
+ `PlaybackEngine` it creates into those same shared stores. Mounting a second
226
+ `<EditorProvider>` on the same page (two independent projects at once) will have
227
+ both instances overwrite each other's state — there is currently no per-instance
228
+ scoping. If you need multiple concurrent editors, isolate each in its own
229
+ tab/window/iframe.
230
+
231
+ ---
232
+
233
+ ## Links
234
+
235
+ - [Website](https://www.elah.dev)
236
+ - [GitHub](https://github.com/elahlabs/elah)
237
+ - [Discord](https://discord.gg/qKJXvc4Pwu)
238
+ - [Headless CLI — @elah/cli](https://www.npmjs.com/package/@elah/cli)
239
+ - [License](https://github.com/elahlabs/elah/blob/main/LICENSE)
240
+ - [Commercial licensing](mailto:paul@elah.dev)
@@ -239,6 +239,6 @@ export function AssetPanel({ style, className, activateOnTap, onAssetActivate, }
239
239
  color: toast.tone === 'warn' ? 'var(--elah-danger-text, #f5d0a9)' : 'var(--elah-info-text, #c8d8f0)',
240
240
  background: toast.tone === 'warn' ? 'var(--elah-danger-bg, #3a2418)' : 'var(--elah-info-bg, #1a2433)',
241
241
  border: `1px solid ${toast.tone === 'warn' ? 'var(--elah-danger-border, #7a4a2a)' : 'var(--elah-info-border, #355070)'}`,
242
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.35)',
242
+ boxShadow: 'var(--elah-toast-shadow, 0 4px 12px rgba(0, 0, 0, 0.35))',
243
243
  }, children: toast.message })), assets.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center min-h-[120px] p-4 text-center text-ed-text-muted text-[11px] border border-dashed border-ed-border rounded-md", children: [_jsx("span", { className: "mb-2 text-2xl opacity-50", children: "\u2193" }), "Drop files here", _jsx("br", {}), "or click Add"] })) : (_jsx("div", { className: "flex flex-col gap-[6px]", children: assets.map((asset) => (_jsx(AssetThumbnail, { asset: asset, onDelete: handleDeleteAsset, onActivate: activationEnabled ? handleAssetActivate : undefined }, asset.id))) }))] })] }));
244
244
  }
@@ -3,17 +3,31 @@ import { useEffect, useMemo } from 'react';
3
3
  import { TimelineEngine, PlaybackEngine, installTraceGlobal, trace, } from '@elah/core';
4
4
  import { useTracksStore, usePlaybackStore, useTransitionsStore, EditorContext, } from '@elah/react';
5
5
  export function EditorProvider({ fps, stage, defaultTrackHeight, maxHistorySize, initialTracks, children, }) {
6
- const engine = useMemo(() => new TimelineEngine({
7
- fps,
8
- stage,
9
- defaultTrackHeight,
10
- maxHistorySize,
11
- initialTracks,
12
- }), []);
13
- const playback = useMemo(() => new PlaybackEngine({
14
- fps,
15
- getTotalFrames: () => useTracksStore.getState().totalFrames,
16
- }), []);
6
+ const { engine, playback } = useMemo(() => {
7
+ const engine = new TimelineEngine({
8
+ fps,
9
+ stage,
10
+ defaultTrackHeight,
11
+ maxHistorySize,
12
+ initialTracks,
13
+ });
14
+ const playback = new PlaybackEngine({
15
+ fps,
16
+ getTotalFrames: () => useTracksStore.getState().totalFrames,
17
+ });
18
+ engine.on('project:loaded', ({ transport }) => {
19
+ if (transport !== 'rewind')
20
+ return;
21
+ playback.pause();
22
+ playback.seek(0);
23
+ const pb = usePlaybackStore.getState();
24
+ if (pb.isPlaying)
25
+ pb.pause();
26
+ if (pb.currentFrame !== 0)
27
+ pb.setCurrentFrame(0);
28
+ });
29
+ return { engine, playback };
30
+ }, []);
17
31
  useEffect(() => {
18
32
  const syncAll = () => {
19
33
  const project = engine.getProject();
@@ -1 +1 @@
1
- export declare function MediaTransformOverlay(): import("react/jsx-runtime").JSX.Element;
1
+ export declare const MediaTransformOverlay: import("react").NamedExoticComponent<object>;