@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,103 @@
1
+ /**
2
+ * ImageClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with useCurrentFrame()
6
+ * - Ken Burns motion + fade-in/out for cross-fade between clips
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - NEVER use CSS animations - use useCurrentFrame()
11
+ * - Use interpolate() for linear animations
12
+ */
13
+
14
+ import { Img, interpolate, useCurrentFrame } from 'remotion';
15
+ import type { ImageClip } from '../../../types/index.js';
16
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
17
+
18
+ const FADE_FRAMES = 12;
19
+ const ZOOM_START = 1;
20
+ const ZOOM_END = 1.08;
21
+ const PAN_PERCENT = 1.5;
22
+ const BURST_FRAMES = 12;
23
+ const BURST_SCALE_START = 1.05;
24
+
25
+ const hashCode = (input: string): number => {
26
+ let hash = 0;
27
+ for (let i = 0; i < input.length; i++) {
28
+ hash = (hash * 31 + input.charCodeAt(i)) | 0;
29
+ }
30
+ return hash;
31
+ };
32
+
33
+ export const ImageClipRenderer: React.FC<ImageClip & { zoomBurst?: boolean }> = ({
34
+ src,
35
+ style,
36
+ durationInFrames,
37
+ noFadeIn = false,
38
+ baseScale = 1,
39
+ transformOriginX = 'center',
40
+ transformOriginY = 'center',
41
+ zoomBurst = false,
42
+ }) => {
43
+ const frame = useCurrentFrame();
44
+
45
+ if (!src) {
46
+ return null;
47
+ }
48
+
49
+ const kenBurnsScale = interpolate(frame, [0, durationInFrames], [ZOOM_START, ZOOM_END], {
50
+ extrapolateLeft: 'clamp',
51
+ extrapolateRight: 'clamp',
52
+ });
53
+
54
+ const variant = Math.abs(hashCode(src)) % 4;
55
+ const panX = variant === 0 ? -PAN_PERCENT : variant === 1 ? PAN_PERCENT : 0;
56
+ const panY = variant === 2 ? -PAN_PERCENT : variant === 3 ? PAN_PERCENT : 0;
57
+ const translateX = interpolate(frame, [0, durationInFrames], [0, panX], {
58
+ extrapolateLeft: 'clamp',
59
+ extrapolateRight: 'clamp',
60
+ });
61
+ const translateY = interpolate(frame, [0, durationInFrames], [0, panY], {
62
+ extrapolateLeft: 'clamp',
63
+ extrapolateRight: 'clamp',
64
+ });
65
+
66
+ const burstScale = zoomBurst
67
+ ? interpolate(frame, [0, BURST_FRAMES], [BURST_SCALE_START, 1], {
68
+ extrapolateLeft: 'clamp',
69
+ extrapolateRight: 'clamp',
70
+ })
71
+ : 1;
72
+
73
+ const scale = kenBurnsScale * baseScale * burstScale;
74
+ const transformOrigin = `${transformOriginX} ${transformOriginY}`;
75
+
76
+ const fadeIn = noFadeIn ? 0 : Math.min(FADE_FRAMES, Math.floor(durationInFrames / 4));
77
+ const fadeOutStart = durationInFrames - Math.min(FADE_FRAMES, Math.floor(durationInFrames / 4));
78
+ const opacity = noFadeIn
79
+ ? interpolate(frame, [0, fadeOutStart, durationInFrames], [1, 1, 0], {
80
+ extrapolateLeft: 'clamp',
81
+ extrapolateRight: 'clamp',
82
+ })
83
+ : interpolate(frame, [0, fadeIn, fadeOutStart, durationInFrames], [0, 1, 1, 0], {
84
+ extrapolateLeft: 'clamp',
85
+ extrapolateRight: 'clamp',
86
+ });
87
+
88
+ return (
89
+ <Img
90
+ src={resolveRemotionAssetSrc(src)}
91
+ style={{
92
+ ...(style as React.CSSProperties),
93
+ width: '100%',
94
+ height: '100%',
95
+ objectFit: 'cover',
96
+ objectPosition: 'center',
97
+ transform: `scale(${scale}) translate(${translateX}%, ${translateY}%)`,
98
+ transformOrigin,
99
+ opacity,
100
+ }}
101
+ />
102
+ );
103
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * LottieClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with @remotion/lottie
6
+ * - Async loading with delayRender/continueRender
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - Use Lottie component from @remotion/lottie
11
+ * - Fetch JSON animation data asynchronously
12
+ * - Handle loading states properly
13
+ */
14
+
15
+ import { Lottie, LottieAnimationData } from '@remotion/lottie';
16
+ import { useEffect, useState } from 'react';
17
+ import { continueRender, delayRender } from 'remotion';
18
+ import type { LottieClip } from '../../../types/index.js';
19
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
20
+
21
+ export const LottieClipRenderer: React.FC<LottieClip> = ({ src, width, height, style }) => {
22
+ const [handle] = useState(() => delayRender());
23
+ const [animationData, setAnimationData] = useState<LottieAnimationData | null>(null);
24
+
25
+ useEffect(() => {
26
+ if (!src) {
27
+ continueRender(handle);
28
+ return;
29
+ }
30
+
31
+ fetch(resolveRemotionAssetSrc(src))
32
+ .then((res) => res.json())
33
+ .then((data) => {
34
+ setAnimationData(data as LottieAnimationData);
35
+ continueRender(handle);
36
+ })
37
+ .catch((err) => {
38
+ console.error('Failed to load Lottie animation:', err);
39
+ continueRender(handle);
40
+ });
41
+ }, [src, handle]);
42
+
43
+ if (!src || !animationData) {
44
+ return null;
45
+ }
46
+
47
+ return (
48
+ <Lottie
49
+ animationData={animationData}
50
+ style={{
51
+ width: width ?? '100%',
52
+ height: height ?? '100%',
53
+ ...(style as React.CSSProperties),
54
+ }}
55
+ />
56
+ );
57
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * SubtitleClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with useCurrentFrame()
6
+ * - Animation via interpolate()
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - NEVER use CSS animations - use useCurrentFrame()
11
+ * - Use interpolate() for fade/slide animations
12
+ */
13
+
14
+ import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
15
+ import type { SubtitleClip } from '../../../types/index.js';
16
+
17
+ export const SubtitleClipRenderer: React.FC<SubtitleClip> = ({
18
+ text,
19
+ fontSize = 32,
20
+ color = '#fff',
21
+ backgroundColor = 'rgba(0, 0, 0, 0.7)',
22
+ position = 'bottom',
23
+ animation = 'fade',
24
+ }) => {
25
+ const frame = useCurrentFrame();
26
+ const { durationInFrames } = useVideoConfig();
27
+
28
+ if (!text) {
29
+ return null;
30
+ }
31
+
32
+ let animatedStyle: React.CSSProperties = {};
33
+
34
+ switch (animation) {
35
+ case 'fade': {
36
+ const fadeInDuration = Math.min(10, durationInFrames / 4);
37
+ const fadeOutStart = durationInFrames - fadeInDuration;
38
+ const opacity = interpolate(frame, [0, fadeInDuration, fadeOutStart, durationInFrames], [0, 1, 1, 0], {
39
+ extrapolateLeft: 'clamp',
40
+ extrapolateRight: 'clamp',
41
+ });
42
+ animatedStyle = { opacity };
43
+ break;
44
+ }
45
+
46
+ case 'slide': {
47
+ const slideDistance = 30;
48
+ const slideDuration = Math.min(15, durationInFrames / 3);
49
+ const translateY = interpolate(frame, [0, slideDuration], [slideDistance, 0], {
50
+ extrapolateLeft: 'clamp',
51
+ extrapolateRight: 'clamp',
52
+ });
53
+ animatedStyle = { transform: `translateY(${translateY}px)` };
54
+ break;
55
+ }
56
+ }
57
+
58
+ const verticalPosition = position === 'top' ? 'flex-start' : position === 'bottom' ? 'flex-end' : 'center';
59
+
60
+ return (
61
+ <AbsoluteFill
62
+ style={{
63
+ display: 'flex',
64
+ alignItems: verticalPosition,
65
+ justifyContent: 'center',
66
+ padding: position === 'bottom' ? '0 40px 60px 40px' : '60px 40px 0 40px',
67
+ }}
68
+ >
69
+ <div
70
+ style={{
71
+ fontSize,
72
+ color,
73
+ backgroundColor,
74
+ padding: '16px 32px',
75
+ borderRadius: '8px',
76
+ textAlign: 'center',
77
+ maxWidth: '80%',
78
+ ...animatedStyle,
79
+ }}
80
+ >
81
+ {text}
82
+ </div>
83
+ </AbsoluteFill>
84
+ );
85
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * TextClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with useCurrentFrame()
6
+ * - Animation via interpolate() and spring()
7
+ * - Type-safe props from Zod schema
8
+ *
9
+ * CODING STANDARDS:
10
+ * - NEVER use CSS animations - use useCurrentFrame()
11
+ * - Use interpolate() for linear animations
12
+ * - Use spring() for physics-based animations
13
+ */
14
+
15
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
16
+ import type { TextClip } from '../../../types/index.js';
17
+
18
+ export const TextClipRenderer: React.FC<TextClip> = ({
19
+ text,
20
+ fontSize = 48,
21
+ fontFamily = 'Arial, sans-serif',
22
+ fontWeight,
23
+ color = '#fff',
24
+ backgroundColor,
25
+ position = 'center',
26
+ animation = 'none',
27
+ highlightWord,
28
+ highlightColor = '#ffeb3b',
29
+ style: customStyle,
30
+ }) => {
31
+ const frame = useCurrentFrame();
32
+ const { fps, durationInFrames } = useVideoConfig();
33
+
34
+ if (!text) {
35
+ return null;
36
+ }
37
+
38
+ let animatedStyle: React.CSSProperties = {};
39
+ let displayText = text;
40
+
41
+ switch (animation) {
42
+ case 'fade': {
43
+ const fadeInDuration = Math.min(15, durationInFrames / 4);
44
+ const fadeOutStart = durationInFrames - fadeInDuration;
45
+ const opacity = interpolate(frame, [0, fadeInDuration, fadeOutStart, durationInFrames], [0, 1, 1, 0], {
46
+ extrapolateLeft: 'clamp',
47
+ extrapolateRight: 'clamp',
48
+ });
49
+ animatedStyle = { opacity };
50
+ break;
51
+ }
52
+
53
+ case 'slide': {
54
+ const slideDistance = 50;
55
+ const slideDuration = Math.min(20, durationInFrames / 3);
56
+ const translateY = interpolate(frame, [0, slideDuration], [slideDistance, 0], {
57
+ extrapolateLeft: 'clamp',
58
+ extrapolateRight: 'clamp',
59
+ });
60
+ animatedStyle = { transform: `translateY(${translateY}px)` };
61
+ break;
62
+ }
63
+
64
+ case 'typewriter': {
65
+ const charsToShow = Math.floor(
66
+ interpolate(frame, [0, durationInFrames], [0, text.length], {
67
+ extrapolateLeft: 'clamp',
68
+ extrapolateRight: 'clamp',
69
+ }),
70
+ );
71
+ displayText = text.substring(0, charsToShow);
72
+ break;
73
+ }
74
+
75
+ case 'highlight': {
76
+ if (highlightWord) {
77
+ const words = text.split(' ');
78
+ const wordIndex = words.findIndex((w) => w.includes(highlightWord));
79
+
80
+ if (wordIndex !== -1) {
81
+ const highlightProgress = spring({
82
+ frame: frame - 10,
83
+ fps,
84
+ config: { damping: 100, stiffness: 200, mass: 0.5 },
85
+ });
86
+
87
+ displayText = words
88
+ .map((word, idx) => {
89
+ if (idx === wordIndex) {
90
+ return `<span style="position: relative; display: inline-block;">
91
+ <span style="position: absolute; bottom: 0; left: 0; height: 8px; background: ${highlightColor}; transform: scaleX(${highlightProgress}); transform-origin: left; z-index: -1;"></span>
92
+ ${word}
93
+ </span>`;
94
+ }
95
+ return word;
96
+ })
97
+ .join(' ');
98
+ }
99
+ }
100
+ break;
101
+ }
102
+ }
103
+
104
+ const verticalPosition = position === 'top' ? 'flex-start' : position === 'bottom' ? 'flex-end' : 'center';
105
+
106
+ return (
107
+ <AbsoluteFill
108
+ style={{
109
+ display: 'flex',
110
+ justifyContent: verticalPosition,
111
+ alignItems: 'center',
112
+ padding: '40px',
113
+ }}
114
+ >
115
+ <div
116
+ style={{
117
+ fontSize,
118
+ fontFamily,
119
+ fontWeight,
120
+ color,
121
+ backgroundColor,
122
+ textAlign: 'center',
123
+ ...animatedStyle,
124
+ ...(customStyle as React.CSSProperties),
125
+ }}
126
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: required for rich text rendering in video clips
127
+ dangerouslySetInnerHTML={{ __html: displayText }}
128
+ />
129
+ </AbsoluteFill>
130
+ );
131
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * VideoClipRenderer Component
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion composition pattern with useCurrentFrame() for motion + fade
6
+ * - OffthreadVideo for performant playback
7
+ *
8
+ * CODING STANDARDS:
9
+ * - NEVER use CSS animations - use useCurrentFrame()
10
+ * - Use interpolate() for linear animations
11
+ * - Handle trimming via startFrom/endAt
12
+ */
13
+
14
+ import { OffthreadVideo, interpolate, useCurrentFrame } from 'remotion';
15
+ import type { VideoClip } from '../../../types/index.js';
16
+ import { resolveRemotionAssetSrc } from '../../../utils/assetPaths';
17
+
18
+ const FADE_FRAMES = 12;
19
+ const ZOOM_START = 1;
20
+ const ZOOM_END = 1.06;
21
+ const PAN_PERCENT = 1.2;
22
+
23
+ const hashCode = (input: string): number => {
24
+ let hash = 0;
25
+ for (let i = 0; i < input.length; i++) {
26
+ hash = (hash * 31 + input.charCodeAt(i)) | 0;
27
+ }
28
+ return hash;
29
+ };
30
+
31
+ export const VideoClipRenderer: React.FC<VideoClip> = ({
32
+ src,
33
+ volume = 1,
34
+ playbackRate = 1,
35
+ trimBefore = 0,
36
+ trimAfter = 0,
37
+ transparent = false,
38
+ muted = false,
39
+ noFadeIn = false,
40
+ durationInFrames,
41
+ }) => {
42
+ const frame = useCurrentFrame();
43
+
44
+ if (!src) {
45
+ return null;
46
+ }
47
+
48
+ const scale = interpolate(frame, [0, durationInFrames], [ZOOM_START, ZOOM_END], {
49
+ extrapolateLeft: 'clamp',
50
+ extrapolateRight: 'clamp',
51
+ });
52
+ const variant = Math.abs(hashCode(src)) % 4;
53
+ const panX = variant === 0 ? -PAN_PERCENT : variant === 1 ? PAN_PERCENT : 0;
54
+ const panY = variant === 2 ? -PAN_PERCENT : variant === 3 ? PAN_PERCENT : 0;
55
+ const translateX = interpolate(frame, [0, durationInFrames], [0, panX], {
56
+ extrapolateLeft: 'clamp',
57
+ extrapolateRight: 'clamp',
58
+ });
59
+ const translateY = interpolate(frame, [0, durationInFrames], [0, panY], {
60
+ extrapolateLeft: 'clamp',
61
+ extrapolateRight: 'clamp',
62
+ });
63
+
64
+ const effectiveFadeFrames = noFadeIn ? 0 : FADE_FRAMES;
65
+ const fadeIn = Math.min(effectiveFadeFrames, Math.floor(durationInFrames / 4));
66
+ const fadeOutStart = durationInFrames - fadeIn;
67
+ const opacity = noFadeIn
68
+ ? 1
69
+ : interpolate(frame, [0, fadeIn, fadeOutStart, durationInFrames], [0, 1, 1, 0], {
70
+ extrapolateLeft: 'clamp',
71
+ extrapolateRight: 'clamp',
72
+ });
73
+
74
+ return (
75
+ <OffthreadVideo
76
+ src={resolveRemotionAssetSrc(src)}
77
+ volume={muted ? 0 : volume}
78
+ playbackRate={playbackRate}
79
+ startFrom={trimBefore}
80
+ endAt={trimAfter > 0 ? trimAfter : undefined}
81
+ transparent={transparent}
82
+ muted={muted}
83
+ style={{
84
+ width: '100%',
85
+ height: '100%',
86
+ objectFit: 'cover',
87
+ objectPosition: 'center',
88
+ transform: `scale(${scale}) translate(${translateX}%, ${translateY}%)`,
89
+ transformOrigin: 'center',
90
+ opacity,
91
+ }}
92
+ />
93
+ );
94
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ClipRenderer Dispatcher
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Type-safe dispatcher pattern
6
+ * - Routes clip types to appropriate renderers
7
+ *
8
+ * CODING STANDARDS:
9
+ * - Use discriminated union pattern for type safety
10
+ * - Exhaustive switch with default case
11
+ */
12
+
13
+ import type { Clip } from '../../../types/index.js';
14
+ import { AudioClipRenderer } from './AudioClipRenderer';
15
+ import { GifClipRenderer } from './GifClipRenderer';
16
+ import { ImageClipRenderer } from './ImageClipRenderer';
17
+ import { LottieClipRenderer } from './LottieClipRenderer';
18
+ import { SubtitleClipRenderer } from './SubtitleClipRenderer';
19
+ import { TextClipRenderer } from './TextClipRenderer';
20
+ import { VideoClipRenderer } from './VideoClipRenderer';
21
+
22
+ export const ClipRenderer: React.FC<Clip> = (clip) => {
23
+ switch (clip.type) {
24
+ case 'video':
25
+ return <VideoClipRenderer {...clip} />;
26
+ case 'image':
27
+ return <ImageClipRenderer {...clip} />;
28
+ case 'text':
29
+ return <TextClipRenderer {...clip} />;
30
+ case 'audio':
31
+ return <AudioClipRenderer {...clip} />;
32
+ case 'subtitle':
33
+ return <SubtitleClipRenderer {...clip} />;
34
+ case 'gif':
35
+ return <GifClipRenderer {...clip} />;
36
+ case 'lottie':
37
+ return <LottieClipRenderer {...clip} />;
38
+ default:
39
+ return null;
40
+ }
41
+ };
42
+
43
+ export {
44
+ AudioClipRenderer,
45
+ GifClipRenderer,
46
+ ImageClipRenderer,
47
+ LottieClipRenderer,
48
+ SubtitleClipRenderer,
49
+ TextClipRenderer,
50
+ VideoClipRenderer,
51
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Remotion Entry Point
3
+ *
4
+ * This file is the entry point for Remotion bundler.
5
+ */
6
+
7
+ import { registerRoot } from 'remotion';
8
+ import { RemotionRoot } from './Root';
9
+
10
+ registerRoot(RemotionRoot);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Calculate Main Composition Metadata
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Remotion calculateMetadata function
6
+ * - Dynamic duration calculation from clips and audio layer
7
+ *
8
+ * CODING STANDARDS:
9
+ * - Return metadata compatible with Remotion Composition
10
+ * - Calculate max duration across clips and timeline-defining audio
11
+ * - Ensure minimum 1 second duration
12
+ */
13
+
14
+ import type { MainCompositionProps } from '../../types/index.js';
15
+
16
+ const msToFrames = (durationMs: number, fps: number): number => Math.ceil((durationMs / 1000) * fps);
17
+
18
+ const voiceoverEffectiveMs = (voiceover: NonNullable<MainCompositionProps['audio']>['voiceover']): number => {
19
+ if (!voiceover?.durationMs) {
20
+ return 0;
21
+ }
22
+
23
+ const trimmedMs = Math.max(0, (voiceover.trimAfterMs ?? voiceover.durationMs) - (voiceover.trimBeforeMs ?? 0));
24
+
25
+ return trimmedMs / (voiceover.playbackRate ?? 1);
26
+ };
27
+
28
+ export const calculateMainMetadata = async ({
29
+ props,
30
+ defaultProps,
31
+ }: {
32
+ props: MainCompositionProps;
33
+ defaultProps: MainCompositionProps;
34
+ }) => {
35
+ const merged = { ...defaultProps, ...props };
36
+
37
+ const clips = merged.clips || [];
38
+ const audio = merged.audio;
39
+ const fps = merged.fps || 30;
40
+
41
+ console.log('[calculateMainMetadata] Props keys:', Object.keys(props));
42
+ console.log('[calculateMainMetadata] Merged keys:', Object.keys(merged));
43
+ console.log('[calculateMainMetadata] clips:', Array.isArray(clips) ? clips.length : 'not array');
44
+ console.log('[calculateMainMetadata] audio:', audio ? 'present' : 'missing');
45
+
46
+ let maxFrame = 0;
47
+
48
+ if (Array.isArray(clips)) {
49
+ for (const clip of clips) {
50
+ const clipEnd = clip.startFrame + clip.durationInFrames;
51
+ if (clipEnd > maxFrame) {
52
+ maxFrame = clipEnd;
53
+ }
54
+ }
55
+ }
56
+
57
+ console.log('[calculateMainMetadata] Max frame from clips:', maxFrame);
58
+
59
+ if (audio?.voiceover && audio.voiceover.definesTimeline !== false) {
60
+ const voFrames = msToFrames(voiceoverEffectiveMs(audio.voiceover), fps);
61
+ console.log('[calculateMainMetadata] Voiceover frames:', voFrames);
62
+ if (voFrames > maxFrame) {
63
+ maxFrame = voFrames;
64
+ }
65
+ }
66
+
67
+ const durationInFrames = Math.max(maxFrame, fps);
68
+ console.log('[calculateMainMetadata] Final durationInFrames:', durationInFrames);
69
+
70
+ return {
71
+ durationInFrames,
72
+ fps,
73
+ width: merged.width || 1920,
74
+ height: merged.height || 1080,
75
+ };
76
+ };