@jjlmoya/utils-audiovisual 1.18.0 → 1.19.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.
@@ -0,0 +1,263 @@
1
+ export interface VideoItem {
2
+ id: string;
3
+ file: File;
4
+ url: string;
5
+ name: string;
6
+ duration: number;
7
+ width: number;
8
+ height: number;
9
+ }
10
+
11
+ export function generateId(): string {
12
+ return Math.random().toString(36).substring(2, 9);
13
+ }
14
+
15
+ export function formatTime(seconds: number): string {
16
+ const date = new Date(seconds * 1000);
17
+ const mm = date.getUTCMinutes().toString().padStart(2, "0");
18
+ const ss = date.getUTCSeconds().toString().padStart(2, "0");
19
+ const ms = Math.floor(date.getUTCMilliseconds()).toString().padStart(3, "0");
20
+ return `${mm}:${ss}.${ms}`;
21
+ }
22
+
23
+ export function getVideoMetadata(file: File): Promise<{ duration: number; width: number; height: number }> {
24
+ return new Promise((resolve, reject) => {
25
+ const video = document.createElement("video");
26
+ video.preload = "metadata";
27
+ video.muted = true;
28
+ video.playsInline = true;
29
+ const url = URL.createObjectURL(file);
30
+ video.src = url;
31
+ video.onloadedmetadata = () => {
32
+ resolve({
33
+ duration: video.duration,
34
+ width: video.videoWidth,
35
+ height: video.videoHeight
36
+ });
37
+ URL.revokeObjectURL(url);
38
+ };
39
+ video.onerror = () => {
40
+ reject(new Error("Failed to load video metadata"));
41
+ URL.revokeObjectURL(url);
42
+ };
43
+ });
44
+ }
45
+
46
+ export interface MergeOptions {
47
+ width: number;
48
+ height: number;
49
+ fps: number;
50
+ }
51
+
52
+ export class VideoMergerEngine {
53
+ private items: VideoItem[] = [];
54
+ private videoElement: HTMLVideoElement;
55
+ private canvas: HTMLCanvasElement;
56
+ private ctx: CanvasRenderingContext2D | null = null;
57
+ private audioContext: AudioContext | null = null;
58
+ private audioDestination: MediaStreamAudioDestinationNode | null = null;
59
+ private mediaRecorder: MediaRecorder | null = null;
60
+ private recordedChunks: Blob[] = [];
61
+ private animationFrameId: number | null = null;
62
+ private isMergingState = false;
63
+
64
+ constructor() {
65
+ this.videoElement = document.createElement("video");
66
+ this.videoElement.muted = false;
67
+ this.videoElement.playsInline = true;
68
+ this.canvas = document.createElement("canvas");
69
+ }
70
+
71
+ public setItems(items: VideoItem[]) {
72
+ this.items = items;
73
+ }
74
+
75
+ public isMerging(): boolean {
76
+ return this.isMergingState;
77
+ }
78
+
79
+ public async merge(
80
+ options: MergeOptions,
81
+ onProgress: (progress: number, currentVideoName: string, index: number) => void
82
+ ): Promise<Blob> {
83
+ if (this.items.length === 0) {
84
+ throw new Error("No videos to merge");
85
+ }
86
+
87
+ this.isMergingState = true;
88
+ this.recordedChunks = [];
89
+
90
+ const { width, height, fps } = options;
91
+ this.canvas.width = width;
92
+ this.canvas.height = height;
93
+ this.ctx = this.canvas.getContext("2d");
94
+
95
+ if (!this.ctx) {
96
+ this.isMergingState = false;
97
+ throw new Error("Could not initialize canvas context");
98
+ }
99
+
100
+ const WebkitAudioContext = (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
101
+ this.audioContext = new (window.AudioContext || WebkitAudioContext)();
102
+ this.audioDestination = this.audioContext.createMediaStreamDestination();
103
+
104
+ const audioSource = this.audioContext.createMediaElementSource(this.videoElement);
105
+ audioSource.connect(this.audioDestination);
106
+
107
+ const videoStream = this.canvas.captureStream(fps);
108
+ const audioStream = this.audioDestination.stream;
109
+
110
+ const mergedStream = new MediaStream([
111
+ ...videoStream.getVideoTracks(),
112
+ ...audioStream.getAudioTracks()
113
+ ]);
114
+
115
+ let mimeType = "video/webm;codecs=vp9,opus";
116
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
117
+ mimeType = "video/webm;codecs=vp8,opus";
118
+ }
119
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
120
+ mimeType = "video/webm";
121
+ }
122
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
123
+ mimeType = "";
124
+ }
125
+
126
+ this.mediaRecorder = mimeType
127
+ ? new MediaRecorder(mergedStream, { mimeType })
128
+ : new MediaRecorder(mergedStream);
129
+
130
+ this.mediaRecorder.ondataavailable = (event) => {
131
+ if (event.data && event.data.size > 0) {
132
+ this.recordedChunks.push(event.data);
133
+ }
134
+ };
135
+
136
+ return new Promise<Blob>(async (resolve, reject) => {
137
+ if (!this.mediaRecorder) return reject(new Error("MediaRecorder not initialized"));
138
+
139
+ this.mediaRecorder.onstop = () => {
140
+ this.cleanup();
141
+ const blob = new Blob(this.recordedChunks, { type: "video/webm" });
142
+ resolve(blob);
143
+ };
144
+
145
+ this.mediaRecorder.onerror = (e) => {
146
+ this.cleanup();
147
+ reject(e);
148
+ };
149
+
150
+ this.mediaRecorder.start();
151
+
152
+ for (let i = 0; i < this.items.length; i++) {
153
+ const currentItem = this.items[i];
154
+ if (!currentItem) continue;
155
+ try {
156
+ await this.playAndRecordItem(currentItem, (prog, name) => {
157
+ onProgress(prog, name, i);
158
+ });
159
+ } catch (err) {
160
+ this.mediaRecorder.stop();
161
+ return reject(err);
162
+ }
163
+ }
164
+
165
+ this.mediaRecorder.stop();
166
+ });
167
+ }
168
+
169
+ private playAndRecordItem(
170
+ item: VideoItem,
171
+ onProgress: (progress: number, currentVideoName: string) => void
172
+ ): Promise<void> {
173
+ return new Promise<void>((resolve, reject) => {
174
+ this.videoElement.src = item.url;
175
+ this.videoElement.load();
176
+
177
+ const onCanPlay = () => {
178
+ this.videoElement.play().catch(reject);
179
+ this.drawLoop({
180
+ item,
181
+ onProgress,
182
+ onDone: resolve
183
+ });
184
+ this.videoElement.removeEventListener("canplaythrough", onCanPlay);
185
+ };
186
+
187
+ this.videoElement.addEventListener("canplaythrough", onCanPlay);
188
+ this.videoElement.onerror = () => {
189
+ this.videoElement.removeEventListener("canplaythrough", onCanPlay);
190
+ reject(new Error(`Error playing video: ${item.name}`));
191
+ };
192
+ });
193
+ }
194
+
195
+ private drawLoop(options: {
196
+ item: VideoItem;
197
+ onProgress: (progress: number, currentVideoName: string) => void;
198
+ onDone: () => void;
199
+ }) {
200
+ const { item, onProgress, onDone } = options;
201
+ const render = () => {
202
+ if (!this.ctx || this.videoElement.paused || this.videoElement.ended) {
203
+ if (this.videoElement.ended) {
204
+ onDone();
205
+ }
206
+ return;
207
+ }
208
+
209
+ this.ctx.fillStyle = "#000000";
210
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
211
+
212
+ const vWidth = this.videoElement.videoWidth;
213
+ const vHeight = this.videoElement.videoHeight;
214
+ const targetRatio = this.canvas.width / this.canvas.height;
215
+ const videoRatio = vWidth / vHeight;
216
+
217
+ let drawWidth = this.canvas.width;
218
+ let drawHeight = this.canvas.height;
219
+ let offsetX = 0;
220
+ let offsetY = 0;
221
+
222
+ if (videoRatio > targetRatio) {
223
+ drawHeight = this.canvas.width / videoRatio;
224
+ offsetY = (this.canvas.height - drawHeight) / 2;
225
+ } else {
226
+ drawWidth = this.canvas.height * videoRatio;
227
+ offsetX = (this.canvas.width - drawWidth) / 2;
228
+ }
229
+
230
+ this.ctx.drawImage(this.videoElement, offsetX, offsetY, drawWidth, drawHeight);
231
+
232
+ const progress = Math.min(100, (this.videoElement.currentTime / item.duration) * 100);
233
+ onProgress(progress, item.name);
234
+
235
+ this.animationFrameId = requestAnimationFrame(render);
236
+ };
237
+
238
+ this.animationFrameId = requestAnimationFrame(render);
239
+
240
+ const onEnded = () => {
241
+ if (this.animationFrameId) {
242
+ cancelAnimationFrame(this.animationFrameId);
243
+ }
244
+ this.videoElement.removeEventListener("ended", onEnded);
245
+ onDone();
246
+ };
247
+
248
+ this.videoElement.addEventListener("ended", onEnded);
249
+ }
250
+
251
+ private cleanup() {
252
+ this.isMergingState = false;
253
+ if (this.animationFrameId) {
254
+ cancelAnimationFrame(this.animationFrameId);
255
+ }
256
+ this.videoElement.pause();
257
+ this.videoElement.src = "";
258
+
259
+ if (this.audioContext) {
260
+ this.audioContext.close().catch(() => {});
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,440 @@
1
+ .vm-root {
2
+ --vm-bg: #fff;
3
+ --vm-bg-muted: #f8fafc;
4
+ --vm-bg-glass: #fff;
5
+ --vm-glass-border: #e2e8f0;
6
+ --vm-glass-text: #6366f1;
7
+ --vm-glass-muted: #94a3b8;
8
+ --vm-glass-btn-bg: #f8fafc;
9
+ --vm-glass-btn-border: #e2e8f0;
10
+ --vm-glass-btn-text: #1e293b;
11
+ --vm-border: #e2e8f0;
12
+ --vm-text: #1e293b;
13
+ --vm-text-muted: #94a3b8;
14
+ --vm-primary: #6366f1;
15
+ --vm-primary-light: rgba(99, 102, 241, 0.1);
16
+ --vm-shadow: 0 25px 60px rgba(0,0,0,0.08);
17
+ --vm-danger: #ef4444;
18
+ --vm-success: #10b981;
19
+
20
+ max-width: 860px;
21
+ margin: 0 auto;
22
+ padding: 1rem;
23
+ }
24
+
25
+ .theme-dark .vm-root {
26
+ --vm-bg: #18181b;
27
+ --vm-bg-muted: #09090b;
28
+ --vm-bg-glass: #27272a;
29
+ --vm-glass-border: #3f3f46;
30
+ --vm-glass-text: #818cf8;
31
+ --vm-glass-muted: #71717a;
32
+ --vm-glass-btn-bg: #3f3f46;
33
+ --vm-glass-btn-border: #52525b;
34
+ --vm-glass-btn-text: #f4f4f5;
35
+ --vm-border: #27272a;
36
+ --vm-text: #f4f4f5;
37
+ --vm-text-muted: #71717a;
38
+ --vm-primary: #818cf8;
39
+ --vm-primary-light: rgba(129, 140, 248, 0.12);
40
+ --vm-shadow: 0 25px 60px rgba(0,0,0,0.4);
41
+ --vm-danger: #f87171;
42
+ --vm-success: #34d399;
43
+ }
44
+
45
+ .vm-premium-card {
46
+ background: var(--vm-bg);
47
+ border: 1px solid var(--vm-border);
48
+ border-radius: 1.5rem;
49
+ box-shadow: var(--vm-shadow);
50
+ overflow: hidden;
51
+ padding: 1.5rem;
52
+ }
53
+
54
+ .vm-uploader-box {
55
+ padding: 4rem 2rem;
56
+ display: flex;
57
+ flex-direction: column;
58
+ align-items: center;
59
+ text-align: center;
60
+ gap: 0.625rem;
61
+ cursor: pointer;
62
+ border: 3px dashed var(--vm-border);
63
+ border-radius: 1.5rem;
64
+ margin-bottom: 1.5rem;
65
+ transition: border-color 0.2s, background 0.2s;
66
+ }
67
+
68
+ .vm-uploader-box:hover,
69
+ .vm-dragover {
70
+ border-color: var(--vm-primary);
71
+ background: var(--vm-primary-light);
72
+ }
73
+
74
+ .vm-uploader-icon {
75
+ width: 5rem;
76
+ height: 5rem;
77
+ background: var(--vm-primary-light);
78
+ border-radius: 1.25rem;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ color: var(--vm-primary);
83
+ margin-bottom: 0.5rem;
84
+ }
85
+
86
+ .vm-uploader-icon svg {
87
+ width: 2.5rem;
88
+ height: 2.5rem;
89
+ }
90
+
91
+ .vm-uploader-text h3 {
92
+ font-size: 1.5rem;
93
+ font-weight: 800;
94
+ color: var(--vm-text);
95
+ margin: 0 0 0.25rem;
96
+ }
97
+
98
+ .vm-uploader-text p {
99
+ color: var(--vm-text-muted);
100
+ font-size: 0.95rem;
101
+ margin: 0;
102
+ }
103
+
104
+ .vm-privacy-note {
105
+ font-size: 0.7rem;
106
+ font-weight: 700;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.08em;
109
+ color: var(--vm-text-muted);
110
+ margin-top: 0.5rem;
111
+ }
112
+
113
+ .vm-list-section {
114
+ margin-bottom: 1.5rem;
115
+ }
116
+
117
+ .vm-list-header {
118
+ display: flex;
119
+ justify-content: space-between;
120
+ align-items: center;
121
+ margin-bottom: 0.75rem;
122
+ }
123
+
124
+ .vm-list-header h4 {
125
+ font-size: 0.95rem;
126
+ font-weight: 800;
127
+ color: var(--vm-text);
128
+ margin: 0;
129
+ }
130
+
131
+ .vm-list-container {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 0.75rem;
135
+ max-height: 320px;
136
+ overflow-y: auto;
137
+ padding-right: 0.25rem;
138
+ scrollbar-width: thin;
139
+ }
140
+
141
+ .vm-empty-state {
142
+ padding: 2rem;
143
+ text-align: center;
144
+ color: var(--vm-text-muted);
145
+ border: 1px dashed var(--vm-border);
146
+ border-radius: 1rem;
147
+ font-size: 0.9rem;
148
+ }
149
+
150
+ .vm-item-card {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 1rem;
154
+ background: var(--vm-bg-muted);
155
+ border: 1px solid var(--vm-border);
156
+ border-radius: 1rem;
157
+ padding: 0.75rem 1rem;
158
+ transition: border-color 0.15s, transform 0.15s;
159
+ }
160
+
161
+ .vm-item-card:hover {
162
+ border-color: var(--vm-primary);
163
+ }
164
+
165
+ .vm-item-index {
166
+ font-size: 0.9rem;
167
+ font-weight: 800;
168
+ color: var(--vm-primary);
169
+ width: 1.5rem;
170
+ text-align: center;
171
+ }
172
+
173
+ .vm-item-details {
174
+ flex: 1;
175
+ min-width: 0;
176
+ }
177
+
178
+ .vm-item-name {
179
+ font-size: 0.9rem;
180
+ font-weight: 700;
181
+ color: var(--vm-text);
182
+ margin: 0 0 0.15rem;
183
+ white-space: nowrap;
184
+ overflow: hidden;
185
+ text-overflow: ellipsis;
186
+ }
187
+
188
+ .vm-item-meta {
189
+ font-size: 0.75rem;
190
+ color: var(--vm-text-muted);
191
+ display: flex;
192
+ gap: 0.75rem;
193
+ }
194
+
195
+ .vm-item-controls {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 0.5rem;
199
+ }
200
+
201
+ .vm-btn-icon {
202
+ background: var(--vm-glass-btn-bg);
203
+ border: 1px solid var(--vm-glass-btn-border);
204
+ color: var(--vm-glass-btn-text);
205
+ width: 2.25rem;
206
+ height: 2.25rem;
207
+ border-radius: 0.5rem;
208
+ display: flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ cursor: pointer;
212
+ transition: all 0.15s;
213
+ }
214
+
215
+ .vm-btn-icon:hover {
216
+ border-color: var(--vm-primary);
217
+ color: var(--vm-primary);
218
+ }
219
+
220
+ .vm-btn-icon.vm-danger:hover {
221
+ border-color: var(--vm-danger);
222
+ color: var(--vm-danger);
223
+ background: rgba(239, 68, 68, 0.08);
224
+ }
225
+
226
+ .vm-btn-icon svg {
227
+ width: 1.15rem;
228
+ height: 1.15rem;
229
+ }
230
+
231
+ .vm-controls-glass {
232
+ background: var(--vm-bg-glass);
233
+ border: 1px solid var(--vm-glass-border);
234
+ border-radius: 1rem;
235
+ padding: 1.25rem;
236
+ display: flex;
237
+ flex-direction: column;
238
+ gap: 1.25rem;
239
+ }
240
+
241
+ .vm-options-title {
242
+ font-size: 0.9rem;
243
+ font-weight: 800;
244
+ color: var(--vm-text);
245
+ margin: 0;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.05em;
248
+ }
249
+
250
+ .vm-options-grid {
251
+ display: grid;
252
+ grid-template-columns: 1fr 1fr;
253
+ gap: 1rem;
254
+ }
255
+
256
+ @media (max-width: 600px) {
257
+ .vm-options-grid {
258
+ grid-template-columns: 1fr;
259
+ }
260
+ }
261
+
262
+ .vm-select-group {
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: 0.35rem;
266
+ }
267
+
268
+ .vm-select-group label {
269
+ font-size: 0.75rem;
270
+ font-weight: 700;
271
+ color: var(--vm-text-muted);
272
+ }
273
+
274
+ .vm-select-group select {
275
+ background: var(--vm-bg-muted);
276
+ border: 1px solid var(--vm-border);
277
+ border-radius: 0.625rem;
278
+ padding: 0.625rem 2.25rem 0.625rem 0.75rem;
279
+ color: var(--vm-text);
280
+ font-size: 0.85rem;
281
+ font-weight: 700;
282
+ outline: none;
283
+ appearance: none;
284
+ -webkit-appearance: none;
285
+ -moz-appearance: none;
286
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236366f1'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2.5' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
287
+ background-repeat: no-repeat;
288
+ background-position: right 0.75rem center;
289
+ background-size: 1.1rem;
290
+ cursor: pointer;
291
+ transition: border-color 0.15s, box-shadow 0.15s;
292
+ }
293
+
294
+ .vm-select-group select:focus {
295
+ border-color: var(--vm-primary);
296
+ box-shadow: 0 0 0 3px var(--vm-primary-light);
297
+ }
298
+
299
+ .theme-dark .vm-select-group select {
300
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23818cf8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2.5' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
301
+ }
302
+
303
+ .vm-quality-note {
304
+ font-size: 0.75rem;
305
+ color: var(--vm-text-muted);
306
+ margin: 0;
307
+ }
308
+
309
+ .vm-progress-wrapper {
310
+ background: var(--vm-bg-muted);
311
+ border: 1px solid var(--vm-border);
312
+ border-radius: 1rem;
313
+ padding: 1.25rem;
314
+ display: flex;
315
+ flex-direction: column;
316
+ gap: 0.75rem;
317
+ }
318
+
319
+ .vm-progress-header {
320
+ display: flex;
321
+ justify-content: space-between;
322
+ align-items: center;
323
+ font-size: 0.85rem;
324
+ font-weight: 700;
325
+ color: var(--vm-text);
326
+ }
327
+
328
+ .vm-progress-bar {
329
+ width: 100%;
330
+ height: 8px;
331
+ display: flex;
332
+ gap: 6px;
333
+ }
334
+
335
+ .vm-progress-bar .vm-progress-fill {
336
+ height: 100%;
337
+ background: var(--vm-primary);
338
+ width: 0%;
339
+ transition: width 0.1s linear;
340
+ border-radius: 9999px;
341
+ flex: 1;
342
+ }
343
+
344
+ .vm-progress-segment {
345
+ flex: 1;
346
+ height: 100%;
347
+ background: var(--vm-border);
348
+ border-radius: 9999px;
349
+ overflow: hidden;
350
+ }
351
+
352
+ .vm-progress-segment-fill {
353
+ height: 100%;
354
+ background: var(--vm-primary);
355
+ width: 0%;
356
+ transition: width 0.1s linear;
357
+ }
358
+
359
+ .vm-actions-row {
360
+ display: flex;
361
+ align-items: center;
362
+ gap: 0.75rem;
363
+ }
364
+
365
+ .vm-btn-main {
366
+ display: inline-flex;
367
+ align-items: center;
368
+ justify-content: center;
369
+ gap: 0.5rem;
370
+ font-weight: 700;
371
+ font-size: 0.9rem;
372
+ padding: 0.75rem 1.5rem;
373
+ border: none;
374
+ border-radius: 0.75rem;
375
+ cursor: pointer;
376
+ transition: all 0.15s;
377
+ text-decoration: none;
378
+ flex: 1;
379
+ }
380
+
381
+ .vm-btn-primary {
382
+ background: var(--vm-primary);
383
+ color: #fff;
384
+ box-shadow: 0 4px 14px rgba(99, 102, 241, 0.3);
385
+ }
386
+
387
+ .vm-btn-primary:hover {
388
+ filter: brightness(1.1);
389
+ }
390
+
391
+ .vm-btn-primary:disabled {
392
+ opacity: 0.5;
393
+ cursor: not-allowed;
394
+ box-shadow: none;
395
+ }
396
+
397
+ .vm-btn-secondary {
398
+ background: var(--vm-glass-btn-bg);
399
+ border: 1px solid var(--vm-glass-btn-border);
400
+ color: var(--vm-glass-btn-text);
401
+ }
402
+
403
+ .vm-btn-secondary:hover {
404
+ border-color: var(--vm-primary);
405
+ color: var(--vm-primary);
406
+ }
407
+
408
+ .vm-btn-main svg {
409
+ width: 1.2rem;
410
+ height: 1.2rem;
411
+ flex-shrink: 0;
412
+ }
413
+
414
+ .vm-warning-box {
415
+ margin-top: 0.75rem;
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 0.5rem;
419
+ background: rgba(245, 158, 11, 0.1);
420
+ border: 1px solid rgba(245, 158, 11, 0.3);
421
+ color: #d97706;
422
+ padding: 0.75rem 1rem;
423
+ border-radius: 0.75rem;
424
+ font-size: 0.8rem;
425
+ font-weight: 700;
426
+ }
427
+
428
+ .theme-dark .vm-warning-box {
429
+ background: rgba(245, 158, 11, 0.06);
430
+ border-color: rgba(245, 158, 11, 0.2);
431
+ color: #fbbf24;
432
+ }
433
+
434
+ .vm-warning-box.vm-hidden {
435
+ display: none;
436
+ }
437
+
438
+ .vm-hidden {
439
+ display: none;
440
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { videoMerger } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await videoMerger.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}