@agimon-ai/video-editor-mcp 0.7.0 → 0.8.2

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 (39) hide show
  1. package/README.md +117 -5
  2. package/dist/cli.cjs +9 -4
  3. package/dist/cli.mjs +9 -4
  4. package/dist/index.cjs +1 -2
  5. package/dist/index.mjs +1 -2
  6. package/dist/stdio-CH0Bk0m7.cjs +17 -0
  7. package/dist/stdio-KMqWPqUm.mjs +17 -0
  8. package/package.json +12 -4
  9. package/schemas/main-composition.schema.json +2283 -0
  10. package/src/remotion/Root.tsx +87 -0
  11. package/src/remotion/compositions/Main.tsx +51 -0
  12. package/src/remotion/compositions/Square.tsx +18 -0
  13. package/src/remotion/compositions/Vertical.tsx +18 -0
  14. package/src/remotion/compositions/audio/AudioLayer.tsx +130 -0
  15. package/src/remotion/compositions/captions/CaptionOverlay.tsx +250 -0
  16. package/src/remotion/compositions/captions/index.ts +1 -0
  17. package/src/remotion/compositions/clips/AudioClipRenderer.tsx +66 -0
  18. package/src/remotion/compositions/clips/GifClipRenderer.tsx +71 -0
  19. package/src/remotion/compositions/clips/ImageClipRenderer.tsx +103 -0
  20. package/src/remotion/compositions/clips/LottieClipRenderer.tsx +57 -0
  21. package/src/remotion/compositions/clips/SubtitleClipRenderer.tsx +85 -0
  22. package/src/remotion/compositions/clips/TextClipRenderer.tsx +131 -0
  23. package/src/remotion/compositions/clips/VideoClipRenderer.tsx +94 -0
  24. package/src/remotion/compositions/clips/index.tsx +51 -0
  25. package/src/remotion/index.ts +10 -0
  26. package/src/remotion/utils/calculateMainMetadata.ts +76 -0
  27. package/src/schemas/clips.ts +202 -0
  28. package/src/schemas/compositions.ts +160 -0
  29. package/src/schemas/index.ts +19 -0
  30. package/src/utils/assetPaths.ts +64 -0
  31. package/src/utils/index.ts +9 -0
  32. package/dist/cli.cjs.map +0 -1
  33. package/dist/cli.mjs.map +0 -1
  34. package/dist/index.cjs.map +0 -1
  35. package/dist/index.mjs.map +0 -1
  36. package/dist/stdio-B6Hg5voC.mjs +0 -4
  37. package/dist/stdio-B6Hg5voC.mjs.map +0 -1
  38. package/dist/stdio-Dwc6SWvA.cjs +0 -4
  39. package/dist/stdio-Dwc6SWvA.cjs.map +0 -1
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Remotion Root Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion root component pattern
6
+ * - Folder organization for composition groups
7
+ * - Type-safe props with Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - Register all compositions
11
+ * - Use calculateMetadata for dynamic duration
12
+ * - Use schema prop for visual editing in Remotion Studio
13
+ * - Group compositions in folders
14
+ */
15
+
16
+ import { Composition, Folder } from 'remotion';
17
+ import { MainCompositionSchema } from '../schemas/index.js';
18
+ import type { MainCompositionProps } from '../types/index.js';
19
+ import { Main } from './compositions/Main';
20
+ import { Square } from './compositions/Square';
21
+ import { Vertical } from './compositions/Vertical';
22
+ import { calculateMainMetadata } from './utils/calculateMainMetadata';
23
+
24
+ const defaultProps: MainCompositionProps = {
25
+ clips: [],
26
+ backgroundColor: '#000',
27
+ fps: 30,
28
+ width: 1920,
29
+ height: 1080,
30
+ };
31
+
32
+ const verticalDefaultProps: MainCompositionProps = {
33
+ ...defaultProps,
34
+ width: 1080,
35
+ height: 1920,
36
+ };
37
+
38
+ const squareDefaultProps: MainCompositionProps = {
39
+ ...defaultProps,
40
+ width: 1080,
41
+ height: 1080,
42
+ };
43
+
44
+ export const RemotionRoot: React.FC = () => {
45
+ return (
46
+ <>
47
+ <Folder name="Standard">
48
+ <Composition
49
+ id="Main"
50
+ component={Main}
51
+ durationInFrames={300}
52
+ fps={30}
53
+ width={1920}
54
+ height={1080}
55
+ defaultProps={defaultProps}
56
+ calculateMetadata={calculateMainMetadata}
57
+ schema={MainCompositionSchema}
58
+ />
59
+ </Folder>
60
+
61
+ <Folder name="Social">
62
+ <Composition
63
+ id="Vertical"
64
+ component={Vertical}
65
+ durationInFrames={300}
66
+ fps={30}
67
+ width={1080}
68
+ height={1920}
69
+ defaultProps={verticalDefaultProps}
70
+ calculateMetadata={calculateMainMetadata}
71
+ schema={MainCompositionSchema}
72
+ />
73
+ <Composition
74
+ id="Square"
75
+ component={Square}
76
+ durationInFrames={300}
77
+ fps={30}
78
+ width={1080}
79
+ height={1080}
80
+ defaultProps={squareDefaultProps}
81
+ calculateMetadata={calculateMainMetadata}
82
+ schema={MainCompositionSchema}
83
+ />
84
+ </Folder>
85
+ </>
86
+ );
87
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Main Composition
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with Sequence
6
+ * - Type-safe props from Zod schema
7
+ * - Dispatcher pattern for clip rendering
8
+ *
9
+ * CODING STANDARDS:
10
+ * - Use ClipRenderer for type-safe rendering
11
+ * - Support captions overlay
12
+ * - Load fonts from props
13
+ * - Use premountFor for smoother playback
14
+ */
15
+
16
+ import { AbsoluteFill, Sequence } from 'remotion';
17
+ import type { MainCompositionProps } from '../../types/index.js';
18
+ import { AudioLayer } from './audio/AudioLayer';
19
+ import { CaptionOverlay } from './captions';
20
+ import { ClipRenderer } from './clips';
21
+
22
+ export const createClipSequenceKey = (
23
+ clip: NonNullable<MainCompositionProps['clips']>[number],
24
+ index: number,
25
+ ): string => `${index}-${clip.type}-${clip.startFrame}-${clip.durationInFrames}`;
26
+
27
+ export const Main: React.FC<MainCompositionProps> = ({ clips = [], backgroundColor = '#000', captions, audio }) => {
28
+ // TODO: Load custom fonts - implement font loading using @remotion/google-fonts
29
+
30
+ return (
31
+ <AbsoluteFill style={{ backgroundColor }}>
32
+ {/* Render all clips */}
33
+ {clips.map((clip, index) => (
34
+ <Sequence
35
+ key={createClipSequenceKey(clip, index)}
36
+ from={clip.startFrame}
37
+ durationInFrames={clip.durationInFrames}
38
+ premountFor={clip.premountFor}
39
+ >
40
+ <ClipRenderer {...clip} />
41
+ </Sequence>
42
+ ))}
43
+
44
+ {/* Render captions overlay if provided */}
45
+ {captions && <CaptionOverlay {...captions} />}
46
+
47
+ {/* Render timeline-spanning audio layer (voiceover, music bed, sfx) */}
48
+ {audio && <AudioLayer {...audio} />}
49
+ </AbsoluteFill>
50
+ );
51
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Square Composition (Instagram Post format)
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Wrapper composition for social media square format
6
+ * - Reuses Main composition with fixed dimensions
7
+ *
8
+ * CODING STANDARDS:
9
+ * - 1080x1080 for Instagram posts
10
+ * - Type-safe props from Zod schema
11
+ */
12
+
13
+ import type { MainCompositionProps } from '../../types/index.js';
14
+ import { Main } from './Main';
15
+
16
+ export const Square: React.FC<MainCompositionProps> = (props) => {
17
+ return <Main {...props} />;
18
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Vertical Composition (TikTok/Reels format)
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Wrapper composition for social media vertical format
6
+ * - Reuses Main composition with fixed dimensions
7
+ *
8
+ * CODING STANDARDS:
9
+ * - 1080x1920 for TikTok/Instagram Reels
10
+ * - Type-safe props from Zod schema
11
+ */
12
+
13
+ import type { MainCompositionProps } from '../../types/index.js';
14
+ import { Main } from './Main';
15
+
16
+ export const Vertical: React.FC<MainCompositionProps> = (props) => {
17
+ return <Main {...props} />;
18
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * AudioLayer Component
3
+ *
4
+ * Renders the timeline-spanning audio layer (voiceover, music bed, point-in-time
5
+ * SFX). Distinct from clip-level audio: layer tracks span 0..duration without
6
+ * the caller computing frames, and music auto-loops over the timeline.
7
+ */
8
+
9
+ import { Audio, Sequence, interpolate, useVideoConfig } from 'remotion';
10
+ import type { AudioLayer as AudioLayerProps, MusicTrack, SfxEntry, VoiceoverTrack } from '../../../types/index.js';
11
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
12
+
13
+ const msToFrames = (ms: number, fps: number): number => Math.max(0, Math.round((ms / 1000) * fps));
14
+
15
+ const buildFadeVolume = (
16
+ baseVolume: number,
17
+ fadeInMs: number,
18
+ fadeOutMs: number,
19
+ spanFrames: number,
20
+ fps: number,
21
+ ): number | ((frame: number) => number) => {
22
+ const fadeInFrames = msToFrames(fadeInMs, fps);
23
+ const fadeOutFrames = msToFrames(fadeOutMs, fps);
24
+
25
+ if (fadeInFrames === 0 && fadeOutFrames === 0) {
26
+ return baseVolume;
27
+ }
28
+
29
+ return (frame: number) => {
30
+ const fadeInVolume =
31
+ fadeInFrames > 0
32
+ ? interpolate(frame, [0, fadeInFrames], [0, baseVolume], {
33
+ extrapolateLeft: 'clamp',
34
+ extrapolateRight: 'clamp',
35
+ })
36
+ : baseVolume;
37
+
38
+ const fadeOutVolume =
39
+ fadeOutFrames > 0
40
+ ? interpolate(frame, [spanFrames - fadeOutFrames, spanFrames], [baseVolume, 0], {
41
+ extrapolateLeft: 'clamp',
42
+ extrapolateRight: 'clamp',
43
+ })
44
+ : baseVolume;
45
+
46
+ return Math.min(fadeInVolume, fadeOutVolume);
47
+ };
48
+ };
49
+
50
+ const VoiceoverRenderer: React.FC<{ track: VoiceoverTrack; fps: number; durationInFrames: number }> = ({
51
+ track,
52
+ fps,
53
+ durationInFrames,
54
+ }) => {
55
+ const startFrom = msToFrames(track.trimBeforeMs, fps);
56
+ const endAt = track.trimAfterMs ? msToFrames(track.trimAfterMs, fps) : undefined;
57
+ const volume = buildFadeVolume(track.volume, track.fadeInMs, track.fadeOutMs, durationInFrames, fps);
58
+
59
+ return (
60
+ <Audio
61
+ src={resolveRemotionAssetSrc(track.src)}
62
+ volume={volume}
63
+ playbackRate={track.playbackRate}
64
+ startFrom={startFrom}
65
+ endAt={endAt}
66
+ />
67
+ );
68
+ };
69
+
70
+ const MusicRenderer: React.FC<{ track: MusicTrack; fps: number; durationInFrames: number }> = ({
71
+ track,
72
+ fps,
73
+ durationInFrames,
74
+ }) => {
75
+ const startFrom = msToFrames(track.trimBeforeMs, fps);
76
+ const endAt = track.trimAfterMs ? msToFrames(track.trimAfterMs, fps) : undefined;
77
+ const volume = buildFadeVolume(track.volume, track.fadeInMs, track.fadeOutMs, durationInFrames, fps);
78
+
79
+ return (
80
+ <Audio
81
+ src={resolveRemotionAssetSrc(track.src)}
82
+ volume={volume}
83
+ playbackRate={track.playbackRate}
84
+ loop={track.loop}
85
+ startFrom={startFrom}
86
+ endAt={endAt}
87
+ />
88
+ );
89
+ };
90
+
91
+ const SfxRenderer: React.FC<{ entry: SfxEntry; fps: number; index: number }> = ({ entry, fps, index }) => {
92
+ const from = msToFrames(entry.atMs, fps);
93
+ const startFrom = msToFrames(entry.trimBeforeMs, fps);
94
+ const endAt = entry.trimAfterMs ? msToFrames(entry.trimAfterMs, fps) : undefined;
95
+ const span = entry.durationMs ? msToFrames(entry.durationMs, fps) : undefined;
96
+ const volume =
97
+ entry.fadeInMs > 0 || entry.fadeOutMs > 0
98
+ ? buildFadeVolume(entry.volume, entry.fadeInMs, entry.fadeOutMs, span ?? fps * 60, fps)
99
+ : entry.volume;
100
+
101
+ return (
102
+ <Sequence key={`sfx-${index}-${from}`} from={from} durationInFrames={span}>
103
+ <Audio
104
+ src={resolveRemotionAssetSrc(entry.src)}
105
+ volume={volume}
106
+ playbackRate={entry.playbackRate}
107
+ startFrom={startFrom}
108
+ endAt={endAt}
109
+ />
110
+ </Sequence>
111
+ );
112
+ };
113
+
114
+ export const AudioLayer: React.FC<AudioLayerProps> = ({ voiceover, music, sfx = [] }) => {
115
+ const { fps, durationInFrames } = useVideoConfig();
116
+
117
+ if (!voiceover && !music && sfx.length === 0) {
118
+ return null;
119
+ }
120
+
121
+ return (
122
+ <>
123
+ {voiceover && <VoiceoverRenderer track={voiceover} fps={fps} durationInFrames={durationInFrames} />}
124
+ {music && <MusicRenderer track={music} fps={fps} durationInFrames={durationInFrames} />}
125
+ {sfx.map((entry, index) => (
126
+ <SfxRenderer key={`sfx-${index}-${entry.atMs}`} entry={entry} fps={fps} index={index} />
127
+ ))}
128
+ </>
129
+ );
130
+ };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * CaptionOverlay Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with useCurrentFrame()
6
+ * - TikTok-style caption rendering
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - NEVER use CSS animations - use useCurrentFrame()
11
+ * - Calculate active caption based on current frame
12
+ * - Support multiple caption styles (tiktok, standard, karaoke)
13
+ */
14
+
15
+ import type { CSSProperties } from 'react';
16
+ import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
17
+ import type { Caption, CaptionConfig, CaptionPage, CaptionToken } from '../../../types/index.js';
18
+
19
+ const PLATFORM_SAFE_ZONES: Record<string, { top: number; right: number; bottom: number; left: number }> = {
20
+ tiktok: { top: 270, right: 140, bottom: 480, left: 80 },
21
+ reels: { top: 220, right: 110, bottom: 420, left: 80 },
22
+ shorts: { top: 220, right: 100, bottom: 420, left: 80 },
23
+ universal: { top: 240, right: 140, bottom: 480, left: 80 },
24
+ };
25
+
26
+ export const CaptionOverlay: React.FC<CaptionConfig> = ({
27
+ captions,
28
+ pages,
29
+ style = 'tiktok',
30
+ preset = 'tiktok-bold',
31
+ platform = 'tiktok',
32
+ position = 'center',
33
+ fontSize = 48,
34
+ minFontSize = 42,
35
+ fontFamily = 'Arial, sans-serif',
36
+ fontWeight = 800,
37
+ lineHeight = 1.08,
38
+ maxWidthPct = 0.86,
39
+ maxCharsPerLine = 22,
40
+ maxLines = 2,
41
+ color = '#fff',
42
+ inactiveColor,
43
+ textColor,
44
+ highlightColor = '#ffeb3b',
45
+ strokeColor = '#000',
46
+ strokeWidth = 5,
47
+ shadow = true,
48
+ backgroundColor = 'rgba(0, 0, 0, 0.8)',
49
+ backgroundRadius = 8,
50
+ safeZone,
51
+ }) => {
52
+ const frame = useCurrentFrame();
53
+ const { fps, width, height } = useVideoConfig();
54
+
55
+ const captionPages = pages && pages.length > 0 ? pages : legacyCaptionsToPages(captions ?? []);
56
+
57
+ if (captionPages.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ // Convert frame to time in milliseconds
62
+ const currentTimeMs = (frame / fps) * 1000;
63
+
64
+ // Find active caption
65
+ const activePage = captionPages.find((caption) => currentTimeMs >= caption.startMs && currentTimeMs <= caption.endMs);
66
+
67
+ if (!activePage) {
68
+ return null;
69
+ }
70
+
71
+ const captionDurationMs = activePage.endMs - activePage.startMs;
72
+ const captionDuration = (captionDurationMs / 1000) * fps;
73
+ const captionFrame = ((currentTimeMs - activePage.startMs) / 1000) * fps;
74
+
75
+ // Fade in/out animation
76
+ const fadeInDuration = Math.min(5, captionDuration / 4);
77
+ const fadeOutStart = captionDuration - fadeInDuration;
78
+ const opacity = interpolate(captionFrame, [0, fadeInDuration, fadeOutStart, captionDuration], [0, 1, 1, 0], {
79
+ extrapolateLeft: 'clamp',
80
+ extrapolateRight: 'clamp',
81
+ });
82
+
83
+ const pagePosition = activePage.placement?.position ?? position;
84
+ const verticalPosition = pagePosition === 'top' ? 'flex-start' : pagePosition === 'bottom' ? 'flex-end' : 'center';
85
+ const safePadding = { ...PLATFORM_SAFE_ZONES[platform], ...safeZone };
86
+ const explicitPlacement = activePage.placement?.x !== undefined || activePage.placement?.y !== undefined;
87
+ const explicitLeft = clamp((activePage.placement?.x ?? 0.5) * width, safePadding.left, width - safePadding.right);
88
+ const explicitTop = clamp(
89
+ (activePage.placement?.y ?? positionToDefaultY(pagePosition)) * height,
90
+ safePadding.top,
91
+ height - safePadding.bottom,
92
+ );
93
+ const pageText = activePage.text ?? activePage.tokens.map((token) => token.text).join(' ');
94
+ const fittedFontSize = fitFontSize(pageText, fontSize, minFontSize, maxCharsPerLine, maxLines);
95
+ const baseTextColor = textColor ?? color;
96
+ const inactiveTextColor = inactiveColor ?? baseTextColor;
97
+ const activeTokenIndex = activePage.tokens.findIndex(
98
+ (token) => currentTimeMs >= token.startMs && currentTimeMs <= token.endMs,
99
+ );
100
+ const showBackground = preset === 'premium-pill' || preset === 'karaoke' || style === 'standard';
101
+ const textShadow = resolveTextShadow(shadow);
102
+ const textStroke = preset === 'minimal' || strokeWidth === 0 ? undefined : `${strokeWidth}px ${strokeColor}`;
103
+
104
+ const captionContent = (
105
+ <div
106
+ style={{
107
+ maxWidth: width * maxWidthPct,
108
+ fontSize: fittedFontSize,
109
+ fontFamily,
110
+ fontWeight,
111
+ lineHeight,
112
+ color: baseTextColor,
113
+ backgroundColor: showBackground ? backgroundColor : 'transparent',
114
+ padding: showBackground ? '14px 28px' : 0,
115
+ borderRadius: backgroundRadius,
116
+ textAlign: 'center',
117
+ textShadow,
118
+ WebkitTextStroke: textStroke,
119
+ }}
120
+ >
121
+ {activePage.tokens.map((token, index) => {
122
+ const isCurrent = index === activeTokenIndex;
123
+ const isPast = token.endMs < currentTimeMs;
124
+ const isEmphasis = token.emphasis && token.emphasis !== 'none';
125
+ const isHighlighted = style === 'karaoke' ? isPast || isCurrent : isCurrent || Boolean(isEmphasis);
126
+
127
+ return (
128
+ <span
129
+ key={`${token.startMs}-${index}-${token.text}`}
130
+ style={{
131
+ display: 'inline-block',
132
+ margin: '0 7px',
133
+ color: isHighlighted ? highlightColor : inactiveTextColor,
134
+ transform: isCurrent ? 'scale(1.08)' : 'scale(1)',
135
+ }}
136
+ >
137
+ {token.text}
138
+ </span>
139
+ );
140
+ })}
141
+ </div>
142
+ );
143
+
144
+ if (explicitPlacement) {
145
+ return (
146
+ <AbsoluteFill style={{ opacity, pointerEvents: 'none' }}>
147
+ <div
148
+ style={{
149
+ position: 'absolute',
150
+ left: explicitLeft,
151
+ top: explicitTop,
152
+ transform: 'translate(-50%, -50%)',
153
+ }}
154
+ >
155
+ {captionContent}
156
+ </div>
157
+ </AbsoluteFill>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <AbsoluteFill
163
+ style={{
164
+ display: 'flex',
165
+ alignItems: 'center',
166
+ justifyContent: verticalPosition,
167
+ padding: `${safePadding.top}px ${safePadding.right}px ${safePadding.bottom}px ${safePadding.left}px`,
168
+ opacity,
169
+ pointerEvents: 'none',
170
+ }}
171
+ >
172
+ {captionContent}
173
+ </AbsoluteFill>
174
+ );
175
+ };
176
+
177
+ const positionToDefaultY = (position: 'top' | 'center' | 'bottom'): number => {
178
+ if (position === 'top') {
179
+ return 0.25;
180
+ }
181
+
182
+ if (position === 'bottom') {
183
+ return 0.75;
184
+ }
185
+
186
+ return 0.5;
187
+ };
188
+
189
+ const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
190
+
191
+ const legacyCaptionsToPages = (captions: Caption[]): CaptionPage[] =>
192
+ captions.map((caption) => {
193
+ const tokens = splitCaption(caption);
194
+ return {
195
+ text: tokens.map((token) => token.text).join(' '),
196
+ startMs: caption.startMs,
197
+ endMs: caption.endMs,
198
+ tokens,
199
+ };
200
+ });
201
+
202
+ const splitCaption = (caption: Caption): CaptionToken[] => {
203
+ const words = caption.text.trim().split(/\s+/).filter(Boolean);
204
+ if (words.length === 0) {
205
+ return [];
206
+ }
207
+
208
+ const duration = Math.max(1, caption.endMs - caption.startMs);
209
+ const wordDuration = duration / words.length;
210
+
211
+ return words.map((word, index) => {
212
+ const startMs = Math.round(caption.startMs + wordDuration * index);
213
+ const endMs = index === words.length - 1 ? caption.endMs : Math.round(caption.startMs + wordDuration * (index + 1));
214
+ return {
215
+ text: word,
216
+ startMs,
217
+ endMs: Math.max(endMs, startMs + 1),
218
+ timestampMs: startMs,
219
+ confidence: caption.confidence,
220
+ emphasis: caption.emphasis ?? 'none',
221
+ };
222
+ });
223
+ };
224
+
225
+ const fitFontSize = (
226
+ text: string,
227
+ fontSize: number,
228
+ minFontSize: number,
229
+ maxCharsPerLine: number,
230
+ maxLines: number,
231
+ ): number => {
232
+ const targetChars = maxCharsPerLine * maxLines;
233
+ if (text.length <= targetChars) {
234
+ return fontSize;
235
+ }
236
+
237
+ return Math.max(minFontSize, Math.round(fontSize * (targetChars / text.length)));
238
+ };
239
+
240
+ const resolveTextShadow = (shadow: CaptionConfig['shadow']): CSSProperties['textShadow'] => {
241
+ if (!shadow) {
242
+ return undefined;
243
+ }
244
+
245
+ if (shadow === true) {
246
+ return '0 3px 8px rgba(0, 0, 0, 0.85)';
247
+ }
248
+
249
+ return `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`;
250
+ };
@@ -0,0 +1 @@
1
+ export { CaptionOverlay } from './CaptionOverlay';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AudioClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern
6
+ * - Type-safe props from Zod schema
7
+ *
8
+ * CODING STANDARDS:
9
+ * - Use Audio from remotion
10
+ * - Handle trimming via startFrom/endAt
11
+ * - Control playback with volume, playbackRate, loop
12
+ */
13
+
14
+ import { Audio, interpolate } from 'remotion';
15
+ import type { AudioClip } from '../../../types/index.js';
16
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
17
+
18
+ const buildVolume = (
19
+ baseVolume: number,
20
+ volumeAutomation: AudioClip['volumeAutomation'],
21
+ ): number | ((frame: number) => number) => {
22
+ if (!volumeAutomation || volumeAutomation.length === 0) {
23
+ return baseVolume;
24
+ }
25
+
26
+ const points = [...volumeAutomation].sort((a, b) => a.frame - b.frame);
27
+ if (points.length === 1) {
28
+ return points[0]?.volume ?? baseVolume;
29
+ }
30
+
31
+ return (frame: number) =>
32
+ interpolate(
33
+ frame,
34
+ points.map((point) => point.frame),
35
+ points.map((point) => point.volume),
36
+ {
37
+ extrapolateLeft: 'clamp',
38
+ extrapolateRight: 'clamp',
39
+ },
40
+ );
41
+ };
42
+
43
+ export const AudioClipRenderer: React.FC<AudioClip> = ({
44
+ src,
45
+ volume = 1,
46
+ volumeAutomation,
47
+ playbackRate = 1,
48
+ loop = false,
49
+ trimBefore = 0,
50
+ trimAfter = 0,
51
+ }) => {
52
+ if (!src) {
53
+ return null;
54
+ }
55
+
56
+ return (
57
+ <Audio
58
+ src={resolveRemotionAssetSrc(src)}
59
+ volume={buildVolume(volume, volumeAutomation)}
60
+ playbackRate={playbackRate}
61
+ loop={loop}
62
+ startFrom={trimBefore}
63
+ endAt={trimAfter > 0 ? trimAfter : undefined}
64
+ />
65
+ );
66
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GifClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with @remotion/gif
6
+ * - Async loading with delayRender/continueRender
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - Use Gif component from @remotion/gif
11
+ * - Preload with useGifAsSource()
12
+ * - Handle loading states properly
13
+ */
14
+
15
+ import { Gif } from '@remotion/gif';
16
+ import { useEffect, useState } from 'react';
17
+ import { continueRender, delayRender } from 'remotion';
18
+ import type { GifClip } from '../../../types/index.js';
19
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
20
+
21
+ export const GifClipRenderer: React.FC<GifClip> = ({
22
+ src,
23
+ fit = 'cover',
24
+ playbackRate = 1,
25
+ loopBehavior = 'loop',
26
+ width,
27
+ height,
28
+ }) => {
29
+ const [handle] = useState(() => delayRender());
30
+ const [gifData, setGifData] = useState<string | null>(null);
31
+
32
+ useEffect(() => {
33
+ if (!src) {
34
+ continueRender(handle);
35
+ return;
36
+ }
37
+
38
+ fetch(resolveRemotionAssetSrc(src))
39
+ .then((res) => res.blob())
40
+ .then((blob) => {
41
+ const url = URL.createObjectURL(blob);
42
+ setGifData(url);
43
+ continueRender(handle);
44
+ })
45
+ .catch((err) => {
46
+ console.error('Failed to load GIF:', err);
47
+ continueRender(handle);
48
+ });
49
+
50
+ return () => {
51
+ if (gifData) {
52
+ URL.revokeObjectURL(gifData);
53
+ }
54
+ };
55
+ }, [src, handle, gifData]);
56
+
57
+ if (!src || !gifData) {
58
+ return null;
59
+ }
60
+
61
+ return (
62
+ <Gif
63
+ src={gifData}
64
+ width={typeof width === 'number' ? width : undefined}
65
+ height={typeof height === 'number' ? height : undefined}
66
+ fit={fit}
67
+ playbackRate={playbackRate}
68
+ loopBehavior={loopBehavior}
69
+ />
70
+ );
71
+ };