@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.
Files changed (40) hide show
  1. package/commands/compositions.js +135 -0
  2. package/commands/preview.js +889 -0
  3. package/commands/render.js +295 -0
  4. package/commands/still.js +165 -0
  5. package/index.js +93 -0
  6. package/package.json +60 -0
  7. package/studio/App.css +605 -0
  8. package/studio/App.jsx +185 -0
  9. package/studio/CompositionsView.css +399 -0
  10. package/studio/CompositionsView.jsx +327 -0
  11. package/studio/PropsEditor.css +195 -0
  12. package/studio/PropsEditor.tsx +176 -0
  13. package/studio/RenderDialog.tsx +476 -0
  14. package/studio/ShareDialog.tsx +200 -0
  15. package/studio/index.ts +19 -0
  16. package/studio/player/Player.css +199 -0
  17. package/studio/player/Player.jsx +355 -0
  18. package/studio/styles/design-system.css +592 -0
  19. package/studio/styles/dialogs.css +420 -0
  20. package/studio/templates/AnimatedGradient.jsx +99 -0
  21. package/studio/templates/InstagramStory.jsx +172 -0
  22. package/studio/templates/LowerThird.jsx +139 -0
  23. package/studio/templates/ProductShowcase.jsx +162 -0
  24. package/studio/templates/SlideTransition.jsx +211 -0
  25. package/studio/templates/SocialIntro.jsx +122 -0
  26. package/studio/templates/SubscribeAnimation.jsx +186 -0
  27. package/studio/templates/TemplateCard.tsx +58 -0
  28. package/studio/templates/TemplateFilters.tsx +97 -0
  29. package/studio/templates/TemplatePreviewDialog.tsx +196 -0
  30. package/studio/templates/TemplatesMarketplace.css +686 -0
  31. package/studio/templates/TemplatesMarketplace.tsx +172 -0
  32. package/studio/templates/TextReveal.jsx +134 -0
  33. package/studio/templates/UseTemplateDialog.tsx +154 -0
  34. package/studio/templates/index.ts +45 -0
  35. package/utils/browser.js +188 -0
  36. package/utils/codecs.js +200 -0
  37. package/utils/logger.js +35 -0
  38. package/utils/props.js +42 -0
  39. package/utils/render.js +447 -0
  40. package/utils/validate.js +148 -0
@@ -0,0 +1,139 @@
1
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from '@codellyson/framely';
2
+
3
+ /**
4
+ * Clean Lower Third Template
5
+ */
6
+ export function LowerThird({
7
+ name = 'John Doe',
8
+ title = 'CEO & Founder',
9
+ social = '@johndoe',
10
+ accentColor = '#3b82f6',
11
+ }) {
12
+ const frame = useCurrentFrame();
13
+ const { fps, durationInFrames } = useVideoConfig();
14
+
15
+ // Entry animation
16
+ const entrySpring = spring({ frame, fps, config: { damping: 15, stiffness: 120 } });
17
+
18
+ // Line animation
19
+ const lineWidth = interpolate(frame, [0, 25], [0, 100], { extrapolateRight: 'clamp' });
20
+
21
+ // Text slide in
22
+ const nameX = interpolate(entrySpring, [0, 1], [-300, 0]);
23
+ const nameOpacity = interpolate(frame, [5, 20], [0, 1], { extrapolateRight: 'clamp' });
24
+
25
+ const titleX = interpolate(frame, [15, 35], [-200, 0], { extrapolateRight: 'clamp' });
26
+ const titleOpacity = interpolate(frame, [15, 30], [0, 1], { extrapolateRight: 'clamp' });
27
+
28
+ // Exit animation
29
+ const exitStart = durationInFrames - 30;
30
+ const exitProgress = interpolate(frame, [exitStart, durationInFrames], [0, 1], {
31
+ extrapolateLeft: 'clamp',
32
+ extrapolateRight: 'clamp',
33
+ });
34
+ const exitX = interpolate(exitProgress, [0, 1], [0, -400]);
35
+ const exitOpacity = interpolate(exitProgress, [0, 0.5], [1, 0], { extrapolateRight: 'clamp' });
36
+
37
+ return (
38
+ <AbsoluteFill style={{ background: 'transparent' }}>
39
+ {/* Lower third container */}
40
+ <div
41
+ style={{
42
+ position: 'absolute',
43
+ bottom: 80,
44
+ left: 60,
45
+ transform: `translateX(${exitX}px)`,
46
+ opacity: exitOpacity,
47
+ }}
48
+ >
49
+ {/* Background shape */}
50
+ <div
51
+ style={{
52
+ position: 'relative',
53
+ padding: '16px 24px',
54
+ background: 'rgba(0, 0, 0, 0.85)',
55
+ backdropFilter: 'blur(10px)',
56
+ borderRadius: 4,
57
+ borderLeft: `4px solid ${accentColor}`,
58
+ }}
59
+ >
60
+ {/* Accent line animation */}
61
+ <div
62
+ style={{
63
+ position: 'absolute',
64
+ top: 0,
65
+ left: 0,
66
+ width: `${lineWidth}%`,
67
+ height: 2,
68
+ background: `linear-gradient(90deg, ${accentColor}, transparent)`,
69
+ }}
70
+ />
71
+
72
+ {/* Name */}
73
+ <h2
74
+ style={{
75
+ margin: 0,
76
+ fontSize: 28,
77
+ fontWeight: 600,
78
+ color: '#fff',
79
+ transform: `translateX(${nameX}px)`,
80
+ opacity: nameOpacity,
81
+ letterSpacing: '0.02em',
82
+ }}
83
+ >
84
+ {name}
85
+ </h2>
86
+
87
+ {/* Title */}
88
+ <p
89
+ style={{
90
+ margin: '4px 0 0',
91
+ fontSize: 16,
92
+ fontWeight: 400,
93
+ color: accentColor,
94
+ transform: `translateX(${titleX}px)`,
95
+ opacity: titleOpacity,
96
+ textTransform: 'uppercase',
97
+ letterSpacing: '0.1em',
98
+ }}
99
+ >
100
+ {title}
101
+ </p>
102
+
103
+ {/* Social handle */}
104
+ {social && (
105
+ <p
106
+ style={{
107
+ margin: '8px 0 0',
108
+ fontSize: 14,
109
+ fontWeight: 400,
110
+ color: 'rgba(255, 255, 255, 0.6)',
111
+ transform: `translateX(${titleX}px)`,
112
+ opacity: titleOpacity * 0.8,
113
+ }}
114
+ >
115
+ {social}
116
+ </p>
117
+ )}
118
+ </div>
119
+
120
+ {/* Decorative element */}
121
+ <div
122
+ style={{
123
+ position: 'absolute',
124
+ right: -20,
125
+ top: '50%',
126
+ transform: 'translateY(-50%)',
127
+ width: 40,
128
+ height: 40,
129
+ border: `2px solid ${accentColor}`,
130
+ borderRadius: '50%',
131
+ opacity: interpolate(frame, [30, 45], [0, 0.5], { extrapolateRight: 'clamp' }),
132
+ }}
133
+ />
134
+ </div>
135
+ </AbsoluteFill>
136
+ );
137
+ }
138
+
139
+ export default LowerThird;
@@ -0,0 +1,162 @@
1
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from '@codellyson/framely';
2
+
3
+ /**
4
+ * Product Showcase Template
5
+ */
6
+ export function ProductShowcase({
7
+ productName = 'Product Name',
8
+ tagline = 'Amazing features await',
9
+ price = '$99.99',
10
+ ctaText = 'Shop Now',
11
+ brandColor = '#10b981',
12
+ }) {
13
+ const frame = useCurrentFrame();
14
+ const { fps, width, height } = useVideoConfig();
15
+
16
+ // Product placeholder (circle representing product)
17
+ const productSpring = spring({ frame, fps, config: { damping: 12, stiffness: 100 } });
18
+ const productScale = interpolate(productSpring, [0, 1], [0, 1]);
19
+ const productRotate = interpolate(frame, [0, 180], [0, 360]);
20
+
21
+ // Text animations
22
+ const nameY = interpolate(frame, [30, 50], [50, 0], { extrapolateRight: 'clamp' });
23
+ const nameOpacity = interpolate(frame, [30, 45], [0, 1], { extrapolateRight: 'clamp' });
24
+
25
+ const taglineY = interpolate(frame, [40, 60], [30, 0], { extrapolateRight: 'clamp' });
26
+ const taglineOpacity = interpolate(frame, [40, 55], [0, 1], { extrapolateRight: 'clamp' });
27
+
28
+ // Price pop
29
+ const priceSpring = spring({ frame: frame - 70, fps, config: { damping: 10, stiffness: 200 } });
30
+ const priceScale = interpolate(priceSpring, [0, 1], [0, 1]);
31
+
32
+ // CTA button
33
+ const ctaSpring = spring({ frame: frame - 100, fps, config: { damping: 15, stiffness: 150 } });
34
+ const ctaScale = interpolate(ctaSpring, [0, 1], [0, 1]);
35
+ const ctaY = interpolate(ctaSpring, [0, 1], [30, 0]);
36
+
37
+ // Pulse effect on CTA
38
+ const ctaPulse = frame > 120 ? 1 + Math.sin((frame - 120) * 0.15) * 0.05 : 1;
39
+
40
+ // Decorative circles
41
+ const circles = [
42
+ { size: 300, delay: 0, x: '20%', y: '30%' },
43
+ { size: 200, delay: 10, x: '80%', y: '60%' },
44
+ { size: 150, delay: 20, x: '70%', y: '20%' },
45
+ ];
46
+
47
+ return (
48
+ <AbsoluteFill
49
+ style={{
50
+ background: `linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, ${brandColor}22 100%)`,
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ overflow: 'hidden',
56
+ }}
57
+ >
58
+ {/* Background circles */}
59
+ {circles.map((circle, i) => {
60
+ const circleScale = interpolate(frame - circle.delay, [0, 40], [0, 1], {
61
+ extrapolateLeft: 'clamp',
62
+ extrapolateRight: 'clamp',
63
+ });
64
+ return (
65
+ <div
66
+ key={i}
67
+ style={{
68
+ position: 'absolute',
69
+ left: circle.x,
70
+ top: circle.y,
71
+ width: circle.size,
72
+ height: circle.size,
73
+ borderRadius: '50%',
74
+ border: `2px solid ${brandColor}33`,
75
+ transform: `translate(-50%, -50%) scale(${circleScale})`,
76
+ }}
77
+ />
78
+ );
79
+ })}
80
+
81
+ {/* Product placeholder */}
82
+ <div
83
+ style={{
84
+ width: Math.min(width, height) * 0.35,
85
+ height: Math.min(width, height) * 0.35,
86
+ borderRadius: '50%',
87
+ background: `linear-gradient(135deg, ${brandColor}, ${brandColor}88)`,
88
+ boxShadow: `0 20px 60px ${brandColor}44`,
89
+ transform: `scale(${productScale}) rotate(${productRotate}deg)`,
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ marginBottom: 30,
94
+ }}
95
+ >
96
+ <span style={{ fontSize: 60, filter: 'grayscale(1) brightness(10)' }}>📦</span>
97
+ </div>
98
+
99
+ {/* Product name */}
100
+ <h1
101
+ style={{
102
+ fontSize: Math.min(width, height) * 0.07,
103
+ fontWeight: 700,
104
+ color: '#fff',
105
+ margin: 0,
106
+ transform: `translateY(${nameY}px)`,
107
+ opacity: nameOpacity,
108
+ textAlign: 'center',
109
+ }}
110
+ >
111
+ {productName}
112
+ </h1>
113
+
114
+ {/* Tagline */}
115
+ <p
116
+ style={{
117
+ fontSize: Math.min(width, height) * 0.035,
118
+ color: 'rgba(255, 255, 255, 0.7)',
119
+ margin: '10px 0 20px',
120
+ transform: `translateY(${taglineY}px)`,
121
+ opacity: taglineOpacity,
122
+ textAlign: 'center',
123
+ }}
124
+ >
125
+ {tagline}
126
+ </p>
127
+
128
+ {/* Price */}
129
+ <div
130
+ style={{
131
+ fontSize: Math.min(width, height) * 0.08,
132
+ fontWeight: 700,
133
+ color: brandColor,
134
+ transform: `scale(${priceScale})`,
135
+ marginBottom: 20,
136
+ }}
137
+ >
138
+ {price}
139
+ </div>
140
+
141
+ {/* CTA Button */}
142
+ <button
143
+ style={{
144
+ padding: '16px 40px',
145
+ fontSize: Math.min(width, height) * 0.035,
146
+ fontWeight: 600,
147
+ color: '#fff',
148
+ background: brandColor,
149
+ border: 'none',
150
+ borderRadius: 50,
151
+ transform: `scale(${ctaScale * ctaPulse}) translateY(${ctaY}px)`,
152
+ boxShadow: `0 10px 30px ${brandColor}66`,
153
+ cursor: 'pointer',
154
+ }}
155
+ >
156
+ {ctaText}
157
+ </button>
158
+ </AbsoluteFill>
159
+ );
160
+ }
161
+
162
+ export default ProductShowcase;
@@ -0,0 +1,211 @@
1
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from '@codellyson/framely';
2
+
3
+ /**
4
+ * Slide Transition Template
5
+ * Smooth slide transitions for presentations
6
+ */
7
+ export function SlideTransition({
8
+ transitionType = 'slide',
9
+ direction = 'left',
10
+ fromContent = 'Slide 1',
11
+ toContent = 'Slide 2',
12
+ backgroundColor = '#1a1a2e',
13
+ slideColor = '#16213e',
14
+ textColor = '#ffffff',
15
+ }) {
16
+ const frame = useCurrentFrame();
17
+ const { fps, width, durationInFrames } = useVideoConfig();
18
+
19
+ const midpoint = durationInFrames / 2;
20
+ const progress = frame / durationInFrames;
21
+
22
+ // Transition animation
23
+ const transitionSpring = spring({
24
+ frame: frame - midpoint / 2,
25
+ fps,
26
+ config: { damping: 15, stiffness: 100 },
27
+ });
28
+
29
+ // Calculate slide positions based on transition type
30
+ const getTransform = (isOutgoing) => {
31
+ const springValue = isOutgoing ? transitionSpring : 1 - transitionSpring;
32
+
33
+ switch (transitionType) {
34
+ case 'slide': {
35
+ const slideOffset = interpolate(springValue, [0, 1], [0, 100]);
36
+ if (direction === 'left') {
37
+ return isOutgoing
38
+ ? `translateX(${-slideOffset}%)`
39
+ : `translateX(${100 - slideOffset}%)`;
40
+ } else if (direction === 'right') {
41
+ return isOutgoing
42
+ ? `translateX(${slideOffset}%)`
43
+ : `translateX(${-100 + slideOffset}%)`;
44
+ } else if (direction === 'up') {
45
+ return isOutgoing
46
+ ? `translateY(${-slideOffset}%)`
47
+ : `translateY(${100 - slideOffset}%)`;
48
+ } else {
49
+ return isOutgoing
50
+ ? `translateY(${slideOffset}%)`
51
+ : `translateY(${-100 + slideOffset}%)`;
52
+ }
53
+ }
54
+
55
+ case 'zoom': {
56
+ const zoomScale = isOutgoing
57
+ ? interpolate(springValue, [0, 1], [1, 0.5])
58
+ : interpolate(springValue, [0, 1], [1.5, 1]);
59
+ const zoomOpacity = isOutgoing
60
+ ? interpolate(springValue, [0, 1], [1, 0])
61
+ : interpolate(springValue, [0, 1], [0, 1]);
62
+ return { transform: `scale(${zoomScale})`, opacity: zoomOpacity };
63
+ }
64
+
65
+ case 'fade': {
66
+ const fadeOpacity = isOutgoing
67
+ ? interpolate(springValue, [0, 1], [1, 0])
68
+ : interpolate(springValue, [0, 1], [0, 1]);
69
+ return { opacity: fadeOpacity };
70
+ }
71
+
72
+ case 'flip': {
73
+ const flipRotation = isOutgoing
74
+ ? interpolate(springValue, [0, 1], [0, -90])
75
+ : interpolate(springValue, [0, 1], [90, 0]);
76
+ const flipOpacity = Math.abs(flipRotation) > 85 ? 0 : 1;
77
+ return {
78
+ transform: `perspective(1000px) rotateY(${flipRotation}deg)`,
79
+ opacity: flipOpacity,
80
+ };
81
+ }
82
+
83
+ default:
84
+ return {};
85
+ }
86
+ };
87
+
88
+ const outgoingTransform = getTransform(true);
89
+ const incomingTransform = getTransform(false);
90
+
91
+ // Slide content component
92
+ const SlideContent = ({ content, style }) => (
93
+ <div
94
+ style={{
95
+ position: 'absolute',
96
+ inset: 0,
97
+ display: 'flex',
98
+ alignItems: 'center',
99
+ justifyContent: 'center',
100
+ background: slideColor,
101
+ ...(typeof style === 'string' ? { transform: style } : style),
102
+ }}
103
+ >
104
+ <div
105
+ style={{
106
+ fontSize: width * 0.06,
107
+ fontWeight: 700,
108
+ color: textColor,
109
+ textAlign: 'center',
110
+ padding: 40,
111
+ }}
112
+ >
113
+ {content}
114
+ </div>
115
+
116
+ {/* Decorative corners */}
117
+ {['top-left', 'top-right', 'bottom-left', 'bottom-right'].map((corner) => (
118
+ <div
119
+ key={corner}
120
+ style={{
121
+ position: 'absolute',
122
+ width: 60,
123
+ height: 60,
124
+ borderColor: textColor,
125
+ borderStyle: 'solid',
126
+ borderWidth: 0,
127
+ opacity: 0.3,
128
+ ...(corner.includes('top') ? { top: 40 } : { bottom: 40 }),
129
+ ...(corner.includes('left') ? { left: 40 } : { right: 40 }),
130
+ ...(corner.includes('top') && corner.includes('left') && {
131
+ borderTopWidth: 3,
132
+ borderLeftWidth: 3,
133
+ }),
134
+ ...(corner.includes('top') && corner.includes('right') && {
135
+ borderTopWidth: 3,
136
+ borderRightWidth: 3,
137
+ }),
138
+ ...(corner.includes('bottom') && corner.includes('left') && {
139
+ borderBottomWidth: 3,
140
+ borderLeftWidth: 3,
141
+ }),
142
+ ...(corner.includes('bottom') && corner.includes('right') && {
143
+ borderBottomWidth: 3,
144
+ borderRightWidth: 3,
145
+ }),
146
+ }}
147
+ />
148
+ ))}
149
+ </div>
150
+ );
151
+
152
+ // Progress indicator
153
+ const progressWidth = interpolate(progress, [0, 1], [0, 100]);
154
+
155
+ return (
156
+ <AbsoluteFill style={{ background: backgroundColor, overflow: 'hidden' }}>
157
+ {/* Outgoing slide */}
158
+ <SlideContent
159
+ content={fromContent}
160
+ style={typeof outgoingTransform === 'string' ? outgoingTransform : outgoingTransform}
161
+ isActive={frame < midpoint}
162
+ />
163
+
164
+ {/* Incoming slide */}
165
+ <SlideContent
166
+ content={toContent}
167
+ style={typeof incomingTransform === 'string' ? incomingTransform : incomingTransform}
168
+ isActive={frame >= midpoint}
169
+ />
170
+
171
+ {/* Progress bar at bottom */}
172
+ <div
173
+ style={{
174
+ position: 'absolute',
175
+ bottom: 0,
176
+ left: 0,
177
+ right: 0,
178
+ height: 4,
179
+ background: 'rgba(255,255,255,0.1)',
180
+ }}
181
+ >
182
+ <div
183
+ style={{
184
+ width: `${progressWidth}%`,
185
+ height: '100%',
186
+ background: textColor,
187
+ opacity: 0.5,
188
+ }}
189
+ />
190
+ </div>
191
+
192
+ {/* Transition type label */}
193
+ <div
194
+ style={{
195
+ position: 'absolute',
196
+ top: 20,
197
+ right: 20,
198
+ fontSize: 14,
199
+ color: textColor,
200
+ opacity: 0.5,
201
+ fontFamily: 'monospace',
202
+ textTransform: 'uppercase',
203
+ }}
204
+ >
205
+ {transitionType} • {direction}
206
+ </div>
207
+ </AbsoluteFill>
208
+ );
209
+ }
210
+
211
+ export default SlideTransition;
@@ -0,0 +1,122 @@
1
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from '@codellyson/framely';
2
+
3
+ /**
4
+ * Modern Social Intro Template
5
+ * Clean intro animation for social media videos
6
+ */
7
+ export function SocialIntro({
8
+ title = 'Your Title Here',
9
+ subtitle = 'Subtitle text',
10
+ accentColor = '#6366f1',
11
+ }) {
12
+ const frame = useCurrentFrame();
13
+ const { fps, width, height } = useVideoConfig();
14
+
15
+ // Background gradient animation
16
+ const gradientProgress = interpolate(frame, [0, 60], [0, 1], { extrapolateRight: 'clamp' });
17
+
18
+ // Title animation
19
+ const titleSpring = spring({ frame, fps, config: { damping: 15, stiffness: 100 } });
20
+ const titleY = interpolate(titleSpring, [0, 1], [100, 0]);
21
+ const titleOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
22
+
23
+ // Subtitle animation (delayed)
24
+ const subtitleSpring = spring({ frame: frame - 15, fps, config: { damping: 15, stiffness: 100 } });
25
+ const subtitleY = interpolate(subtitleSpring, [0, 1], [50, 0]);
26
+ const subtitleOpacity = interpolate(frame, [15, 35], [0, 1], { extrapolateRight: 'clamp' });
27
+
28
+ // Accent line animation
29
+ const lineWidth = interpolate(frame, [30, 60], [0, 200], { extrapolateRight: 'clamp' });
30
+
31
+ // Exit animation
32
+ const exitProgress = interpolate(frame, [70, 90], [0, 1], { extrapolateRight: 'clamp' });
33
+ const scale = interpolate(exitProgress, [0, 1], [1, 0.8]);
34
+ const overallOpacity = interpolate(exitProgress, [0, 1], [1, 0]);
35
+
36
+ return (
37
+ <AbsoluteFill
38
+ style={{
39
+ background: `linear-gradient(135deg, #0a0a0f ${gradientProgress * 30}%, ${accentColor}22 100%)`,
40
+ display: 'flex',
41
+ flexDirection: 'column',
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ transform: `scale(${scale})`,
45
+ opacity: overallOpacity,
46
+ }}
47
+ >
48
+ {/* Background particles */}
49
+ <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
50
+ {[...Array(20)].map((_, i) => {
51
+ const delay = i * 3;
52
+ const particleY = interpolate(
53
+ frame - delay,
54
+ [0, 90],
55
+ [height + 50, -50],
56
+ { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
57
+ );
58
+ const particleX = (i * 97) % width;
59
+ const size = 4 + (i % 3) * 2;
60
+ return (
61
+ <div
62
+ key={i}
63
+ style={{
64
+ position: 'absolute',
65
+ left: particleX,
66
+ top: particleY,
67
+ width: size,
68
+ height: size,
69
+ borderRadius: '50%',
70
+ background: accentColor,
71
+ opacity: 0.3,
72
+ }}
73
+ />
74
+ );
75
+ })}
76
+ </div>
77
+
78
+ {/* Content */}
79
+ <div style={{ textAlign: 'center', zIndex: 1 }}>
80
+ <h1
81
+ style={{
82
+ fontSize: Math.min(width, height) * 0.08,
83
+ fontWeight: 700,
84
+ color: '#fff',
85
+ margin: 0,
86
+ transform: `translateY(${titleY}px)`,
87
+ opacity: titleOpacity,
88
+ textShadow: `0 4px 30px ${accentColor}66`,
89
+ }}
90
+ >
91
+ {title}
92
+ </h1>
93
+
94
+ {/* Accent line */}
95
+ <div
96
+ style={{
97
+ width: lineWidth,
98
+ height: 4,
99
+ background: `linear-gradient(90deg, transparent, ${accentColor}, transparent)`,
100
+ margin: '20px auto',
101
+ borderRadius: 2,
102
+ }}
103
+ />
104
+
105
+ <p
106
+ style={{
107
+ fontSize: Math.min(width, height) * 0.035,
108
+ fontWeight: 400,
109
+ color: 'rgba(255, 255, 255, 0.7)',
110
+ margin: 0,
111
+ transform: `translateY(${subtitleY}px)`,
112
+ opacity: subtitleOpacity,
113
+ }}
114
+ >
115
+ {subtitle}
116
+ </p>
117
+ </div>
118
+ </AbsoluteFill>
119
+ );
120
+ }
121
+
122
+ export default SocialIntro;