@codellyson/framely-cli 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/commands/compositions.js +135 -0
- package/commands/preview.js +889 -0
- package/commands/render.js +295 -0
- package/commands/still.js +165 -0
- package/index.js +93 -0
- package/package.json +60 -0
- package/studio/App.css +605 -0
- package/studio/App.jsx +185 -0
- package/studio/CompositionsView.css +399 -0
- package/studio/CompositionsView.jsx +327 -0
- package/studio/PropsEditor.css +195 -0
- package/studio/PropsEditor.tsx +176 -0
- package/studio/RenderDialog.tsx +476 -0
- package/studio/ShareDialog.tsx +200 -0
- package/studio/index.ts +19 -0
- package/studio/player/Player.css +199 -0
- package/studio/player/Player.jsx +355 -0
- package/studio/styles/design-system.css +592 -0
- package/studio/styles/dialogs.css +420 -0
- package/studio/templates/AnimatedGradient.jsx +99 -0
- package/studio/templates/InstagramStory.jsx +172 -0
- package/studio/templates/LowerThird.jsx +139 -0
- package/studio/templates/ProductShowcase.jsx +162 -0
- package/studio/templates/SlideTransition.jsx +211 -0
- package/studio/templates/SocialIntro.jsx +122 -0
- package/studio/templates/SubscribeAnimation.jsx +186 -0
- package/studio/templates/TemplateCard.tsx +58 -0
- package/studio/templates/TemplateFilters.tsx +97 -0
- package/studio/templates/TemplatePreviewDialog.tsx +196 -0
- package/studio/templates/TemplatesMarketplace.css +686 -0
- package/studio/templates/TemplatesMarketplace.tsx +172 -0
- package/studio/templates/TextReveal.jsx +134 -0
- package/studio/templates/UseTemplateDialog.tsx +154 -0
- package/studio/templates/index.ts +45 -0
- package/utils/browser.js +188 -0
- package/utils/codecs.js +200 -0
- package/utils/logger.js +35 -0
- package/utils/props.js +42 -0
- package/utils/render.js +447 -0
- package/utils/validate.js +148 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { templatesApi } from '@codellyson/framely';
|
|
3
|
+
import type { Template, TemplatesFilterParams } from '@codellyson/framely';
|
|
4
|
+
import { TemplateCard } from './TemplateCard';
|
|
5
|
+
import { TemplateFilters } from './TemplateFilters';
|
|
6
|
+
import { TemplatePreviewDialog } from './TemplatePreviewDialog';
|
|
7
|
+
import { UseTemplateDialog } from './UseTemplateDialog';
|
|
8
|
+
import './TemplatesMarketplace.css';
|
|
9
|
+
|
|
10
|
+
export interface TemplatesMarketplaceProps {
|
|
11
|
+
onUseTemplate: (template: Template, customId: string, customProps?: Record<string, unknown>) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Main marketplace view for browsing templates
|
|
16
|
+
*/
|
|
17
|
+
export function TemplatesMarketplace({ onUseTemplate }: TemplatesMarketplaceProps) {
|
|
18
|
+
const [templates, setTemplates] = useState<Template[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [filters, setFilters] = useState<TemplatesFilterParams>({
|
|
22
|
+
sortBy: 'newest',
|
|
23
|
+
page: 1,
|
|
24
|
+
pageSize: 12,
|
|
25
|
+
});
|
|
26
|
+
const [hasMore, setHasMore] = useState(false);
|
|
27
|
+
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
|
28
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
29
|
+
const [useDialogOpen, setUseDialogOpen] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Fetch templates
|
|
32
|
+
const fetchTemplates = useCallback(
|
|
33
|
+
async (reset = false) => {
|
|
34
|
+
setLoading(true);
|
|
35
|
+
setError(null);
|
|
36
|
+
try {
|
|
37
|
+
const response = await templatesApi.getTemplates(filters);
|
|
38
|
+
setTemplates((prev) => (reset ? response.templates : [...prev, ...response.templates]));
|
|
39
|
+
setHasMore(response.hasMore);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[filters]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Fetch on filter change
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
fetchTemplates(true);
|
|
52
|
+
}, [filters.category, filters.search, filters.sortBy]);
|
|
53
|
+
|
|
54
|
+
// Handle filter changes
|
|
55
|
+
const handleFilterChange = useCallback((newFilters: Partial<TemplatesFilterParams>) => {
|
|
56
|
+
setFilters((prev) => ({ ...prev, ...newFilters, page: 1 }));
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Handle template click
|
|
60
|
+
const handleTemplateClick = useCallback((template: Template) => {
|
|
61
|
+
setSelectedTemplate(template);
|
|
62
|
+
setPreviewOpen(true);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Handle "Use Template" from preview
|
|
66
|
+
const handleUseFromPreview = useCallback(() => {
|
|
67
|
+
setPreviewOpen(false);
|
|
68
|
+
setUseDialogOpen(true);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Confirm using template
|
|
72
|
+
const handleConfirmUse = useCallback(
|
|
73
|
+
(customId: string, customProps: Record<string, unknown>) => {
|
|
74
|
+
if (selectedTemplate) {
|
|
75
|
+
onUseTemplate(selectedTemplate, customId, customProps);
|
|
76
|
+
setUseDialogOpen(false);
|
|
77
|
+
setSelectedTemplate(null);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[selectedTemplate, onUseTemplate]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Load more templates
|
|
84
|
+
const handleLoadMore = useCallback(() => {
|
|
85
|
+
setFilters((prev) => ({ ...prev, page: (prev.page || 1) + 1 }));
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
// Load more when page changes
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (filters.page && filters.page > 1) {
|
|
91
|
+
fetchTemplates(false);
|
|
92
|
+
}
|
|
93
|
+
}, [filters.page]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="templates-marketplace">
|
|
97
|
+
{/* Header */}
|
|
98
|
+
<div className="templates-header">
|
|
99
|
+
<h2>Templates Marketplace</h2>
|
|
100
|
+
<p className="templates-subtitle">
|
|
101
|
+
Browse and use professionally designed video templates
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Filters */}
|
|
106
|
+
<TemplateFilters filters={filters} onChange={handleFilterChange} />
|
|
107
|
+
|
|
108
|
+
{/* Content */}
|
|
109
|
+
{error ? (
|
|
110
|
+
<div className="templates-error">
|
|
111
|
+
<p>{error}</p>
|
|
112
|
+
<button type="button" onClick={() => fetchTemplates(true)}>
|
|
113
|
+
Retry
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
<div className="templates-grid">
|
|
119
|
+
{templates.map((template) => (
|
|
120
|
+
<TemplateCard
|
|
121
|
+
key={template.id}
|
|
122
|
+
template={template}
|
|
123
|
+
onClick={() => handleTemplateClick(template)}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
{loading &&
|
|
127
|
+
Array.from({ length: 4 }).map((_, i) => (
|
|
128
|
+
<div key={`skeleton-${i}`} className="template-card-skeleton" />
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{!loading && templates.length === 0 && (
|
|
133
|
+
<div className="templates-empty">
|
|
134
|
+
<p>No templates found</p>
|
|
135
|
+
{filters.search && (
|
|
136
|
+
<p className="templates-empty-hint">
|
|
137
|
+
Try adjusting your search or filters
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{hasMore && !loading && (
|
|
144
|
+
<div className="templates-load-more">
|
|
145
|
+
<button type="button" onClick={handleLoadMore}>
|
|
146
|
+
Load More
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Preview Dialog */}
|
|
154
|
+
<TemplatePreviewDialog
|
|
155
|
+
open={previewOpen}
|
|
156
|
+
template={selectedTemplate}
|
|
157
|
+
onClose={() => setPreviewOpen(false)}
|
|
158
|
+
onUseTemplate={handleUseFromPreview}
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
{/* Use Template Dialog */}
|
|
162
|
+
<UseTemplateDialog
|
|
163
|
+
open={useDialogOpen}
|
|
164
|
+
template={selectedTemplate}
|
|
165
|
+
onClose={() => setUseDialogOpen(false)}
|
|
166
|
+
onConfirm={handleConfirmUse}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default TemplatesMarketplace;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from '@codellyson/framely';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kinetic Text Reveal Template
|
|
5
|
+
*/
|
|
6
|
+
export function TextReveal({
|
|
7
|
+
text = 'Your text here',
|
|
8
|
+
fontSize = 120,
|
|
9
|
+
color = '#ffffff',
|
|
10
|
+
backgroundColor = '#000000',
|
|
11
|
+
}) {
|
|
12
|
+
const frame = useCurrentFrame();
|
|
13
|
+
const { width } = useVideoConfig();
|
|
14
|
+
|
|
15
|
+
const characters = text.split('');
|
|
16
|
+
const charDelay = 3; // frames between each character
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<AbsoluteFill
|
|
20
|
+
style={{
|
|
21
|
+
background: backgroundColor,
|
|
22
|
+
display: 'flex',
|
|
23
|
+
alignItems: 'center',
|
|
24
|
+
justifyContent: 'center',
|
|
25
|
+
overflow: 'hidden',
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
{/* Background animated lines */}
|
|
29
|
+
{[...Array(5)].map((_, i) => {
|
|
30
|
+
const lineY = interpolate(
|
|
31
|
+
frame,
|
|
32
|
+
[i * 10, i * 10 + 60],
|
|
33
|
+
[-50, 110],
|
|
34
|
+
{ extrapolateRight: 'clamp' }
|
|
35
|
+
);
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
key={i}
|
|
39
|
+
style={{
|
|
40
|
+
position: 'absolute',
|
|
41
|
+
left: 0,
|
|
42
|
+
right: 0,
|
|
43
|
+
top: `${lineY}%`,
|
|
44
|
+
height: 1,
|
|
45
|
+
background: `linear-gradient(90deg, transparent, ${color}22, transparent)`,
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
|
|
51
|
+
{/* Text container */}
|
|
52
|
+
<div
|
|
53
|
+
style={{
|
|
54
|
+
display: 'flex',
|
|
55
|
+
flexWrap: 'wrap',
|
|
56
|
+
justifyContent: 'center',
|
|
57
|
+
maxWidth: width * 0.9,
|
|
58
|
+
gap: '0 0.05em',
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{characters.map((char, i) => {
|
|
62
|
+
const charFrame = frame - i * charDelay;
|
|
63
|
+
|
|
64
|
+
// Character animations
|
|
65
|
+
const y = interpolate(charFrame, [0, 15], [100, 0], {
|
|
66
|
+
extrapolateLeft: 'clamp',
|
|
67
|
+
extrapolateRight: 'clamp',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const opacity = interpolate(charFrame, [0, 10], [0, 1], {
|
|
71
|
+
extrapolateLeft: 'clamp',
|
|
72
|
+
extrapolateRight: 'clamp',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const scale = interpolate(charFrame, [0, 10, 15], [0.5, 1.2, 1], {
|
|
76
|
+
extrapolateLeft: 'clamp',
|
|
77
|
+
extrapolateRight: 'clamp',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const rotate = interpolate(charFrame, [0, 15], [-20, 0], {
|
|
81
|
+
extrapolateLeft: 'clamp',
|
|
82
|
+
extrapolateRight: 'clamp',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Glow effect
|
|
86
|
+
const glowIntensity = interpolate(charFrame, [10, 20, 30], [0, 1, 0], {
|
|
87
|
+
extrapolateLeft: 'clamp',
|
|
88
|
+
extrapolateRight: 'clamp',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (char === ' ') {
|
|
92
|
+
return <span key={i} style={{ width: fontSize * 0.3 }} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<span
|
|
97
|
+
key={i}
|
|
98
|
+
style={{
|
|
99
|
+
display: 'inline-block',
|
|
100
|
+
fontSize,
|
|
101
|
+
fontWeight: 700,
|
|
102
|
+
color,
|
|
103
|
+
transform: `translateY(${y}px) scale(${scale}) rotate(${rotate}deg)`,
|
|
104
|
+
opacity,
|
|
105
|
+
textShadow: glowIntensity > 0
|
|
106
|
+
? `0 0 ${20 * glowIntensity}px ${color}, 0 0 ${40 * glowIntensity}px ${color}`
|
|
107
|
+
: 'none',
|
|
108
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{char}
|
|
112
|
+
</span>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Scan line effect */}
|
|
118
|
+
<div
|
|
119
|
+
style={{
|
|
120
|
+
position: 'absolute',
|
|
121
|
+
left: 0,
|
|
122
|
+
right: 0,
|
|
123
|
+
height: 4,
|
|
124
|
+
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
|
|
125
|
+
top: `${interpolate(frame, [0, 60], [-10, 110], { extrapolateRight: 'clamp' })}%`,
|
|
126
|
+
opacity: 0.5,
|
|
127
|
+
filter: 'blur(2px)',
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
</AbsoluteFill>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default TextReveal;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import type { Template } from '@codellyson/framely';
|
|
3
|
+
import { PropsEditor } from '../PropsEditor.tsx';
|
|
4
|
+
|
|
5
|
+
export interface UseTemplateDialogProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
template: Template | null;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onConfirm: (customId: string, customProps: Record<string, unknown>) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dialog for confirming template usage and setting custom composition ID
|
|
14
|
+
*/
|
|
15
|
+
export function UseTemplateDialog({
|
|
16
|
+
open,
|
|
17
|
+
template,
|
|
18
|
+
onClose,
|
|
19
|
+
onConfirm,
|
|
20
|
+
}: UseTemplateDialogProps) {
|
|
21
|
+
const [customId, setCustomId] = useState('');
|
|
22
|
+
const [customProps, setCustomProps] = useState<Record<string, unknown>>({});
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
25
|
+
|
|
26
|
+
// Initialize customId and props when template changes
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (template) {
|
|
29
|
+
setCustomId(`${template.id}-copy`);
|
|
30
|
+
setCustomProps({ ...template.defaultProps });
|
|
31
|
+
setError(null);
|
|
32
|
+
}
|
|
33
|
+
}, [template]);
|
|
34
|
+
|
|
35
|
+
// Focus input when dialog opens
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (open && inputRef.current) {
|
|
38
|
+
inputRef.current.focus();
|
|
39
|
+
inputRef.current.select();
|
|
40
|
+
}
|
|
41
|
+
}, [open]);
|
|
42
|
+
|
|
43
|
+
// Escape key handler
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open) return;
|
|
46
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
47
|
+
if (e.key === 'Escape') onClose();
|
|
48
|
+
};
|
|
49
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
50
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
51
|
+
}, [open, onClose]);
|
|
52
|
+
|
|
53
|
+
if (!open || !template) return null;
|
|
54
|
+
|
|
55
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
|
|
58
|
+
// Validate ID
|
|
59
|
+
const trimmedId = customId.trim();
|
|
60
|
+
if (!trimmedId) {
|
|
61
|
+
setError('Composition ID is required');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!/^[a-z0-9-]+$/i.test(trimmedId)) {
|
|
65
|
+
setError('ID can only contain letters, numbers, and hyphens');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onConfirm(trimmedId, customProps);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
className="template-preview-overlay"
|
|
75
|
+
role="dialog"
|
|
76
|
+
aria-modal="true"
|
|
77
|
+
aria-labelledby="use-dialog-title"
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
if (e.target === e.currentTarget) onClose();
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<div className="use-template-dialog">
|
|
83
|
+
{/* Header */}
|
|
84
|
+
<div className="template-preview-header">
|
|
85
|
+
<h2 id="use-dialog-title">Use Template</h2>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={onClose}
|
|
89
|
+
aria-label="Close"
|
|
90
|
+
className="template-dialog-close"
|
|
91
|
+
>
|
|
92
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
93
|
+
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
94
|
+
</svg>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Content */}
|
|
99
|
+
<form onSubmit={handleSubmit} className="use-template-content">
|
|
100
|
+
<p className="use-template-intro">
|
|
101
|
+
Add <strong>{template.name}</strong> as a new composition in your project.
|
|
102
|
+
</p>
|
|
103
|
+
|
|
104
|
+
<div className="use-template-form-group">
|
|
105
|
+
<label htmlFor="composition-id">Composition ID</label>
|
|
106
|
+
<input
|
|
107
|
+
ref={inputRef}
|
|
108
|
+
id="composition-id"
|
|
109
|
+
type="text"
|
|
110
|
+
value={customId}
|
|
111
|
+
onChange={(e) => {
|
|
112
|
+
setCustomId(e.target.value);
|
|
113
|
+
setError(null);
|
|
114
|
+
}}
|
|
115
|
+
placeholder="my-custom-video"
|
|
116
|
+
className={error ? 'has-error' : ''}
|
|
117
|
+
/>
|
|
118
|
+
{error && <span className="use-template-error">{error}</span>}
|
|
119
|
+
<span className="use-template-hint">
|
|
120
|
+
This ID will be used to reference the composition in your code
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Props Editor */}
|
|
125
|
+
{Object.keys(template.defaultProps).length > 0 && (
|
|
126
|
+
<div className="use-template-props">
|
|
127
|
+
<h4>Customize Properties</h4>
|
|
128
|
+
<PropsEditor
|
|
129
|
+
defaultProps={template.defaultProps}
|
|
130
|
+
onChange={setCustomProps}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* Footer */}
|
|
136
|
+
<div className="template-preview-footer">
|
|
137
|
+
<button type="button" onClick={onClose} className="template-btn-secondary">
|
|
138
|
+
Cancel
|
|
139
|
+
</button>
|
|
140
|
+
<button
|
|
141
|
+
type="submit"
|
|
142
|
+
className="template-btn-primary"
|
|
143
|
+
disabled={!customId.trim()}
|
|
144
|
+
>
|
|
145
|
+
Add to Project
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
</form>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default UseTemplateDialog;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates Index
|
|
3
|
+
*
|
|
4
|
+
* Export all template components and provide a registry for the marketplace.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ComponentType } from 'react';
|
|
8
|
+
import { SocialIntro } from './SocialIntro';
|
|
9
|
+
import { SubscribeAnimation } from './SubscribeAnimation';
|
|
10
|
+
import { LowerThird } from './LowerThird';
|
|
11
|
+
import { TextReveal } from './TextReveal';
|
|
12
|
+
import { AnimatedGradient } from './AnimatedGradient';
|
|
13
|
+
import { ProductShowcase } from './ProductShowcase';
|
|
14
|
+
import { InstagramStory } from './InstagramStory';
|
|
15
|
+
import { SlideTransition } from './SlideTransition';
|
|
16
|
+
|
|
17
|
+
// Template component registry - maps template IDs to their React components
|
|
18
|
+
export const templateComponents: Record<string, ComponentType<any>> = {
|
|
19
|
+
'social-intro-1': SocialIntro,
|
|
20
|
+
'youtube-subscribe': SubscribeAnimation,
|
|
21
|
+
'lower-third-1': LowerThird,
|
|
22
|
+
'text-reveal-1': TextReveal,
|
|
23
|
+
'gradient-bg-1': AnimatedGradient,
|
|
24
|
+
'promo-slide-1': ProductShowcase,
|
|
25
|
+
'instagram-story': InstagramStory,
|
|
26
|
+
'presentation-1': SlideTransition,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Get a template component by ID
|
|
30
|
+
export function getTemplateComponent(templateId: string): ComponentType<any> | null {
|
|
31
|
+
return templateComponents[templateId] || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Export individual components
|
|
35
|
+
export { SocialIntro } from './SocialIntro';
|
|
36
|
+
export { SubscribeAnimation } from './SubscribeAnimation';
|
|
37
|
+
export { LowerThird } from './LowerThird';
|
|
38
|
+
export { TextReveal } from './TextReveal';
|
|
39
|
+
export { AnimatedGradient } from './AnimatedGradient';
|
|
40
|
+
export { ProductShowcase } from './ProductShowcase';
|
|
41
|
+
export { InstagramStory } from './InstagramStory';
|
|
42
|
+
export { SlideTransition } from './SlideTransition';
|
|
43
|
+
|
|
44
|
+
// Marketplace UI
|
|
45
|
+
export { TemplatesMarketplace } from './TemplatesMarketplace';
|
package/utils/browser.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Utilities
|
|
3
|
+
*
|
|
4
|
+
* Manages headless browser instances for rendering.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default browser launch arguments for rendering.
|
|
11
|
+
*
|
|
12
|
+
* Security-related flags:
|
|
13
|
+
* - `--no-sandbox` / `--disable-setuid-sandbox`: Required in Docker/CI environments
|
|
14
|
+
* where Chrome's sandbox cannot be set up. The browser is only loading trusted
|
|
15
|
+
* local content (localhost), so the sandbox provides minimal additional protection.
|
|
16
|
+
* - `--disable-web-security`: Allows the renderer to load cross-origin resources
|
|
17
|
+
* (fonts, images, video) without CORS restrictions. Necessary because compositions
|
|
18
|
+
* may reference assets from CDNs or local file:// paths during rendering.
|
|
19
|
+
* - `--disable-features=IsolateOrigins` / `--disable-site-isolation-trials`:
|
|
20
|
+
* Reduces memory overhead by not isolating each origin into its own process.
|
|
21
|
+
* Only one origin (localhost) is loaded during rendering.
|
|
22
|
+
*
|
|
23
|
+
* Performance-related flags:
|
|
24
|
+
* - `--disable-gpu`: Headless rendering doesn't benefit from GPU compositing
|
|
25
|
+
* and GPU initialization can cause issues in server environments.
|
|
26
|
+
* - `--disable-dev-shm-usage`: Uses /tmp instead of /dev/shm which may be too
|
|
27
|
+
* small in Docker containers, preventing crashes on large pages.
|
|
28
|
+
* - `--disable-background-timer-throttling` / `--disable-backgrounding-occluded-windows`
|
|
29
|
+
* / `--disable-renderer-backgrounding`: Prevents Chrome from throttling timers
|
|
30
|
+
* and rendering in background tabs, ensuring consistent frame timing.
|
|
31
|
+
* - `--disable-ipc-flooding-protection`: Allows rapid frame updates without
|
|
32
|
+
* Chrome's IPC rate limiting interfering with the capture loop.
|
|
33
|
+
* - `--autoplay-policy=no-user-gesture-required`: Allows audio/video elements
|
|
34
|
+
* to play automatically for compositions that include media.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_ARGS = [
|
|
37
|
+
'--disable-web-security',
|
|
38
|
+
'--disable-features=IsolateOrigins',
|
|
39
|
+
'--disable-site-isolation-trials',
|
|
40
|
+
'--no-sandbox',
|
|
41
|
+
'--disable-setuid-sandbox',
|
|
42
|
+
'--disable-gpu',
|
|
43
|
+
'--disable-dev-shm-usage',
|
|
44
|
+
'--disable-background-timer-throttling',
|
|
45
|
+
'--disable-backgrounding-occluded-windows',
|
|
46
|
+
'--disable-renderer-backgrounding',
|
|
47
|
+
'--disable-ipc-flooding-protection',
|
|
48
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a new browser instance configured for rendering.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} options
|
|
55
|
+
* @param {number} [options.width=1920] - Viewport width
|
|
56
|
+
* @param {number} [options.height=1080] - Viewport height
|
|
57
|
+
* @param {number} [options.scale=1] - Device scale factor
|
|
58
|
+
* @param {string} [options.executablePath] - Custom browser executable
|
|
59
|
+
* @param {boolean} [options.headless=true] - Run in headless mode
|
|
60
|
+
* @returns {Promise<{ browser: Browser, page: Page }>}
|
|
61
|
+
*/
|
|
62
|
+
export async function createBrowser({
|
|
63
|
+
width = 1920,
|
|
64
|
+
height = 1080,
|
|
65
|
+
scale = 1,
|
|
66
|
+
executablePath,
|
|
67
|
+
headless = true,
|
|
68
|
+
} = {}) {
|
|
69
|
+
const launchOptions = {
|
|
70
|
+
args: DEFAULT_ARGS,
|
|
71
|
+
headless,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (executablePath) {
|
|
75
|
+
launchOptions.executablePath = executablePath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const browser = await chromium.launch(launchOptions);
|
|
79
|
+
|
|
80
|
+
const context = await browser.newContext({
|
|
81
|
+
viewport: {
|
|
82
|
+
width: Math.round(width * scale),
|
|
83
|
+
height: Math.round(height * scale),
|
|
84
|
+
},
|
|
85
|
+
deviceScaleFactor: 1,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
|
|
90
|
+
return { browser, page };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Close a browser instance.
|
|
95
|
+
*
|
|
96
|
+
* @param {Browser} browser
|
|
97
|
+
*/
|
|
98
|
+
export async function closeBrowser(browser) {
|
|
99
|
+
if (browser) {
|
|
100
|
+
try {
|
|
101
|
+
await browser.close();
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore close errors
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create multiple browser instances for parallel rendering.
|
|
110
|
+
*
|
|
111
|
+
* @param {number} count - Number of browser instances
|
|
112
|
+
* @param {object} options - Browser options (same as createBrowser)
|
|
113
|
+
* @returns {Promise<Array<{ browser: Browser, page: Page }>>}
|
|
114
|
+
*/
|
|
115
|
+
export async function createBrowserPool(count, options = {}) {
|
|
116
|
+
const browsers = await Promise.all(
|
|
117
|
+
Array.from({ length: count }, () => createBrowser(options))
|
|
118
|
+
);
|
|
119
|
+
return browsers;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Close all browsers in a pool.
|
|
124
|
+
*
|
|
125
|
+
* @param {Array<{ browser: Browser }>} pool
|
|
126
|
+
*/
|
|
127
|
+
export async function closeBrowserPool(pool) {
|
|
128
|
+
await Promise.all(pool.map(({ browser }) => closeBrowser(browser)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wait for all pending delay renders to complete.
|
|
133
|
+
*
|
|
134
|
+
* @param {Page} page - Playwright page
|
|
135
|
+
* @param {number} timeout - Timeout in milliseconds
|
|
136
|
+
*/
|
|
137
|
+
export async function waitForDelayRenders(page, timeout = 30000) {
|
|
138
|
+
await page.waitForFunction(
|
|
139
|
+
() => {
|
|
140
|
+
const dr = window.__FRAMELY_DELAY_RENDER;
|
|
141
|
+
return !dr || dr.pendingCount === 0;
|
|
142
|
+
},
|
|
143
|
+
{ timeout }
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Default timeout for waiting on delayRender and page readiness (ms). */
|
|
148
|
+
export const DEFAULT_TIMEOUT = 30000;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set the current frame and wait for render to complete.
|
|
152
|
+
*
|
|
153
|
+
* @param {Page} page - Playwright page
|
|
154
|
+
* @param {number} frame - Frame number to render
|
|
155
|
+
* @param {number} [timeout=30000] - Timeout in ms for delayRender
|
|
156
|
+
*/
|
|
157
|
+
export async function setFrame(page, frame, timeout = DEFAULT_TIMEOUT) {
|
|
158
|
+
// Set frame and check delayRender in a single evaluate round-trip
|
|
159
|
+
const hasDelayRender = await page.evaluate((f) => {
|
|
160
|
+
window.__setFrame(f);
|
|
161
|
+
const dr = window.__FRAMELY_DELAY_RENDER;
|
|
162
|
+
return dr && dr.pendingCount > 0;
|
|
163
|
+
}, frame);
|
|
164
|
+
|
|
165
|
+
// Only wait for delayRender if something is actually pending
|
|
166
|
+
if (hasDelayRender) {
|
|
167
|
+
try {
|
|
168
|
+
await page.waitForFunction(
|
|
169
|
+
() => {
|
|
170
|
+
const dr = window.__FRAMELY_DELAY_RENDER;
|
|
171
|
+
return !dr || dr.pendingCount === 0;
|
|
172
|
+
},
|
|
173
|
+
{ timeout }
|
|
174
|
+
);
|
|
175
|
+
} catch {
|
|
176
|
+
// Continue even if delayRender check fails
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default {
|
|
182
|
+
createBrowser,
|
|
183
|
+
closeBrowser,
|
|
184
|
+
createBrowserPool,
|
|
185
|
+
closeBrowserPool,
|
|
186
|
+
waitForDelayRenders,
|
|
187
|
+
setFrame,
|
|
188
|
+
};
|