@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.
- package/README.md +117 -5
- package/dist/cli.cjs +9 -4
- package/dist/cli.mjs +9 -4
- package/dist/index.cjs +1 -2
- package/dist/index.mjs +1 -2
- package/dist/stdio-CH0Bk0m7.cjs +17 -0
- package/dist/stdio-KMqWPqUm.mjs +17 -0
- package/package.json +12 -4
- package/schemas/main-composition.schema.json +2283 -0
- package/src/remotion/Root.tsx +87 -0
- package/src/remotion/compositions/Main.tsx +51 -0
- package/src/remotion/compositions/Square.tsx +18 -0
- package/src/remotion/compositions/Vertical.tsx +18 -0
- package/src/remotion/compositions/audio/AudioLayer.tsx +130 -0
- package/src/remotion/compositions/captions/CaptionOverlay.tsx +250 -0
- package/src/remotion/compositions/captions/index.ts +1 -0
- package/src/remotion/compositions/clips/AudioClipRenderer.tsx +66 -0
- package/src/remotion/compositions/clips/GifClipRenderer.tsx +71 -0
- package/src/remotion/compositions/clips/ImageClipRenderer.tsx +103 -0
- package/src/remotion/compositions/clips/LottieClipRenderer.tsx +57 -0
- package/src/remotion/compositions/clips/SubtitleClipRenderer.tsx +85 -0
- package/src/remotion/compositions/clips/TextClipRenderer.tsx +131 -0
- package/src/remotion/compositions/clips/VideoClipRenderer.tsx +94 -0
- package/src/remotion/compositions/clips/index.tsx +51 -0
- package/src/remotion/index.ts +10 -0
- package/src/remotion/utils/calculateMainMetadata.ts +76 -0
- package/src/schemas/clips.ts +202 -0
- package/src/schemas/compositions.ts +160 -0
- package/src/schemas/index.ts +19 -0
- package/src/utils/assetPaths.ts +64 -0
- package/src/utils/index.ts +9 -0
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.mjs.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/stdio-B6Hg5voC.mjs +0 -4
- package/dist/stdio-B6Hg5voC.mjs.map +0 -1
- package/dist/stdio-Dwc6SWvA.cjs +0 -4
- 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,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
|
+
};
|