@editframe/create 0.43.0 → 0.45.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 +11 -0
- package/dist/index.js +16 -28
- package/dist/index.js.map +1 -1
- package/dist/skills/editframe-brand-video-generator/README.md +155 -0
- package/dist/skills/editframe-brand-video-generator/SKILL.md +207 -0
- package/dist/skills/editframe-brand-video-generator/references/brand-examples.md +178 -0
- package/dist/skills/editframe-brand-video-generator/references/color-psychology.md +227 -0
- package/dist/skills/editframe-brand-video-generator/references/composition-patterns.md +383 -0
- package/dist/skills/editframe-brand-video-generator/references/editing.md +66 -0
- package/dist/skills/editframe-brand-video-generator/references/emotional-arcs.md +496 -0
- package/dist/skills/editframe-brand-video-generator/references/genre-selection.md +135 -0
- package/dist/skills/editframe-brand-video-generator/references/transition-styles.md +611 -0
- package/dist/skills/editframe-brand-video-generator/references/typography-personalities.md +326 -0
- package/dist/skills/editframe-brand-video-generator/references/video-archetypes.md +86 -0
- package/dist/skills/editframe-brand-video-generator/references/video-fundamentals.md +169 -0
- package/dist/skills/editframe-brand-video-generator/references/visual-metaphors.md +50 -0
- package/dist/skills/editframe-composition/SKILL.md +169 -0
- package/dist/skills/editframe-composition/references/audio.md +483 -0
- package/dist/skills/editframe-composition/references/captions.md +844 -0
- package/dist/skills/editframe-composition/references/composition-model.md +73 -0
- package/dist/skills/editframe-composition/references/configuration.md +403 -0
- package/dist/skills/editframe-composition/references/css-parts.md +105 -0
- package/dist/skills/editframe-composition/references/css-variables.md +640 -0
- package/dist/skills/editframe-composition/references/entry-points.md +810 -0
- package/dist/skills/editframe-composition/references/events.md +499 -0
- package/dist/skills/editframe-composition/references/getting-started.md +259 -0
- package/dist/skills/editframe-composition/references/hooks.md +234 -0
- package/dist/skills/editframe-composition/references/image.md +241 -0
- package/dist/skills/editframe-composition/references/r3f.md +580 -0
- package/dist/skills/editframe-composition/references/render-api.md +484 -0
- package/dist/skills/editframe-composition/references/render-strategies.md +119 -0
- package/dist/skills/editframe-composition/references/render-to-video.md +1101 -0
- package/dist/skills/editframe-composition/references/scripting.md +606 -0
- package/dist/skills/editframe-composition/references/sequencing.md +116 -0
- package/dist/skills/editframe-composition/references/server-rendering.md +753 -0
- package/dist/skills/editframe-composition/references/surface.md +329 -0
- package/dist/skills/editframe-composition/references/text.md +627 -0
- package/dist/skills/editframe-composition/references/time-model.md +99 -0
- package/dist/skills/editframe-composition/references/timegroup-modes.md +102 -0
- package/dist/skills/editframe-composition/references/timegroup.md +457 -0
- package/dist/skills/editframe-composition/references/timeline-root.md +398 -0
- package/dist/skills/editframe-composition/references/transcription.md +47 -0
- package/dist/skills/editframe-composition/references/transitions.md +608 -0
- package/dist/skills/editframe-composition/references/use-media-info.md +357 -0
- package/dist/skills/editframe-composition/references/video.md +506 -0
- package/dist/skills/editframe-composition/references/waveform.md +327 -0
- package/dist/skills/editframe-editor-gui/SKILL.md +152 -0
- package/dist/skills/editframe-editor-gui/references/active-root-temporal.md +657 -0
- package/dist/skills/editframe-editor-gui/references/canvas.md +947 -0
- package/dist/skills/editframe-editor-gui/references/controls.md +366 -0
- package/dist/skills/editframe-editor-gui/references/dial.md +756 -0
- package/dist/skills/editframe-editor-gui/references/editor-toolkit.md +587 -0
- package/dist/skills/editframe-editor-gui/references/filmstrip.md +460 -0
- package/dist/skills/editframe-editor-gui/references/fit-scale.md +772 -0
- package/dist/skills/editframe-editor-gui/references/focus-overlay.md +561 -0
- package/dist/skills/editframe-editor-gui/references/hierarchy.md +544 -0
- package/dist/skills/editframe-editor-gui/references/overlay-item.md +634 -0
- package/dist/skills/editframe-editor-gui/references/overlay-layer.md +429 -0
- package/dist/skills/editframe-editor-gui/references/pan-zoom.md +568 -0
- package/dist/skills/editframe-editor-gui/references/pause.md +397 -0
- package/dist/skills/editframe-editor-gui/references/play.md +370 -0
- package/dist/skills/editframe-editor-gui/references/preview.md +391 -0
- package/dist/skills/editframe-editor-gui/references/resizable-box.md +749 -0
- package/dist/skills/editframe-editor-gui/references/scrubber.md +588 -0
- package/dist/skills/editframe-editor-gui/references/thumbnail-strip.md +566 -0
- package/dist/skills/editframe-editor-gui/references/time-display.md +492 -0
- package/dist/skills/editframe-editor-gui/references/timeline-ruler.md +489 -0
- package/dist/skills/editframe-editor-gui/references/timeline.md +604 -0
- package/dist/skills/editframe-editor-gui/references/toggle-loop.md +618 -0
- package/dist/skills/editframe-editor-gui/references/toggle-play.md +526 -0
- package/dist/skills/editframe-editor-gui/references/transform-handles.md +924 -0
- package/dist/skills/editframe-editor-gui/references/trim-handles.md +725 -0
- package/dist/skills/editframe-editor-gui/references/workbench.md +453 -0
- package/dist/skills/editframe-motion-design/SKILL.md +101 -0
- package/dist/skills/editframe-motion-design/references/0-editframe.md +299 -0
- package/dist/skills/editframe-motion-design/references/1-intent.md +201 -0
- package/dist/skills/editframe-motion-design/references/2-physics-model.md +405 -0
- package/dist/skills/editframe-motion-design/references/3-attention.md +350 -0
- package/dist/skills/editframe-motion-design/references/4-process.md +418 -0
- package/dist/skills/editframe-vite-plugin/SKILL.md +75 -0
- package/dist/skills/editframe-vite-plugin/references/file-api.md +111 -0
- package/dist/skills/editframe-vite-plugin/references/getting-started.md +96 -0
- package/dist/skills/editframe-vite-plugin/references/jit-transcoding.md +91 -0
- package/dist/skills/editframe-vite-plugin/references/local-assets.md +75 -0
- package/dist/skills/editframe-vite-plugin/references/visual-testing.md +136 -0
- package/dist/skills/editframe-webhooks/SKILL.md +126 -0
- package/dist/skills/editframe-webhooks/references/events.md +382 -0
- package/dist/skills/editframe-webhooks/references/getting-started.md +232 -0
- package/dist/skills/editframe-webhooks/references/security.md +418 -0
- package/dist/skills/editframe-webhooks/references/testing.md +409 -0
- package/dist/skills/editframe-webhooks/references/troubleshooting.md +457 -0
- package/dist/templates/html/AGENTS.md +13 -0
- package/dist/templates/react/AGENTS.md +13 -0
- package/dist/utils.js +15 -16
- package/dist/utils.js.map +1 -1
- package/package.json +2 -2
- package/tsdown.config.ts +4 -0
- package/dist/detectAgent.js +0 -89
- package/dist/detectAgent.js.map +0 -1
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Render to Video Tutorial
|
|
3
|
+
description: End-to-end guide to exporting Editframe compositions as MP4 video files using the browser renderer or Editframe CLI.
|
|
4
|
+
type: tutorial
|
|
5
|
+
nav:
|
|
6
|
+
parent: "Rendering"
|
|
7
|
+
priority: 10
|
|
8
|
+
related: ["render-api", "render-strategies"]
|
|
9
|
+
react:
|
|
10
|
+
generate: true
|
|
11
|
+
componentName: "Render to Video Tutorial"
|
|
12
|
+
importPath: "@editframe/react"
|
|
13
|
+
nav:
|
|
14
|
+
parent: "Guides / Tutorials"
|
|
15
|
+
priority: 15
|
|
16
|
+
related: ["hooks", "timegroup"]
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<!-- html-only -->
|
|
20
|
+
# Render to Video Tutorial
|
|
21
|
+
|
|
22
|
+
Build a composition and export it as MP4 video directly in the browser using WebCodecs.
|
|
23
|
+
<!-- /html-only -->
|
|
24
|
+
<!-- react-only -->
|
|
25
|
+
# Render to Video Tutorial (React)
|
|
26
|
+
|
|
27
|
+
Build a React composition and export it as MP4 video directly in the browser using WebCodecs.
|
|
28
|
+
<!-- /react-only -->
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
<!-- html-only -->
|
|
33
|
+
Browser must support WebCodecs API (Chrome 94+, Edge 94+, Safari 16.4+). Check support:
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
const supported = 'VideoEncoder' in window && 'VideoDecoder' in window;
|
|
37
|
+
```
|
|
38
|
+
<!-- /html-only -->
|
|
39
|
+
<!-- react-only -->
|
|
40
|
+
- React project with `@editframe/react` installed
|
|
41
|
+
- Browser supporting WebCodecs API (Chrome 94+, Edge 94+, Safari 16.4+)
|
|
42
|
+
- `TimelineRoot` wrapper (required for rendering)
|
|
43
|
+
<!-- /react-only -->
|
|
44
|
+
|
|
45
|
+
## Step 1: Create a Composition
|
|
46
|
+
|
|
47
|
+
<!-- html-only -->
|
|
48
|
+
Start with a basic composition that you want to render:
|
|
49
|
+
|
|
50
|
+
```html live
|
|
51
|
+
<ef-timegroup id="myComposition" mode="sequence" class="w-[1280px] h-[720px] bg-gradient-to-br from-purple-900 to-blue-900">
|
|
52
|
+
<!-- Scene 1: Title -->
|
|
53
|
+
<ef-timegroup mode="fixed" duration="3s" class="absolute w-full h-full flex items-center justify-center">
|
|
54
|
+
<ef-text duration="3s" class="text-white text-6xl font-bold animate-fade-in">
|
|
55
|
+
Welcome
|
|
56
|
+
</ef-text>
|
|
57
|
+
</ef-timegroup>
|
|
58
|
+
|
|
59
|
+
<!-- Scene 2: Content -->
|
|
60
|
+
<ef-timegroup mode="fixed" duration="4s" class="absolute w-full h-full flex items-center justify-center">
|
|
61
|
+
<ef-text duration="4s" class="text-white text-4xl">
|
|
62
|
+
This will be exported as video
|
|
63
|
+
</ef-text>
|
|
64
|
+
</ef-timegroup>
|
|
65
|
+
</ef-timegroup>
|
|
66
|
+
```
|
|
67
|
+
<!-- /html-only -->
|
|
68
|
+
<!-- react-only -->
|
|
69
|
+
Build your video composition as a React component:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { Timegroup, Text } from "@editframe/react";
|
|
73
|
+
|
|
74
|
+
export const MyVideo = () => {
|
|
75
|
+
return (
|
|
76
|
+
<Timegroup
|
|
77
|
+
mode="sequence"
|
|
78
|
+
className="w-[1280px] h-[720px] bg-gradient-to-br from-purple-900 to-blue-900"
|
|
79
|
+
>
|
|
80
|
+
{/* Scene 1: Title */}
|
|
81
|
+
<Timegroup
|
|
82
|
+
mode="fixed"
|
|
83
|
+
duration="3s"
|
|
84
|
+
className="absolute w-full h-full flex items-center justify-center"
|
|
85
|
+
>
|
|
86
|
+
<Text duration="3s" className="text-white text-6xl font-bold">
|
|
87
|
+
Welcome
|
|
88
|
+
</Text>
|
|
89
|
+
</Timegroup>
|
|
90
|
+
|
|
91
|
+
{/* Scene 2: Content */}
|
|
92
|
+
<Timegroup
|
|
93
|
+
mode="fixed"
|
|
94
|
+
duration="4s"
|
|
95
|
+
className="absolute w-full h-full flex items-center justify-center"
|
|
96
|
+
>
|
|
97
|
+
<Text duration="4s" className="text-white text-4xl">
|
|
98
|
+
This will be exported as video
|
|
99
|
+
</Text>
|
|
100
|
+
</Timegroup>
|
|
101
|
+
</Timegroup>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
```
|
|
105
|
+
<!-- /react-only -->
|
|
106
|
+
|
|
107
|
+
## Step 2: <!-- html-only -->Add Export Button<!-- /html-only --><!-- react-only -->Get Timegroup Reference<!-- /react-only -->
|
|
108
|
+
|
|
109
|
+
<!-- html-only -->
|
|
110
|
+
Create UI for triggering the export:
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<button id="exportBtn" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
114
|
+
Export Video
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
<div id="progressContainer" class="mt-4 hidden">
|
|
118
|
+
<div class="flex justify-between text-sm text-gray-300 mb-2">
|
|
119
|
+
<span id="progressText">Preparing...</span>
|
|
120
|
+
<span id="progressPercent">0%</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="w-full bg-gray-700 rounded-full h-3">
|
|
123
|
+
<div id="progressBar" class="bg-blue-600 h-3 rounded-full transition-all" style="width: 0%"></div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="mt-2 text-xs text-gray-400">
|
|
126
|
+
<span id="timeInfo"></span>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
```
|
|
130
|
+
<!-- /html-only -->
|
|
131
|
+
<!-- react-only -->
|
|
132
|
+
Use a ref to access the underlying timegroup element:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { useRef } from "react";
|
|
136
|
+
import { Timegroup, Text } from "@editframe/react";
|
|
137
|
+
import type { EFTimegroup } from "@editframe/elements";
|
|
138
|
+
|
|
139
|
+
export const MyVideo = () => {
|
|
140
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
141
|
+
|
|
142
|
+
const handleExport = async () => {
|
|
143
|
+
if (!timegroupRef.current) return;
|
|
144
|
+
|
|
145
|
+
await timegroupRef.current.renderToVideo({
|
|
146
|
+
fps: 30,
|
|
147
|
+
codec: "avc",
|
|
148
|
+
filename: "my-video.mp4"
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<Timegroup
|
|
155
|
+
ref={timegroupRef}
|
|
156
|
+
mode="sequence"
|
|
157
|
+
className="w-[1280px] h-[720px] bg-black"
|
|
158
|
+
>
|
|
159
|
+
{/* composition content */}
|
|
160
|
+
</Timegroup>
|
|
161
|
+
|
|
162
|
+
<button onClick={handleExport}>Export Video</button>
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
```
|
|
167
|
+
<!-- /react-only -->
|
|
168
|
+
|
|
169
|
+
## Step 3: <!-- html-only -->Implement Basic Render<!-- /html-only --><!-- react-only -->Add Progress State<!-- /react-only -->
|
|
170
|
+
|
|
171
|
+
<!-- html-only -->
|
|
172
|
+
Call `renderToVideo()` on the timegroup element:
|
|
173
|
+
|
|
174
|
+
```javascript
|
|
175
|
+
const timegroup = document.getElementById('myComposition');
|
|
176
|
+
const exportBtn = document.getElementById('exportBtn');
|
|
177
|
+
|
|
178
|
+
exportBtn.addEventListener('click', async () => {
|
|
179
|
+
exportBtn.disabled = true;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await timegroup.renderToVideo({
|
|
183
|
+
fps: 30,
|
|
184
|
+
codec: 'avc',
|
|
185
|
+
filename: 'my-video.mp4'
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
alert('Video exported successfully!');
|
|
189
|
+
} catch (error) {
|
|
190
|
+
alert('Export failed: ' + error.message);
|
|
191
|
+
} finally {
|
|
192
|
+
exportBtn.disabled = false;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The video automatically downloads when rendering completes.
|
|
198
|
+
|
|
199
|
+
## Step 4: Add Progress Tracking
|
|
200
|
+
|
|
201
|
+
Show real-time progress with the `onProgress` callback:
|
|
202
|
+
|
|
203
|
+
```javascript
|
|
204
|
+
const progressContainer = document.getElementById('progressContainer');
|
|
205
|
+
const progressBar = document.getElementById('progressBar');
|
|
206
|
+
const progressText = document.getElementById('progressText');
|
|
207
|
+
const progressPercent = document.getElementById('progressPercent');
|
|
208
|
+
const timeInfo = document.getElementById('timeInfo');
|
|
209
|
+
|
|
210
|
+
exportBtn.addEventListener('click', async () => {
|
|
211
|
+
exportBtn.disabled = true;
|
|
212
|
+
progressContainer.classList.remove('hidden');
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await timegroup.renderToVideo({
|
|
216
|
+
fps: 30,
|
|
217
|
+
codec: 'avc',
|
|
218
|
+
filename: 'my-video.mp4',
|
|
219
|
+
onProgress: (progress) => {
|
|
220
|
+
// Update progress bar
|
|
221
|
+
const percent = Math.round(progress.progress * 100);
|
|
222
|
+
progressBar.style.width = `${percent}%`;
|
|
223
|
+
progressPercent.textContent = `${percent}%`;
|
|
224
|
+
|
|
225
|
+
// Update status text
|
|
226
|
+
progressText.textContent = `Rendering frame ${progress.currentFrame} of ${progress.totalFrames}`;
|
|
227
|
+
|
|
228
|
+
// Show timing information
|
|
229
|
+
const elapsed = Math.round(progress.elapsedMs / 1000);
|
|
230
|
+
const remaining = Math.round(progress.estimatedRemainingMs / 1000);
|
|
231
|
+
const speed = progress.speedMultiplier.toFixed(1);
|
|
232
|
+
timeInfo.textContent = `Elapsed: ${elapsed}s | Remaining: ${remaining}s | Speed: ${speed}x`;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
progressText.textContent = 'Export complete!';
|
|
237
|
+
progressPercent.textContent = '100%';
|
|
238
|
+
} catch (error) {
|
|
239
|
+
progressText.textContent = 'Export failed: ' + error.message;
|
|
240
|
+
} finally {
|
|
241
|
+
exportBtn.disabled = false;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
<!-- /html-only -->
|
|
246
|
+
<!-- react-only -->
|
|
247
|
+
Track render progress with React state:
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { useState, useRef } from "react";
|
|
251
|
+
import { Timegroup, Text } from "@editframe/react";
|
|
252
|
+
import type { EFTimegroup } from "@editframe/elements";
|
|
253
|
+
import type { RenderProgress } from "@editframe/elements";
|
|
254
|
+
|
|
255
|
+
export const MyVideo = () => {
|
|
256
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
257
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
258
|
+
const [progress, setProgress] = useState<RenderProgress | null>(null);
|
|
259
|
+
const [error, setError] = useState<string | null>(null);
|
|
260
|
+
|
|
261
|
+
const handleExport = async () => {
|
|
262
|
+
if (!timegroupRef.current) return;
|
|
263
|
+
|
|
264
|
+
setIsRendering(true);
|
|
265
|
+
setError(null);
|
|
266
|
+
setProgress(null);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
await timegroupRef.current.renderToVideo({
|
|
270
|
+
fps: 30,
|
|
271
|
+
codec: "avc",
|
|
272
|
+
filename: "my-video.mp4",
|
|
273
|
+
onProgress: (p) => {
|
|
274
|
+
setProgress(p);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
setError(null);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
setError(err instanceof Error ? err.message : "Export failed");
|
|
281
|
+
} finally {
|
|
282
|
+
setIsRendering(false);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
<Timegroup
|
|
289
|
+
ref={timegroupRef}
|
|
290
|
+
mode="sequence"
|
|
291
|
+
className="w-[1280px] h-[720px] bg-gradient-to-br from-blue-600 to-purple-600"
|
|
292
|
+
>
|
|
293
|
+
<Timegroup
|
|
294
|
+
mode="fixed"
|
|
295
|
+
duration="3s"
|
|
296
|
+
className="absolute w-full h-full flex items-center justify-center"
|
|
297
|
+
>
|
|
298
|
+
<Text duration="3s" className="text-white text-6xl font-bold">
|
|
299
|
+
Editframe
|
|
300
|
+
</Text>
|
|
301
|
+
</Timegroup>
|
|
302
|
+
</Timegroup>
|
|
303
|
+
|
|
304
|
+
<div className="mt-4 space-y-4">
|
|
305
|
+
<button
|
|
306
|
+
onClick={handleExport}
|
|
307
|
+
disabled={isRendering}
|
|
308
|
+
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
|
309
|
+
>
|
|
310
|
+
{isRendering ? "Rendering..." : "Export Video"}
|
|
311
|
+
</button>
|
|
312
|
+
|
|
313
|
+
{progress && (
|
|
314
|
+
<div>
|
|
315
|
+
<div className="flex justify-between text-sm mb-2">
|
|
316
|
+
<span>
|
|
317
|
+
Frame {progress.currentFrame} of {progress.totalFrames}
|
|
318
|
+
</span>
|
|
319
|
+
<span>{Math.round(progress.progress * 100)}%</span>
|
|
320
|
+
</div>
|
|
321
|
+
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
322
|
+
<div
|
|
323
|
+
className="bg-blue-600 h-3 rounded-full transition-all"
|
|
324
|
+
style={{ width: `${progress.progress * 100}%` }}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
<div className="text-xs text-gray-600 mt-2">
|
|
328
|
+
Speed: {progress.speedMultiplier.toFixed(1)}x |
|
|
329
|
+
Remaining: {Math.round(progress.estimatedRemainingMs / 1000)}s
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{error && (
|
|
335
|
+
<div className="text-red-600 text-sm">Error: {error}</div>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
</>
|
|
339
|
+
);
|
|
340
|
+
};
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Step 4: Use useTimingInfo for Dynamic Content
|
|
344
|
+
|
|
345
|
+
Combine `useTimingInfo` with rendering for time-based animations:
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
import { Timegroup, Text, useTimingInfo } from "@editframe/react";
|
|
349
|
+
|
|
350
|
+
const FadeInScene = ({ children }: { children: React.ReactNode }) => {
|
|
351
|
+
const { ref, percentComplete } = useTimingInfo();
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<Timegroup
|
|
355
|
+
ref={ref}
|
|
356
|
+
mode="fixed"
|
|
357
|
+
duration="3s"
|
|
358
|
+
className="absolute w-full h-full flex items-center justify-center"
|
|
359
|
+
>
|
|
360
|
+
<div style={{ opacity: percentComplete }}>
|
|
361
|
+
{children}
|
|
362
|
+
</div>
|
|
363
|
+
</Timegroup>
|
|
364
|
+
);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const AnimatedVideo = () => {
|
|
368
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
369
|
+
|
|
370
|
+
const handleExport = async () => {
|
|
371
|
+
if (!timegroupRef.current) return;
|
|
372
|
+
|
|
373
|
+
await timegroupRef.current.renderToVideo({
|
|
374
|
+
fps: 30,
|
|
375
|
+
codec: "avc",
|
|
376
|
+
filename: "animated-video.mp4"
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<>
|
|
382
|
+
<Timegroup
|
|
383
|
+
ref={timegroupRef}
|
|
384
|
+
mode="sequence"
|
|
385
|
+
className="w-[1280px] h-[720px] bg-black"
|
|
386
|
+
>
|
|
387
|
+
<FadeInScene>
|
|
388
|
+
<Text className="text-white text-6xl">Fades In</Text>
|
|
389
|
+
</FadeInScene>
|
|
390
|
+
|
|
391
|
+
<FadeInScene>
|
|
392
|
+
<Text className="text-white text-6xl">Also Fades In</Text>
|
|
393
|
+
</FadeInScene>
|
|
394
|
+
</Timegroup>
|
|
395
|
+
|
|
396
|
+
<button onClick={handleExport}>Export</button>
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
```
|
|
401
|
+
<!-- /react-only -->
|
|
402
|
+
|
|
403
|
+
## Step 5: Add Codec Selection
|
|
404
|
+
|
|
405
|
+
Let users choose the codec based on browser support:
|
|
406
|
+
|
|
407
|
+
<!-- html-only -->
|
|
408
|
+
```html
|
|
409
|
+
<select id="codecSelect" class="px-4 py-2 bg-gray-700 text-white rounded">
|
|
410
|
+
<option value="avc">H.264 (Best Compatibility)</option>
|
|
411
|
+
<option value="hevc">H.265 (Better Compression)</option>
|
|
412
|
+
<option value="vp9">VP9 (Open Codec)</option>
|
|
413
|
+
<option value="av1">AV1 (Best Quality)</option>
|
|
414
|
+
</select>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```javascript
|
|
418
|
+
const codecSelect = document.getElementById('codecSelect');
|
|
419
|
+
|
|
420
|
+
// Check codec support on load
|
|
421
|
+
const codecs = ['avc', 'hevc', 'vp9', 'av1'];
|
|
422
|
+
codecs.forEach(async (codec) => {
|
|
423
|
+
const config = {
|
|
424
|
+
codec: codec === 'avc' ? 'avc1.42E01E' :
|
|
425
|
+
codec === 'hevc' ? 'hvc1.1.6.L93.B0' :
|
|
426
|
+
codec === 'vp9' ? 'vp09.00.10.08' : 'av01.0.05M.08',
|
|
427
|
+
width: 1280,
|
|
428
|
+
height: 720,
|
|
429
|
+
bitrate: 5_000_000,
|
|
430
|
+
framerate: 30
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const supported = await VideoEncoder.isConfigSupported(config);
|
|
434
|
+
if (!supported.supported) {
|
|
435
|
+
const option = codecSelect.querySelector(`option[value="${codec}"]`);
|
|
436
|
+
option.disabled = true;
|
|
437
|
+
option.textContent += ' (Not Supported)';
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Use selected codec when rendering
|
|
442
|
+
exportBtn.addEventListener('click', async () => {
|
|
443
|
+
const codec = codecSelect.value;
|
|
444
|
+
// ... rest of render code
|
|
445
|
+
await timegroup.renderToVideo({
|
|
446
|
+
fps: 30,
|
|
447
|
+
codec: codec,
|
|
448
|
+
filename: 'my-video.mp4',
|
|
449
|
+
onProgress: (progress) => { /* ... */ }
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
<!-- /html-only -->
|
|
454
|
+
<!-- react-only -->
|
|
455
|
+
```tsx
|
|
456
|
+
import { useState, useRef } from "react";
|
|
457
|
+
import { Timegroup, Text } from "@editframe/react";
|
|
458
|
+
import type { EFTimegroup } from "@editframe/elements";
|
|
459
|
+
|
|
460
|
+
type Codec = "avc" | "hevc" | "vp9" | "av1";
|
|
461
|
+
|
|
462
|
+
export const MyVideo = () => {
|
|
463
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
464
|
+
const [codec, setCodec] = useState<Codec>("avc");
|
|
465
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
466
|
+
|
|
467
|
+
const handleExport = async () => {
|
|
468
|
+
if (!timegroupRef.current) return;
|
|
469
|
+
|
|
470
|
+
setIsRendering(true);
|
|
471
|
+
try {
|
|
472
|
+
await timegroupRef.current.renderToVideo({
|
|
473
|
+
fps: 30,
|
|
474
|
+
codec,
|
|
475
|
+
filename: `video-${codec}.mp4`
|
|
476
|
+
});
|
|
477
|
+
} finally {
|
|
478
|
+
setIsRendering(false);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<>
|
|
484
|
+
<Timegroup
|
|
485
|
+
ref={timegroupRef}
|
|
486
|
+
mode="sequence"
|
|
487
|
+
className="w-[1280px] h-[720px] bg-black"
|
|
488
|
+
>
|
|
489
|
+
{/* composition */}
|
|
490
|
+
</Timegroup>
|
|
491
|
+
|
|
492
|
+
<div className="flex gap-4 mt-4">
|
|
493
|
+
<select
|
|
494
|
+
value={codec}
|
|
495
|
+
onChange={(e) => setCodec(e.target.value as Codec)}
|
|
496
|
+
className="px-4 py-2 border rounded"
|
|
497
|
+
>
|
|
498
|
+
<option value="avc">H.264 (Best Compatibility)</option>
|
|
499
|
+
<option value="hevc">H.265 (Better Compression)</option>
|
|
500
|
+
<option value="vp9">VP9 (Open Codec)</option>
|
|
501
|
+
<option value="av1">AV1 (Best Quality)</option>
|
|
502
|
+
</select>
|
|
503
|
+
|
|
504
|
+
<button onClick={handleExport} disabled={isRendering}>
|
|
505
|
+
{isRendering ? "Rendering..." : "Export Video"}
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
</>
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
```
|
|
512
|
+
<!-- /react-only -->
|
|
513
|
+
|
|
514
|
+
## Step 6: Include Audio
|
|
515
|
+
|
|
516
|
+
Audio from <!-- html-only -->`ef-video` and `ef-audio`<!-- /html-only --><!-- react-only -->Video and Audio<!-- /react-only --> elements is automatically mixed:
|
|
517
|
+
|
|
518
|
+
<!-- html-only -->
|
|
519
|
+
```html
|
|
520
|
+
<ef-timegroup id="compositionWithAudio" mode="sequence" class="w-[1280px] h-[720px]">
|
|
521
|
+
<ef-timegroup mode="fixed" duration="5s" class="absolute w-full h-full">
|
|
522
|
+
<ef-video src="/assets/clip.mp4" class="size-full object-cover"></ef-video>
|
|
523
|
+
<ef-audio src="/assets/music.mp3" volume="0.3"></ef-audio>
|
|
524
|
+
</ef-timegroup>
|
|
525
|
+
</ef-timegroup>
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
```javascript
|
|
529
|
+
await timegroup.renderToVideo({
|
|
530
|
+
fps: 30,
|
|
531
|
+
codec: 'avc',
|
|
532
|
+
includeAudio: true, // Default: true
|
|
533
|
+
audioBitrate: 192_000, // 192 kbps for high quality
|
|
534
|
+
preferredAudioCodecs: ['opus', 'aac'], // Preference order
|
|
535
|
+
filename: 'video-with-audio.mp4'
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
<!-- /html-only -->
|
|
539
|
+
<!-- react-only -->
|
|
540
|
+
```tsx
|
|
541
|
+
import { Timegroup, Video, Audio, Text } from "@editframe/react";
|
|
542
|
+
|
|
543
|
+
export const VideoWithAudio = () => {
|
|
544
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
545
|
+
|
|
546
|
+
const handleExport = async () => {
|
|
547
|
+
if (!timegroupRef.current) return;
|
|
548
|
+
|
|
549
|
+
await timegroupRef.current.renderToVideo({
|
|
550
|
+
fps: 30,
|
|
551
|
+
codec: "avc",
|
|
552
|
+
includeAudio: true, // Default: true
|
|
553
|
+
audioBitrate: 192_000, // High quality (192 kbps)
|
|
554
|
+
preferredAudioCodecs: ["opus", "aac"],
|
|
555
|
+
filename: "video-with-audio.mp4"
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<>
|
|
561
|
+
<Timegroup
|
|
562
|
+
ref={timegroupRef}
|
|
563
|
+
mode="fixed"
|
|
564
|
+
duration="10s"
|
|
565
|
+
className="w-[1280px] h-[720px] bg-black"
|
|
566
|
+
>
|
|
567
|
+
<Video src="/assets/clip.mp4" className="size-full object-cover" />
|
|
568
|
+
<Audio src="/assets/music.mp3" volume={0.3} />
|
|
569
|
+
<Text className="absolute bottom-8 text-white text-2xl">
|
|
570
|
+
With Background Music
|
|
571
|
+
</Text>
|
|
572
|
+
</Timegroup>
|
|
573
|
+
|
|
574
|
+
<button onClick={handleExport}>Export with Audio</button>
|
|
575
|
+
</>
|
|
576
|
+
);
|
|
577
|
+
};
|
|
578
|
+
```
|
|
579
|
+
<!-- /react-only -->
|
|
580
|
+
|
|
581
|
+
## Step 7: Add Cancel Support
|
|
582
|
+
|
|
583
|
+
Allow users to abort long renders:
|
|
584
|
+
|
|
585
|
+
<!-- html-only -->
|
|
586
|
+
```html
|
|
587
|
+
<button id="cancelBtn" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 hidden">
|
|
588
|
+
Cancel Render
|
|
589
|
+
</button>
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```javascript
|
|
593
|
+
let abortController = null;
|
|
594
|
+
const cancelBtn = document.getElementById('cancelBtn');
|
|
595
|
+
|
|
596
|
+
exportBtn.addEventListener('click', async () => {
|
|
597
|
+
exportBtn.disabled = true;
|
|
598
|
+
cancelBtn.classList.remove('hidden');
|
|
599
|
+
progressContainer.classList.remove('hidden');
|
|
600
|
+
|
|
601
|
+
abortController = new AbortController();
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
await timegroup.renderToVideo({
|
|
605
|
+
fps: 30,
|
|
606
|
+
codec: 'avc',
|
|
607
|
+
filename: 'my-video.mp4',
|
|
608
|
+
signal: abortController.signal,
|
|
609
|
+
onProgress: (progress) => {
|
|
610
|
+
// Update progress UI
|
|
611
|
+
const percent = Math.round(progress.progress * 100);
|
|
612
|
+
progressBar.style.width = `${percent}%`;
|
|
613
|
+
progressPercent.textContent = `${percent}%`;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
progressText.textContent = 'Export complete!';
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (error.name === 'RenderCancelledError') {
|
|
620
|
+
progressText.textContent = 'Render cancelled by user';
|
|
621
|
+
} else {
|
|
622
|
+
progressText.textContent = 'Export failed: ' + error.message;
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
exportBtn.disabled = false;
|
|
626
|
+
cancelBtn.classList.add('hidden');
|
|
627
|
+
abortController = null;
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
cancelBtn.addEventListener('click', () => {
|
|
632
|
+
if (abortController) {
|
|
633
|
+
abortController.abort();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
<!-- /html-only -->
|
|
638
|
+
<!-- react-only -->
|
|
639
|
+
Allow users to abort renders with AbortController:
|
|
640
|
+
|
|
641
|
+
```tsx
|
|
642
|
+
import { useState, useRef } from "react";
|
|
643
|
+
import { Timegroup, Text } from "@editframe/react";
|
|
644
|
+
import type { EFTimegroup } from "@editframe/elements";
|
|
645
|
+
|
|
646
|
+
export const CancellableExport = () => {
|
|
647
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
648
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
649
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
650
|
+
const [progress, setProgress] = useState(0);
|
|
651
|
+
|
|
652
|
+
const handleExport = async () => {
|
|
653
|
+
if (!timegroupRef.current) return;
|
|
654
|
+
|
|
655
|
+
setIsRendering(true);
|
|
656
|
+
setProgress(0);
|
|
657
|
+
|
|
658
|
+
abortControllerRef.current = new AbortController();
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
await timegroupRef.current.renderToVideo({
|
|
662
|
+
fps: 30,
|
|
663
|
+
codec: "avc",
|
|
664
|
+
filename: "my-video.mp4",
|
|
665
|
+
signal: abortControllerRef.current.signal,
|
|
666
|
+
onProgress: (p) => {
|
|
667
|
+
setProgress(p.progress * 100);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
alert("Export complete!");
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if (error instanceof Error && error.name === "RenderCancelledError") {
|
|
674
|
+
alert("Render cancelled");
|
|
675
|
+
} else {
|
|
676
|
+
alert("Export failed");
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
setIsRendering(false);
|
|
680
|
+
abortControllerRef.current = null;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const handleCancel = () => {
|
|
685
|
+
abortControllerRef.current?.abort();
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
return (
|
|
689
|
+
<>
|
|
690
|
+
<Timegroup
|
|
691
|
+
ref={timegroupRef}
|
|
692
|
+
mode="sequence"
|
|
693
|
+
className="w-[1280px] h-[720px] bg-black"
|
|
694
|
+
>
|
|
695
|
+
{/* composition */}
|
|
696
|
+
</Timegroup>
|
|
697
|
+
|
|
698
|
+
<div className="flex gap-4 mt-4">
|
|
699
|
+
<button onClick={handleExport} disabled={isRendering}>
|
|
700
|
+
Export Video
|
|
701
|
+
</button>
|
|
702
|
+
|
|
703
|
+
{isRendering && (
|
|
704
|
+
<>
|
|
705
|
+
<button onClick={handleCancel} className="bg-red-600">
|
|
706
|
+
Cancel
|
|
707
|
+
</button>
|
|
708
|
+
<div className="flex-1">
|
|
709
|
+
<div className="text-sm mb-1">{Math.round(progress)}%</div>
|
|
710
|
+
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
711
|
+
<div
|
|
712
|
+
className="bg-blue-600 h-2 rounded-full"
|
|
713
|
+
style={{ width: `${progress}%` }}
|
|
714
|
+
/>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
</>
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
</>
|
|
721
|
+
);
|
|
722
|
+
};
|
|
723
|
+
```
|
|
724
|
+
<!-- /react-only -->
|
|
725
|
+
|
|
726
|
+
## Complete Example
|
|
727
|
+
|
|
728
|
+
<!-- html-only -->
|
|
729
|
+
```html live
|
|
730
|
+
<ef-timegroup id="finalComposition" mode="sequence" class="w-[1280px] h-[720px] bg-black">
|
|
731
|
+
<ef-timegroup mode="fixed" duration="3s" class="absolute w-full h-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center">
|
|
732
|
+
<ef-text duration="3s" class="text-white text-6xl font-bold">Editframe</ef-text>
|
|
733
|
+
</ef-timegroup>
|
|
734
|
+
<ef-timegroup mode="fixed" duration="3s" class="absolute w-full h-full bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center">
|
|
735
|
+
<ef-text duration="3s" class="text-white text-4xl">Export to Video</ef-text>
|
|
736
|
+
</ef-timegroup>
|
|
737
|
+
</ef-timegroup>
|
|
738
|
+
|
|
739
|
+
<div class="mt-4 space-y-4">
|
|
740
|
+
<div class="flex gap-4">
|
|
741
|
+
<button id="finalExportBtn" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
742
|
+
Export Video
|
|
743
|
+
</button>
|
|
744
|
+
<button id="finalCancelBtn" class="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 hidden">
|
|
745
|
+
Cancel
|
|
746
|
+
</button>
|
|
747
|
+
<select id="finalCodecSelect" class="px-4 py-2 bg-gray-700 text-white rounded">
|
|
748
|
+
<option value="avc">H.264</option>
|
|
749
|
+
<option value="vp9">VP9</option>
|
|
750
|
+
<option value="av1">AV1</option>
|
|
751
|
+
</select>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<div id="finalProgressContainer" class="hidden">
|
|
755
|
+
<div class="flex justify-between text-sm mb-2">
|
|
756
|
+
<span id="finalProgressText">Preparing...</span>
|
|
757
|
+
<span id="finalProgressPercent">0%</span>
|
|
758
|
+
</div>
|
|
759
|
+
<div class="w-full bg-gray-700 rounded-full h-3">
|
|
760
|
+
<div id="finalProgressBar" class="bg-blue-600 h-3 rounded-full transition-all" style="width: 0%"></div>
|
|
761
|
+
</div>
|
|
762
|
+
<div class="mt-2 text-xs text-gray-400" id="finalTimeInfo"></div>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<script type="module">
|
|
767
|
+
const timegroup = document.getElementById('finalComposition');
|
|
768
|
+
const exportBtn = document.getElementById('finalExportBtn');
|
|
769
|
+
const cancelBtn = document.getElementById('finalCancelBtn');
|
|
770
|
+
const codecSelect = document.getElementById('finalCodecSelect');
|
|
771
|
+
const progressContainer = document.getElementById('finalProgressContainer');
|
|
772
|
+
const progressBar = document.getElementById('finalProgressBar');
|
|
773
|
+
const progressText = document.getElementById('finalProgressText');
|
|
774
|
+
const progressPercent = document.getElementById('finalProgressPercent');
|
|
775
|
+
const timeInfo = document.getElementById('finalTimeInfo');
|
|
776
|
+
|
|
777
|
+
let abortController = null;
|
|
778
|
+
|
|
779
|
+
exportBtn.addEventListener('click', async () => {
|
|
780
|
+
exportBtn.disabled = true;
|
|
781
|
+
cancelBtn.classList.remove('hidden');
|
|
782
|
+
progressContainer.classList.remove('hidden');
|
|
783
|
+
|
|
784
|
+
abortController = new AbortController();
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
await timegroup.renderToVideo({
|
|
788
|
+
fps: 30,
|
|
789
|
+
codec: codecSelect.value,
|
|
790
|
+
bitrate: 5_000_000,
|
|
791
|
+
filename: 'editframe-export.mp4',
|
|
792
|
+
signal: abortController.signal,
|
|
793
|
+
onProgress: (progress) => {
|
|
794
|
+
const percent = Math.round(progress.progress * 100);
|
|
795
|
+
progressBar.style.width = `${percent}%`;
|
|
796
|
+
progressPercent.textContent = `${percent}%`;
|
|
797
|
+
progressText.textContent = `Rendering frame ${progress.currentFrame}/${progress.totalFrames}`;
|
|
798
|
+
|
|
799
|
+
const elapsed = Math.round(progress.elapsedMs / 1000);
|
|
800
|
+
const remaining = Math.round(progress.estimatedRemainingMs / 1000);
|
|
801
|
+
timeInfo.textContent = `${elapsed}s elapsed | ${remaining}s remaining | ${progress.speedMultiplier.toFixed(1)}x speed`;
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
progressText.textContent = 'Export complete! Video downloaded.';
|
|
806
|
+
progressPercent.textContent = '100%';
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (error.name === 'RenderCancelledError') {
|
|
809
|
+
progressText.textContent = 'Render cancelled';
|
|
810
|
+
} else {
|
|
811
|
+
progressText.textContent = `Error: ${error.message}`;
|
|
812
|
+
}
|
|
813
|
+
} finally {
|
|
814
|
+
exportBtn.disabled = false;
|
|
815
|
+
cancelBtn.classList.add('hidden');
|
|
816
|
+
abortController = null;
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
cancelBtn.addEventListener('click', () => {
|
|
821
|
+
if (abortController) abortController.abort();
|
|
822
|
+
});
|
|
823
|
+
</script>
|
|
824
|
+
```
|
|
825
|
+
<!-- /html-only -->
|
|
826
|
+
<!-- react-only -->
|
|
827
|
+
```tsx
|
|
828
|
+
import { useState, useRef } from "react";
|
|
829
|
+
import { Timegroup, Text, useTimingInfo } from "@editframe/react";
|
|
830
|
+
import type { EFTimegroup, RenderProgress } from "@editframe/elements";
|
|
831
|
+
|
|
832
|
+
type Codec = "avc" | "hevc" | "vp9" | "av1";
|
|
833
|
+
|
|
834
|
+
const AnimatedScene = ({ children }: { children: React.ReactNode }) => {
|
|
835
|
+
const { ref, percentComplete } = useTimingInfo();
|
|
836
|
+
|
|
837
|
+
return (
|
|
838
|
+
<Timegroup
|
|
839
|
+
ref={ref}
|
|
840
|
+
mode="fixed"
|
|
841
|
+
duration="3s"
|
|
842
|
+
className="absolute w-full h-full flex items-center justify-center"
|
|
843
|
+
>
|
|
844
|
+
<div style={{ opacity: percentComplete, transform: `scale(${0.5 + percentComplete * 0.5})` }}>
|
|
845
|
+
{children}
|
|
846
|
+
</div>
|
|
847
|
+
</Timegroup>
|
|
848
|
+
);
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
export const CompleteExportExample = () => {
|
|
852
|
+
const timegroupRef = useRef<EFTimegroup>(null);
|
|
853
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
854
|
+
|
|
855
|
+
const [codec, setCodec] = useState<Codec>("avc");
|
|
856
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
857
|
+
const [progress, setProgress] = useState<RenderProgress | null>(null);
|
|
858
|
+
const [error, setError] = useState<string | null>(null);
|
|
859
|
+
|
|
860
|
+
const handleExport = async () => {
|
|
861
|
+
if (!timegroupRef.current) return;
|
|
862
|
+
|
|
863
|
+
setIsRendering(true);
|
|
864
|
+
setError(null);
|
|
865
|
+
setProgress(null);
|
|
866
|
+
|
|
867
|
+
abortControllerRef.current = new AbortController();
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
await timegroupRef.current.renderToVideo({
|
|
871
|
+
fps: 30,
|
|
872
|
+
codec,
|
|
873
|
+
bitrate: 5_000_000,
|
|
874
|
+
filename: `my-video-${codec}.mp4`,
|
|
875
|
+
signal: abortControllerRef.current.signal,
|
|
876
|
+
onProgress: (p) => setProgress(p)
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
alert("Video exported successfully!");
|
|
880
|
+
} catch (err) {
|
|
881
|
+
if (err instanceof Error && err.name === "RenderCancelledError") {
|
|
882
|
+
setError("Render cancelled by user");
|
|
883
|
+
} else {
|
|
884
|
+
setError(err instanceof Error ? err.message : "Export failed");
|
|
885
|
+
}
|
|
886
|
+
} finally {
|
|
887
|
+
setIsRendering(false);
|
|
888
|
+
abortControllerRef.current = null;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const handleCancel = () => {
|
|
893
|
+
abortControllerRef.current?.abort();
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const percent = progress ? Math.round(progress.progress * 100) : 0;
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<div className="space-y-4">
|
|
900
|
+
<Timegroup
|
|
901
|
+
ref={timegroupRef}
|
|
902
|
+
mode="sequence"
|
|
903
|
+
className="w-[1280px] h-[720px] bg-gradient-to-br from-blue-600 to-purple-600"
|
|
904
|
+
>
|
|
905
|
+
<AnimatedScene>
|
|
906
|
+
<Text className="text-white text-6xl font-bold">Editframe</Text>
|
|
907
|
+
</AnimatedScene>
|
|
908
|
+
|
|
909
|
+
<AnimatedScene>
|
|
910
|
+
<Text className="text-white text-4xl">Export to Video</Text>
|
|
911
|
+
</AnimatedScene>
|
|
912
|
+
|
|
913
|
+
<AnimatedScene>
|
|
914
|
+
<Text className="text-white text-4xl">React + WebCodecs</Text>
|
|
915
|
+
</AnimatedScene>
|
|
916
|
+
</Timegroup>
|
|
917
|
+
|
|
918
|
+
<div className="flex gap-4 items-center">
|
|
919
|
+
<select
|
|
920
|
+
value={codec}
|
|
921
|
+
onChange={(e) => setCodec(e.target.value as Codec)}
|
|
922
|
+
disabled={isRendering}
|
|
923
|
+
className="px-4 py-2 border rounded"
|
|
924
|
+
>
|
|
925
|
+
<option value="avc">H.264</option>
|
|
926
|
+
<option value="hevc">H.265</option>
|
|
927
|
+
<option value="vp9">VP9</option>
|
|
928
|
+
<option value="av1">AV1</option>
|
|
929
|
+
</select>
|
|
930
|
+
|
|
931
|
+
<button
|
|
932
|
+
onClick={handleExport}
|
|
933
|
+
disabled={isRendering}
|
|
934
|
+
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
|
935
|
+
>
|
|
936
|
+
{isRendering ? "Rendering..." : "Export Video"}
|
|
937
|
+
</button>
|
|
938
|
+
|
|
939
|
+
{isRendering && (
|
|
940
|
+
<button
|
|
941
|
+
onClick={handleCancel}
|
|
942
|
+
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
943
|
+
>
|
|
944
|
+
Cancel
|
|
945
|
+
</button>
|
|
946
|
+
)}
|
|
947
|
+
</div>
|
|
948
|
+
|
|
949
|
+
{progress && (
|
|
950
|
+
<div className="space-y-2">
|
|
951
|
+
<div className="flex justify-between text-sm">
|
|
952
|
+
<span>Frame {progress.currentFrame} of {progress.totalFrames}</span>
|
|
953
|
+
<span>{percent}%</span>
|
|
954
|
+
</div>
|
|
955
|
+
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
956
|
+
<div
|
|
957
|
+
className="bg-blue-600 h-3 rounded-full transition-all"
|
|
958
|
+
style={{ width: `${percent}%` }}
|
|
959
|
+
/>
|
|
960
|
+
</div>
|
|
961
|
+
<div className="text-xs text-gray-600">
|
|
962
|
+
Elapsed: {Math.round(progress.elapsedMs / 1000)}s |
|
|
963
|
+
Remaining: {Math.round(progress.estimatedRemainingMs / 1000)}s |
|
|
964
|
+
Speed: {progress.speedMultiplier.toFixed(1)}x
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
)}
|
|
968
|
+
|
|
969
|
+
{error && (
|
|
970
|
+
<div className="text-red-600 text-sm p-3 bg-red-50 rounded">
|
|
971
|
+
{error}
|
|
972
|
+
</div>
|
|
973
|
+
)}
|
|
974
|
+
</div>
|
|
975
|
+
);
|
|
976
|
+
};
|
|
977
|
+
```
|
|
978
|
+
<!-- /react-only -->
|
|
979
|
+
|
|
980
|
+
## Advanced Options
|
|
981
|
+
|
|
982
|
+
### High-Resolution Export
|
|
983
|
+
|
|
984
|
+
<!-- html-only -->
|
|
985
|
+
Render at higher resolution than the composition:
|
|
986
|
+
|
|
987
|
+
```javascript
|
|
988
|
+
await timegroup.renderToVideo({
|
|
989
|
+
scale: 2, // 2x resolution (2560x1440 from 1280x720)
|
|
990
|
+
bitrate: 10_000_000, // Increase bitrate for quality
|
|
991
|
+
filename: 'high-res.mp4'
|
|
992
|
+
});
|
|
993
|
+
```
|
|
994
|
+
<!-- /html-only -->
|
|
995
|
+
<!-- react-only -->
|
|
996
|
+
```tsx
|
|
997
|
+
await timegroupRef.current.renderToVideo({
|
|
998
|
+
scale: 2, // 2x resolution
|
|
999
|
+
bitrate: 10_000_000, // Higher bitrate for quality
|
|
1000
|
+
filename: "high-res.mp4"
|
|
1001
|
+
});
|
|
1002
|
+
```
|
|
1003
|
+
<!-- /react-only -->
|
|
1004
|
+
|
|
1005
|
+
### Partial Export
|
|
1006
|
+
|
|
1007
|
+
<!-- html-only -->
|
|
1008
|
+
Export only a portion of the composition:
|
|
1009
|
+
|
|
1010
|
+
```javascript
|
|
1011
|
+
await timegroup.renderToVideo({
|
|
1012
|
+
fromMs: 2000, // Start at 2 seconds
|
|
1013
|
+
toMs: 8000, // End at 8 seconds
|
|
1014
|
+
filename: 'clip.mp4'
|
|
1015
|
+
});
|
|
1016
|
+
```
|
|
1017
|
+
<!-- /html-only -->
|
|
1018
|
+
<!-- react-only -->
|
|
1019
|
+
```tsx
|
|
1020
|
+
await timegroupRef.current.renderToVideo({
|
|
1021
|
+
fromMs: 2000, // Start at 2 seconds
|
|
1022
|
+
toMs: 8000, // End at 8 seconds
|
|
1023
|
+
filename: "clip.mp4"
|
|
1024
|
+
});
|
|
1025
|
+
```
|
|
1026
|
+
<!-- /react-only -->
|
|
1027
|
+
|
|
1028
|
+
### Programmatic <!-- html-only -->Access<!-- /html-only --><!-- react-only -->Buffer Access<!-- /react-only -->
|
|
1029
|
+
|
|
1030
|
+
<!-- html-only -->
|
|
1031
|
+
Get the video as a buffer instead of downloading:
|
|
1032
|
+
|
|
1033
|
+
```javascript
|
|
1034
|
+
const videoBuffer = await timegroup.renderToVideo({
|
|
1035
|
+
returnBuffer: true,
|
|
1036
|
+
filename: 'video.mp4'
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// Upload to server
|
|
1040
|
+
const formData = new FormData();
|
|
1041
|
+
formData.append('video', new Blob([videoBuffer], { type: 'video/mp4' }));
|
|
1042
|
+
await fetch('/api/upload', { method: 'POST', body: formData });
|
|
1043
|
+
```
|
|
1044
|
+
<!-- /html-only -->
|
|
1045
|
+
<!-- react-only -->
|
|
1046
|
+
```tsx
|
|
1047
|
+
const videoBuffer = await timegroupRef.current.renderToVideo({
|
|
1048
|
+
returnBuffer: true,
|
|
1049
|
+
filename: "video.mp4"
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// Upload to server
|
|
1053
|
+
const formData = new FormData();
|
|
1054
|
+
formData.append("video", new Blob([videoBuffer], { type: "video/mp4" }));
|
|
1055
|
+
await fetch("/api/upload", { method: "POST", body: formData });
|
|
1056
|
+
```
|
|
1057
|
+
<!-- /react-only -->
|
|
1058
|
+
|
|
1059
|
+
<!-- react-only -->
|
|
1060
|
+
## TypeScript Types
|
|
1061
|
+
|
|
1062
|
+
Import types for proper typing:
|
|
1063
|
+
|
|
1064
|
+
```tsx
|
|
1065
|
+
import type {
|
|
1066
|
+
EFTimegroup,
|
|
1067
|
+
RenderProgress,
|
|
1068
|
+
RenderToVideoOptions
|
|
1069
|
+
} from "@editframe/elements";
|
|
1070
|
+
|
|
1071
|
+
const options: RenderToVideoOptions = {
|
|
1072
|
+
fps: 60,
|
|
1073
|
+
codec: "avc",
|
|
1074
|
+
bitrate: 8_000_000,
|
|
1075
|
+
onProgress: (progress: RenderProgress) => {
|
|
1076
|
+
console.log(progress.progress);
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
## Important Notes
|
|
1082
|
+
|
|
1083
|
+
1. **TimelineRoot Required**: Always wrap your composition with `TimelineRoot` for rendering to work correctly with React state and hooks
|
|
1084
|
+
2. **Refs for Access**: Use refs to access the underlying `EFTimegroup` element
|
|
1085
|
+
3. **State Management**: React state updates work normally during rendering thanks to `TimelineRoot`
|
|
1086
|
+
4. **Hook Support**: `useTimingInfo` and other hooks work in render clones
|
|
1087
|
+
<!-- /react-only -->
|
|
1088
|
+
|
|
1089
|
+
## Next Steps
|
|
1090
|
+
|
|
1091
|
+
<!-- html-only -->
|
|
1092
|
+
- See [render-api.md](references/render-api.md) for all options
|
|
1093
|
+
- See [render-strategies.md](references/render-strategies.md) for choosing between browser, CLI, and cloud rendering
|
|
1094
|
+
- See the `editframe-cli` skill for server-side rendering
|
|
1095
|
+
<!-- /html-only -->
|
|
1096
|
+
<!-- react-only -->
|
|
1097
|
+
- See [hooks.md](references/hooks.md) for `useTimingInfo` and other hooks
|
|
1098
|
+
- See [timegroup.md](references/timegroup.md) for composition structure
|
|
1099
|
+
- See [timeline-root.md](references/timeline-root.md) for why it's required
|
|
1100
|
+
- See the `editframe-cli` skill for server-side rendering
|
|
1101
|
+
<!-- /react-only -->
|