@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,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
|
+
};
|