mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
Add transitions library (WIP) with 6 custom presentations
New lib/transitions/ with custom scene-to-scene transitions: - glitch() - Digital distortion, slice displacement, RGB separation - rgbSplit() - Chromatic aberration with channel separation - zoomBlur() - Radial motion blur with scale - lightLeak() - Cinematic lens flare and overexposure - clockWipe() - Radial sweep like clock hands - pixelate() - Digital mosaic dissolution Also includes: - Re-exports official @remotion/transitions (slide, fade, wipe, flip) - showcase/transitions/ - Self-contained gallery for previewing - Full README documentation - CLAUDE.md updated with transitions section Note: This branch needs testing before merge to main. See _internal/BACKLOG.md for remaining TODOs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -258,6 +258,85 @@ const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp'
|
||||
<Audio src={staticFile('music.mp3')} volume={0.15} />
|
||||
```
|
||||
|
||||
## Scene Transitions
|
||||
|
||||
The toolkit includes a transitions library at `lib/transitions/` with both official Remotion transitions and custom presentations.
|
||||
|
||||
### Using TransitionSeries
|
||||
|
||||
For scene-to-scene transitions (scenes overlap during transition):
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from '@remotion/transitions';
|
||||
import { glitch, lightLeak, zoomBlur } from '../../../lib/transitions';
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={90}>
|
||||
<TitleSlide />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={glitch({ intensity: 0.8 })}
|
||||
timing={linearTiming({ durationInFrames: 20 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={120}>
|
||||
<ContentSlide />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
```
|
||||
|
||||
### Available Transitions
|
||||
|
||||
| Transition | Description | Best For |
|
||||
|------------|-------------|----------|
|
||||
| `glitch()` | Digital distortion, RGB separation, scan lines | Tech demos, edgy reveals |
|
||||
| `rgbSplit()` | Chromatic aberration effect | Modern tech, energetic |
|
||||
| `zoomBlur()` | Radial motion blur with scale | CTAs, high-energy moments |
|
||||
| `lightLeak()` | Cinematic lens flare, overexposure | Celebrations, film aesthetic |
|
||||
| `clockWipe()` | Radial wipe like clock hands | Playful reveals |
|
||||
| `pixelate()` | Digital mosaic dissolution | Retro/gaming themes |
|
||||
| `slide()` | Push scene from direction | Standard transitions |
|
||||
| `fade()` | Simple crossfade | Professional, subtle |
|
||||
| `wipe()` | Edge reveal | Clean transitions |
|
||||
| `flip()` | 3D card flip | Playful, dynamic |
|
||||
|
||||
### Transition Options Examples
|
||||
|
||||
```tsx
|
||||
// Tech/cyberpunk feel
|
||||
glitch({ intensity: 0.8, slices: 8, rgbShift: true })
|
||||
|
||||
// Warm celebration
|
||||
lightLeak({ temperature: 'warm', direction: 'right' })
|
||||
|
||||
// High energy zoom
|
||||
zoomBlur({ direction: 'in', blurAmount: 20 })
|
||||
|
||||
// Chromatic aberration
|
||||
rgbSplit({ direction: 'diagonal', displacement: 30 })
|
||||
```
|
||||
|
||||
### Timing Functions
|
||||
|
||||
```tsx
|
||||
// Linear: constant speed
|
||||
linearTiming({ durationInFrames: 30 })
|
||||
|
||||
// Spring: physics-based with bounce
|
||||
springTiming({ config: { damping: 200 }, durationInFrames: 45 })
|
||||
```
|
||||
|
||||
### Transition Duration Guidelines
|
||||
|
||||
| Type | Frames | Notes |
|
||||
|------|--------|-------|
|
||||
| Quick cut | 10-15 | Fast, punchy |
|
||||
| Standard | 20-30 | Most common |
|
||||
| Dramatic | 40-60 | Slow reveals |
|
||||
| Glitch effects | 15-25 | Should feel sudden |
|
||||
| Light leak | 30-45 | Needs time to sweep |
|
||||
|
||||
See `lib/transitions/README.md` for full documentation.
|
||||
|
||||
## Design Refinement with frontend-design Skill
|
||||
|
||||
The `frontend-design` skill elevates slide visuals from generic to distinctive. It's integrated at multiple levels:
|
||||
|
||||
+18
-1
@@ -178,10 +178,27 @@ ElevenLabs usage monitoring:
|
||||
- Scroll smoothing
|
||||
|
||||
### Template Improvements
|
||||
- More transition styles
|
||||
- Additional color themes
|
||||
- Progress bar component
|
||||
|
||||
### Transitions Library (In Progress - experiment/transitions branch)
|
||||
**Status:** Built but needs testing before merge
|
||||
|
||||
New `lib/transitions/` with 6 custom transitions + official Remotion transitions:
|
||||
- `glitch()` - Digital distortion, slice displacement, RGB separation
|
||||
- `rgbSplit()` - Chromatic aberration
|
||||
- `zoomBlur()` - Radial motion blur with scale
|
||||
- `lightLeak()` - Cinematic lens flare
|
||||
- `clockWipe()` - Radial sweep
|
||||
- `pixelate()` - Digital mosaic
|
||||
|
||||
**TODO before merge:**
|
||||
- [ ] Test all transitions in gallery (`showcase/transitions/`)
|
||||
- [ ] Fix any broken transitions (glitch was fixed but needs verification)
|
||||
- [ ] Copy fixed presentations back to `lib/transitions/presentations/`
|
||||
- [ ] Test integration with templates
|
||||
- [ ] Update lib/transitions/TransitionGallery.tsx to match showcase version
|
||||
|
||||
### Brand System Enhancements
|
||||
- Brand inheritance (extend another brand)
|
||||
- Dark/light mode variants per brand
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Transitions Library
|
||||
|
||||
Scene transition effects for Remotion videos. Combines official `@remotion/transitions` with custom presentations for a comprehensive transition toolkit.
|
||||
|
||||
## Installation
|
||||
|
||||
The transitions package is installed in each template. If setting up manually:
|
||||
|
||||
```bash
|
||||
npm install @remotion/transitions @remotion/paths @remotion/shapes
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Transitions work with Remotion's `TransitionSeries` component:
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming, springTiming } from '@remotion/transitions';
|
||||
import { glitch, rgbSplit, lightLeak } from '../../../lib/transitions';
|
||||
|
||||
export const MyVideo = () => {
|
||||
return (
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={90}>
|
||||
<TitleScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={glitch({ intensity: 0.8 })}
|
||||
timing={linearTiming({ durationInFrames: 20 })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={120}>
|
||||
<ContentScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={lightLeak({ temperature: 'warm' })}
|
||||
timing={springTiming({ config: { damping: 200 } })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={90}>
|
||||
<EndScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Available Transitions
|
||||
|
||||
### Custom Transitions (this library)
|
||||
|
||||
| Transition | Description | Best For |
|
||||
|------------|-------------|----------|
|
||||
| `glitch()` | Digital distortion with slice displacement and RGB separation | Tech demos, cyberpunk, edgy reveals |
|
||||
| `rgbSplit()` | Chromatic aberration with channel separation | Modern tech, energetic transitions |
|
||||
| `zoomBlur()` | Radial motion blur with scale | CTAs, reveals, high-energy moments |
|
||||
| `lightLeak()` | Cinematic lens flare and overexposure | Emotional moments, celebrations, film aesthetic |
|
||||
| `clockWipe()` | Radial wipe like clock hands | Time-related content, playful reveals |
|
||||
| `pixelate()` | Digital mosaic dissolution | Retro/gaming, digital transformations |
|
||||
|
||||
### Official Transitions (re-exported)
|
||||
|
||||
| Transition | Description |
|
||||
|------------|-------------|
|
||||
| `slide()` | Scene slides in from a direction |
|
||||
| `fade()` | Simple crossfade |
|
||||
| `wipe()` | Edge wipe reveal |
|
||||
| `flip()` | 3D card flip |
|
||||
|
||||
## Transition Options
|
||||
|
||||
### glitch(options?)
|
||||
|
||||
```tsx
|
||||
glitch({
|
||||
intensity: 0.8, // Effect strength (0-1). Default: 0.8
|
||||
slices: 8, // Horizontal slice count. Default: 8
|
||||
rgbShift: true, // RGB channel separation. Default: true
|
||||
scanLines: true, // CRT scan line overlay. Default: true
|
||||
})
|
||||
```
|
||||
|
||||
### rgbSplit(options?)
|
||||
|
||||
```tsx
|
||||
rgbSplit({
|
||||
direction: 'horizontal', // 'horizontal' | 'vertical' | 'diagonal'. Default: 'horizontal'
|
||||
displacement: 30, // Max pixel offset. Default: 30
|
||||
channelBlur: true, // Motion blur on channels. Default: true
|
||||
})
|
||||
```
|
||||
|
||||
### zoomBlur(options?)
|
||||
|
||||
```tsx
|
||||
zoomBlur({
|
||||
direction: 'in', // 'in' (toward viewer) | 'out' (away). Default: 'in'
|
||||
blurAmount: 20, // Max blur pixels. Default: 20
|
||||
scaleAmount: 1.15, // Scale multiplier. Default: 1.15
|
||||
origin: 'center', // 'center' | 'top' | 'bottom' | 'left' | 'right'. Default: 'center'
|
||||
})
|
||||
```
|
||||
|
||||
### lightLeak(options?)
|
||||
|
||||
```tsx
|
||||
lightLeak({
|
||||
temperature: 'warm', // 'warm' | 'cool' | 'rainbow'. Default: 'warm'
|
||||
direction: 'right', // 'left' | 'right' | 'top' | 'bottom' | 'center'. Default: 'right'
|
||||
intensity: 0.8, // Overexposure strength (0-1). Default: 0.8
|
||||
flareArtifacts: true, // Lens flare spots. Default: true
|
||||
})
|
||||
```
|
||||
|
||||
### clockWipe(options?)
|
||||
|
||||
```tsx
|
||||
clockWipe({
|
||||
startAngle: 0, // Starting angle in degrees. Default: 0 (12 o'clock)
|
||||
direction: 'clockwise', // 'clockwise' | 'counterclockwise'. Default: 'clockwise'
|
||||
segments: 1, // Number of wipe arms. Default: 1
|
||||
softEdge: true, // Soft glow on edge. Default: true
|
||||
})
|
||||
```
|
||||
|
||||
### pixelate(options?)
|
||||
|
||||
```tsx
|
||||
pixelate({
|
||||
maxBlockSize: 40, // Max pixel block size. Default: 40
|
||||
posterize: true, // Reduce color depth. Default: true
|
||||
pattern: 'uniform', // 'uniform' | 'random'. Default: 'uniform'
|
||||
})
|
||||
```
|
||||
|
||||
## Timing Functions
|
||||
|
||||
### linearTiming
|
||||
|
||||
Constant speed transition:
|
||||
|
||||
```tsx
|
||||
linearTiming({ durationInFrames: 30 }) // 1 second at 30fps
|
||||
```
|
||||
|
||||
### springTiming
|
||||
|
||||
Physics-based with bounce:
|
||||
|
||||
```tsx
|
||||
springTiming({
|
||||
config: {
|
||||
damping: 200, // Higher = less bounce
|
||||
stiffness: 100, // Higher = snappier
|
||||
mass: 1, // Higher = slower
|
||||
},
|
||||
durationInFrames: 45, // Optional max duration
|
||||
})
|
||||
```
|
||||
|
||||
## Choosing Transitions
|
||||
|
||||
| Video Type | Recommended Transitions |
|
||||
|------------|------------------------|
|
||||
| **Tech/Product Demo** | `glitch`, `rgbSplit`, `slide` |
|
||||
| **Corporate/Professional** | `fade`, `wipe`, `zoomBlur` |
|
||||
| **Celebration/Launch** | `lightLeak`, `zoomBlur` |
|
||||
| **Retro/Gaming** | `pixelate`, `glitch` |
|
||||
| **Cinematic** | `lightLeak`, `fade`, `wipe` |
|
||||
| **Playful/Creative** | `clockWipe`, `flip` |
|
||||
| **High Energy** | `zoomBlur`, `rgbSplit`, `glitch` |
|
||||
|
||||
## Transition Duration Guidelines
|
||||
|
||||
| Transition Type | Recommended Duration | Notes |
|
||||
|-----------------|---------------------|-------|
|
||||
| Quick cut | 10-15 frames | Fast, punchy |
|
||||
| Standard | 20-30 frames | Most common |
|
||||
| Dramatic | 40-60 frames | Slow reveals |
|
||||
| Glitch effects | 15-25 frames | Should feel sudden |
|
||||
| Light leak | 30-45 frames | Needs time to sweep |
|
||||
|
||||
## Combining with Audio
|
||||
|
||||
Add sound effects to transitions:
|
||||
|
||||
```tsx
|
||||
import { Audio, Sequence } from 'remotion';
|
||||
|
||||
// Play whoosh sound during transition
|
||||
<Sequence from={transitionStartFrame} durationInFrames={30}>
|
||||
<Audio src={staticFile('sfx/whoosh.mp3')} volume={0.5} />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Transition Gallery
|
||||
|
||||
A visual showcase component is included for previewing all transitions:
|
||||
|
||||
```tsx
|
||||
import { TransitionGallery, transitionGalleryConfig } from '../../../lib/transitions';
|
||||
|
||||
// Register in Root.tsx
|
||||
<Composition
|
||||
id={transitionGalleryConfig.id}
|
||||
component={TransitionGallery}
|
||||
durationInFrames={transitionGalleryConfig.durationInFrames}
|
||||
fps={transitionGalleryConfig.fps}
|
||||
width={transitionGalleryConfig.width}
|
||||
height={transitionGalleryConfig.height}
|
||||
/>
|
||||
```
|
||||
|
||||
Then run `npm run studio` and select "TransitionGallery" to preview all transitions.
|
||||
|
||||
### Single Transition Preview
|
||||
|
||||
For interactive previews (e.g., with `@remotion/player`):
|
||||
|
||||
```tsx
|
||||
import { SingleTransitionPreview, transitionMap } from '../../../lib/transitions';
|
||||
|
||||
// Preview a specific transition
|
||||
<SingleTransitionPreview transitionName="glitch" />
|
||||
|
||||
// Access transition config programmatically
|
||||
const { presentation, duration } = transitionMap.lightLeak;
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
1. **TransitionSeries vs Series**: `TransitionSeries` allows overlapping scenes during transitions. Regular `Series` does not.
|
||||
|
||||
2. **Duration calculation**: Total video duration = sum of sequence durations - sum of transition durations (because scenes overlap).
|
||||
|
||||
3. **Performance**: Complex transitions (glitch, pixelate) use SVG filters which may impact preview performance. Final renders are unaffected.
|
||||
|
||||
4. **Browser compatibility**: All transitions use standard CSS/SVG features. Tested in Chrome (Remotion's render target).
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Transition Gallery
|
||||
*
|
||||
* A visual showcase of all available transitions.
|
||||
* Each transition is demonstrated with consistent before/after scenes,
|
||||
* labeled clearly for easy comparison.
|
||||
*
|
||||
* Can be:
|
||||
* 1. Rendered as a showcase video
|
||||
* 2. Used with @remotion/player for interactive preview
|
||||
*
|
||||
* Total duration: ~20 seconds at 30fps
|
||||
*/
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate, Sequence } from 'remotion';
|
||||
import { TransitionSeries, linearTiming } from '@remotion/transitions';
|
||||
import { slide } from '@remotion/transitions/slide';
|
||||
import { fade } from '@remotion/transitions/fade';
|
||||
import { wipe } from '@remotion/transitions/wipe';
|
||||
import { flip } from '@remotion/transitions/flip';
|
||||
import { glitch } from './presentations/glitch';
|
||||
import { rgbSplit } from './presentations/rgb-split';
|
||||
import { zoomBlur } from './presentations/zoom-blur';
|
||||
import { lightLeak } from './presentations/light-leak';
|
||||
import { clockWipe } from './presentations/clock-wipe';
|
||||
import { pixelate } from './presentations/pixelate';
|
||||
|
||||
// Scene colors for visual variety
|
||||
const SCENE_A_COLOR = '#1a1a2e';
|
||||
const SCENE_B_COLOR = '#e94560';
|
||||
|
||||
// Consistent scene component
|
||||
const GalleryScene: React.FC<{
|
||||
color: string;
|
||||
label: string;
|
||||
isAfter?: boolean;
|
||||
}> = ({ color, label, isAfter = false }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const labelOpacity = interpolate(frame, [0, 10], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Transition name label */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
opacity: labelOpacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
padding: '12px 32px',
|
||||
borderRadius: 8,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Before/After indicator */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 120,
|
||||
fontWeight: 800,
|
||||
color: 'rgba(255, 255, 255, 0.15)',
|
||||
letterSpacing: '-4px',
|
||||
}}
|
||||
>
|
||||
{isAfter ? 'B' : 'A'}
|
||||
</div>
|
||||
|
||||
{/* Scene indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
fontSize: 18,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isAfter ? 'After' : 'Before'}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Single transition demo segment
|
||||
const TransitionDemo: React.FC<{
|
||||
name: string;
|
||||
presentation: ReturnType<typeof glitch>;
|
||||
transitionDuration?: number;
|
||||
}> = ({ name, presentation, transitionDuration = 20 }) => {
|
||||
const sceneDuration = 45; // 1.5 seconds per scene
|
||||
|
||||
return (
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<GalleryScene color={SCENE_A_COLOR} label={name} />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={presentation}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<GalleryScene color={SCENE_B_COLOR} label={name} isAfter />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
);
|
||||
};
|
||||
|
||||
// Intro slide
|
||||
const IntroSlide: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const titleOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
|
||||
const subtitleOpacity = interpolate(frame, [15, 35], [0, 1], { extrapolateRight: 'clamp' });
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: '#0f0f1a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
margin: 0,
|
||||
opacity: titleOpacity,
|
||||
letterSpacing: '-2px',
|
||||
}}
|
||||
>
|
||||
Transitions Gallery
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 20,
|
||||
opacity: subtitleOpacity,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
claude-code-video-toolkit
|
||||
</p>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Define all transitions to showcase
|
||||
const TRANSITIONS = [
|
||||
{ name: 'glitch()', presentation: glitch({ intensity: 0.9 }), duration: 25 },
|
||||
{ name: 'rgbSplit()', presentation: rgbSplit({ direction: 'horizontal' }), duration: 25 },
|
||||
{ name: 'zoomBlur()', presentation: zoomBlur({ direction: 'in' }), duration: 25 },
|
||||
{ name: 'lightLeak()', presentation: lightLeak({ temperature: 'warm' }), duration: 35 },
|
||||
{ name: 'clockWipe()', presentation: clockWipe({ direction: 'clockwise' }), duration: 25 },
|
||||
{ name: 'pixelate()', presentation: pixelate({ maxBlockSize: 50 }), duration: 25 },
|
||||
{ name: 'slide()', presentation: slide(), duration: 20 },
|
||||
{ name: 'fade()', presentation: fade(), duration: 25 },
|
||||
{ name: 'wipe()', presentation: wipe(), duration: 20 },
|
||||
{ name: 'flip()', presentation: flip(), duration: 25 },
|
||||
];
|
||||
|
||||
// Calculate segment duration (scene + transition + scene, minus overlap)
|
||||
const getSegmentDuration = (transitionDuration: number) => {
|
||||
const sceneDuration = 45;
|
||||
return sceneDuration * 2 - transitionDuration;
|
||||
};
|
||||
|
||||
export const TransitionGallery: React.FC = () => {
|
||||
const introDuration = 60; // 2 seconds
|
||||
|
||||
let currentFrame = introDuration;
|
||||
const segments: { name: string; from: number; duration: number }[] = [];
|
||||
|
||||
TRANSITIONS.forEach((t) => {
|
||||
const duration = getSegmentDuration(t.duration);
|
||||
segments.push({ name: t.name, from: currentFrame, duration });
|
||||
currentFrame += duration;
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: '#0f0f1a' }}>
|
||||
{/* Intro */}
|
||||
<Sequence durationInFrames={introDuration}>
|
||||
<IntroSlide />
|
||||
</Sequence>
|
||||
|
||||
{/* Each transition demo */}
|
||||
{TRANSITIONS.map((t, i) => (
|
||||
<Sequence
|
||||
key={t.name}
|
||||
from={segments[i].from}
|
||||
durationInFrames={segments[i].duration}
|
||||
>
|
||||
<TransitionDemo
|
||||
name={t.name}
|
||||
presentation={t.presentation}
|
||||
transitionDuration={t.duration}
|
||||
/>
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Export configuration for Remotion
|
||||
export const transitionGalleryConfig = {
|
||||
id: 'TransitionGallery',
|
||||
component: TransitionGallery,
|
||||
durationInFrames: 60 + TRANSITIONS.reduce(
|
||||
(acc, t) => acc + getSegmentDuration(t.duration),
|
||||
0
|
||||
),
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
|
||||
// For single-transition preview (useful for interactive player)
|
||||
export const SingleTransitionPreview: React.FC<{
|
||||
transitionName: keyof typeof transitionMap;
|
||||
}> = ({ transitionName }) => {
|
||||
const transition = transitionMap[transitionName];
|
||||
if (!transition) return null;
|
||||
|
||||
return (
|
||||
<TransitionDemo
|
||||
name={transitionName}
|
||||
presentation={transition.presentation}
|
||||
transitionDuration={transition.duration}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Map for programmatic access
|
||||
export const transitionMap = {
|
||||
glitch: { presentation: glitch({ intensity: 0.9 }), duration: 25 },
|
||||
rgbSplit: { presentation: rgbSplit({ direction: 'horizontal' }), duration: 25 },
|
||||
zoomBlur: { presentation: zoomBlur({ direction: 'in' }), duration: 25 },
|
||||
lightLeak: { presentation: lightLeak({ temperature: 'warm' }), duration: 35 },
|
||||
clockWipe: { presentation: clockWipe({ direction: 'clockwise' }), duration: 25 },
|
||||
pixelate: { presentation: pixelate({ maxBlockSize: 50 }), duration: 25 },
|
||||
slide: { presentation: slide(), duration: 20 },
|
||||
fade: { presentation: fade(), duration: 25 },
|
||||
wipe: { presentation: wipe(), duration: 20 },
|
||||
flip: { presentation: flip(), duration: 25 },
|
||||
} as const;
|
||||
|
||||
export type TransitionName = keyof typeof transitionMap;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Transitions Library
|
||||
*
|
||||
* Unified API for scene transitions in Remotion videos.
|
||||
* Combines official @remotion/transitions with custom presentations.
|
||||
*
|
||||
* Usage with TransitionSeries:
|
||||
* ```tsx
|
||||
* import { TransitionSeries, linearTiming } from '@remotion/transitions';
|
||||
* import { glitch, rgbSplit, zoomBlur, lightLeak } from '../../../lib/transitions';
|
||||
*
|
||||
* <TransitionSeries>
|
||||
* <TransitionSeries.Sequence durationInFrames={90}>
|
||||
* <SceneA />
|
||||
* </TransitionSeries.Sequence>
|
||||
* <TransitionSeries.Transition
|
||||
* presentation={glitch()}
|
||||
* timing={linearTiming({ durationInFrames: 20 })}
|
||||
* />
|
||||
* <TransitionSeries.Sequence durationInFrames={90}>
|
||||
* <SceneB />
|
||||
* </TransitionSeries.Sequence>
|
||||
* </TransitionSeries>
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Custom transitions
|
||||
export { glitch } from './presentations/glitch';
|
||||
export type { GlitchProps } from './presentations/glitch';
|
||||
|
||||
export { rgbSplit } from './presentations/rgb-split';
|
||||
export type { RgbSplitProps } from './presentations/rgb-split';
|
||||
|
||||
export { zoomBlur } from './presentations/zoom-blur';
|
||||
export type { ZoomBlurProps } from './presentations/zoom-blur';
|
||||
|
||||
export { lightLeak } from './presentations/light-leak';
|
||||
export type { LightLeakProps } from './presentations/light-leak';
|
||||
|
||||
export { clockWipe } from './presentations/clock-wipe';
|
||||
export type { ClockWipeProps } from './presentations/clock-wipe';
|
||||
|
||||
export { pixelate } from './presentations/pixelate';
|
||||
export type { PixelateProps } from './presentations/pixelate';
|
||||
|
||||
// Re-export official transitions for convenience
|
||||
export { slide } from '@remotion/transitions/slide';
|
||||
export { fade } from '@remotion/transitions/fade';
|
||||
export { wipe } from '@remotion/transitions/wipe';
|
||||
export { flip } from '@remotion/transitions/flip';
|
||||
|
||||
// Re-export timing functions
|
||||
export { linearTiming, springTiming, TransitionSeries } from '@remotion/transitions';
|
||||
|
||||
// Gallery/showcase components
|
||||
export { TransitionGallery, transitionGalleryConfig, SingleTransitionPreview, transitionMap } from './TransitionGallery';
|
||||
export type { TransitionName } from './TransitionGallery';
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Clock Wipe Transition
|
||||
*
|
||||
* A radial wipe that reveals the scene like clock hands sweeping.
|
||||
* Classic transition with a playful, dynamic quality.
|
||||
*
|
||||
* Best for: Time-related content, reveals, playful videos
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type ClockWipeProps = {
|
||||
/** Starting angle in degrees. Default: 0 (12 o'clock) */
|
||||
startAngle?: number;
|
||||
/** Direction: 'clockwise' or 'counterclockwise'. Default: 'clockwise' */
|
||||
direction?: 'clockwise' | 'counterclockwise';
|
||||
/** Number of wipe segments (1 = single wipe, 2+ = multiple arms). Default: 1 */
|
||||
segments?: number;
|
||||
/** Include soft edge blur. Default: true */
|
||||
softEdge?: boolean;
|
||||
};
|
||||
|
||||
const ClockWipePresentation: React.FC<
|
||||
TransitionPresentationComponentProps<ClockWipeProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
startAngle = 0,
|
||||
direction = 'clockwise',
|
||||
segments = 1,
|
||||
softEdge = true,
|
||||
} = passedProps;
|
||||
|
||||
const [clipId] = useState(() => `clock-wipe-${String(random(null)).slice(2, 10)}`);
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Calculate the sweep angle
|
||||
const sweepAngle = useMemo(() => {
|
||||
const totalSweep = 360 / segments;
|
||||
return interpolate(progress, [0, 1], [0, totalSweep], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress, segments]);
|
||||
|
||||
// For exiting, we use the inverse clip
|
||||
const isRevealing = presentationDirection === 'entering';
|
||||
|
||||
// Generate the SVG path for the pie slice(s)
|
||||
const generateClipPath = () => {
|
||||
const paths: string[] = [];
|
||||
const cx = 50; // Center X (percentage)
|
||||
const cy = 50; // Center Y (percentage)
|
||||
const r = 75; // Radius (large enough to cover corners)
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const segmentStartAngle = startAngle + (i * 360 / segments);
|
||||
const adjustedSweep = direction === 'clockwise' ? sweepAngle : -sweepAngle;
|
||||
const endAngle = segmentStartAngle + adjustedSweep;
|
||||
|
||||
// Convert angles to radians (SVG uses different coordinate system)
|
||||
const startRad = (segmentStartAngle - 90) * Math.PI / 180;
|
||||
const endRad = (endAngle - 90) * Math.PI / 180;
|
||||
|
||||
// Calculate arc endpoints
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
|
||||
// Determine if we need the large arc flag
|
||||
const largeArc = Math.abs(sweepAngle) > 180 ? 1 : 0;
|
||||
const sweepFlag = direction === 'clockwise' ? 1 : 0;
|
||||
|
||||
// Create pie slice path
|
||||
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} ${sweepFlag} ${x2} ${y2} Z`;
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
return paths.join(' ');
|
||||
};
|
||||
|
||||
// Opacity for smooth transition
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0.8, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0, 0.2], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{/* Clipped content */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
clipPath: isRevealing ? `url(#${clipId})` : undefined,
|
||||
WebkitClipPath: isRevealing ? `url(#${clipId})` : undefined,
|
||||
opacity: isRevealing ? opacity : 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* For exiting, show content disappearing */}
|
||||
{!isRevealing && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
clipPath: `url(#${clipId}-inverse)`,
|
||||
WebkitClipPath: `url(#${clipId}-inverse)`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Soft edge glow effect */}
|
||||
{softEdge && sweepAngle > 5 && sweepAngle < 355 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
||||
<defs>
|
||||
<radialGradient id={`${clipId}-glow`}>
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="90%" stopColor="white" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{/* Edge glow line */}
|
||||
{(() => {
|
||||
const edgeAngle = startAngle + (direction === 'clockwise' ? sweepAngle : -sweepAngle);
|
||||
const edgeRad = (edgeAngle - 90) * Math.PI / 180;
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const r = 75;
|
||||
const x = cx + r * Math.cos(edgeRad);
|
||||
const y = cy + r * Math.sin(edgeRad);
|
||||
return (
|
||||
<line
|
||||
x1={`${cx}%`}
|
||||
y1={`${cy}%`}
|
||||
x2={`${x}%`}
|
||||
y2={`${y}%`}
|
||||
stroke="rgba(255, 255, 255, 0.5)"
|
||||
strokeWidth="4"
|
||||
filter="blur(3px)"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* SVG clip path definitions */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d={generateClipPath()}
|
||||
transform="scale(0.01)"
|
||||
/>
|
||||
</clipPath>
|
||||
{/* Inverse clip for exiting */}
|
||||
<clipPath id={`${clipId}-inverse`} clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d={`M 0 0 L 100 0 L 100 100 L 0 100 Z ${generateClipPath()}`}
|
||||
transform="scale(0.01)"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const clockWipe = (
|
||||
props: ClockWipeProps = {}
|
||||
): TransitionPresentation<ClockWipeProps> => {
|
||||
return { component: ClockWipePresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Glitch Transition
|
||||
*
|
||||
* A digital distortion effect perfect for tech-focused videos.
|
||||
* Creates horizontal slice displacement, RGB channel separation,
|
||||
* and scan line artifacts for an authentic glitch aesthetic.
|
||||
*
|
||||
* Best for: Tech demos, cyberpunk themes, edgy reveals
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, random, interpolate } from 'remotion';
|
||||
|
||||
export type GlitchProps = {
|
||||
/** Intensity of the glitch effect (0-1). Default: 0.8 */
|
||||
intensity?: number;
|
||||
/** Number of horizontal slices. Default: 8 */
|
||||
slices?: number;
|
||||
/** Include RGB channel separation. Default: true */
|
||||
rgbShift?: boolean;
|
||||
/** Include scan lines overlay. Default: true */
|
||||
scanLines?: boolean;
|
||||
};
|
||||
|
||||
const GlitchPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<GlitchProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
intensity = 0.8,
|
||||
slices = 8,
|
||||
rgbShift = true,
|
||||
scanLines = true,
|
||||
} = passedProps;
|
||||
|
||||
// For exiting scene, we reverse the effect
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Glitch is most intense in the middle of the transition
|
||||
const glitchIntensity = useMemo(() => {
|
||||
const peak = interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
return peak * intensity;
|
||||
}, [progress, intensity]);
|
||||
|
||||
// Generate deterministic slice offsets
|
||||
const sliceOffsets = useMemo(() => {
|
||||
return Array.from({ length: slices }, (_, i) => {
|
||||
const seed = `glitch-slice-${i}`;
|
||||
const baseOffset = (random(seed) - 0.5) * 60 * glitchIntensity;
|
||||
// Add some temporal variation
|
||||
const flicker = random(`${seed}-${Math.floor(progress * 10)}`) > 0.7 ? 1.5 : 1;
|
||||
return baseOffset * flicker;
|
||||
});
|
||||
}, [slices, glitchIntensity, progress]);
|
||||
|
||||
// RGB shift amounts
|
||||
const rgbShiftAmount = rgbShift ? glitchIntensity * 8 : 0;
|
||||
|
||||
// Opacity for the entering/exiting effect
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.3], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.7, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}), []);
|
||||
|
||||
const sliceHeight = 100 / slices;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{/* Main content with slice displacement */}
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
{sliceOffsets.map((offset, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${i * sliceHeight}%`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${sliceHeight + 0.5}%`, // Slight overlap to prevent gaps
|
||||
overflow: 'hidden',
|
||||
transform: `translateX(${offset}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `-${i * sliceHeight}%`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${100 / sliceHeight * 100}%`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* RGB channel separation overlay */}
|
||||
{rgbShift && glitchIntensity > 0.1 && (
|
||||
<>
|
||||
{/* Red channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity * 0.5 * glitchIntensity,
|
||||
transform: `translateX(${-rgbShiftAmount}px)`,
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'url(#redChannel)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
{/* Cyan channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity * 0.5 * glitchIntensity,
|
||||
transform: `translateX(${rgbShiftAmount}px)`,
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'url(#cyanChannel)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scan lines overlay */}
|
||||
{scanLines && glitchIntensity > 0.1 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: glitchIntensity * 0.3,
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.3) 2px,
|
||||
rgba(0, 0, 0, 0.3) 4px
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Noise overlay for texture */}
|
||||
{glitchIntensity > 0.2 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: glitchIntensity * 0.15,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SVG filters for RGB separation */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id="redChannel">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
<filter id="cyanChannel">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const glitch = (
|
||||
props: GlitchProps = {}
|
||||
): TransitionPresentation<GlitchProps> => {
|
||||
return { component: GlitchPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Light Leak Transition
|
||||
*
|
||||
* Cinematic light leak/lens flare effect that washes over the scene.
|
||||
* Creates warmth, nostalgia, and organic film-like quality.
|
||||
*
|
||||
* Best for: Emotional moments, celebrations, warm transitions, film aesthetic
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type LightLeakProps = {
|
||||
/** Color temperature: 'warm' (orange/gold), 'cool' (blue/cyan), 'rainbow'. Default: 'warm' */
|
||||
temperature?: 'warm' | 'cool' | 'rainbow';
|
||||
/** Direction the light enters from. Default: 'right' */
|
||||
direction?: 'left' | 'right' | 'top' | 'bottom' | 'center';
|
||||
/** Intensity of the overexposure. Default: 0.8 */
|
||||
intensity?: number;
|
||||
/** Include lens flare artifacts. Default: true */
|
||||
flareArtifacts?: boolean;
|
||||
};
|
||||
|
||||
const LightLeakPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<LightLeakProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
temperature = 'warm',
|
||||
direction = 'right',
|
||||
intensity = 0.8,
|
||||
flareArtifacts = true,
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Light leak sweeps across the scene
|
||||
const leakProgress = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.6, 1], [0, 1, 0.2], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Scene exposure (brightens during transition)
|
||||
const exposure = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.4, 0.6, 1], [1, 1.3, 1.3, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.6], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.4, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Color gradients based on temperature
|
||||
const getGradientColors = () => {
|
||||
switch (temperature) {
|
||||
case 'warm':
|
||||
return {
|
||||
primary: 'rgba(255, 180, 80, 0.9)',
|
||||
secondary: 'rgba(255, 120, 50, 0.7)',
|
||||
tertiary: 'rgba(255, 220, 150, 0.5)',
|
||||
};
|
||||
case 'cool':
|
||||
return {
|
||||
primary: 'rgba(100, 180, 255, 0.9)',
|
||||
secondary: 'rgba(150, 220, 255, 0.7)',
|
||||
tertiary: 'rgba(200, 240, 255, 0.5)',
|
||||
};
|
||||
case 'rainbow':
|
||||
return {
|
||||
primary: 'rgba(255, 100, 150, 0.8)',
|
||||
secondary: 'rgba(255, 200, 100, 0.6)',
|
||||
tertiary: 'rgba(100, 200, 255, 0.5)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getGradientColors();
|
||||
|
||||
// Calculate gradient position based on direction
|
||||
const getGradientPosition = () => {
|
||||
const pos = leakProgress * 150 - 50; // -50 to 100
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return `linear-gradient(90deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'right':
|
||||
return `linear-gradient(270deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'top':
|
||||
return `linear-gradient(180deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'bottom':
|
||||
return `linear-gradient(0deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'center':
|
||||
return `radial-gradient(ellipse at center, ${colors.primary} ${pos * 0.5}%, ${colors.secondary} ${pos * 0.7}%, ${colors.tertiary} ${pos}%, transparent ${pos + 30}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// Flare artifact positions (deterministic)
|
||||
const flarePositions = useMemo(() => {
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
x: random(`flare-x-${i}`) * 100,
|
||||
y: random(`flare-y-${i}`) * 100,
|
||||
size: 20 + random(`flare-size-${i}`) * 60,
|
||||
delay: random(`flare-delay-${i}`) * 0.3,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{/* Main content with exposure adjustment */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity,
|
||||
filter: `brightness(${exposure})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Light leak gradient overlay */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: getGradientPosition(),
|
||||
opacity: intensity * leakProgress,
|
||||
mixBlendMode: 'screen',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft glow overlay */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at ${direction === 'left' ? '20%' : direction === 'right' ? '80%' : '50%'} 50%, ${colors.tertiary}, transparent 70%)`,
|
||||
opacity: intensity * leakProgress * 0.5,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lens flare artifacts */}
|
||||
{flareArtifacts && leakProgress > 0.2 && (
|
||||
<AbsoluteFill style={{ pointerEvents: 'none' }}>
|
||||
{flarePositions.map((flare, i) => {
|
||||
const flareOpacity = interpolate(
|
||||
progress,
|
||||
[flare.delay, flare.delay + 0.3, 0.7, 1],
|
||||
[0, 0.6, 0.6, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${flare.x}%`,
|
||||
top: `${flare.y}%`,
|
||||
width: flare.size,
|
||||
height: flare.size,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${temperature === 'warm' ? 'rgba(255, 255, 200, 0.8)' : temperature === 'cool' ? 'rgba(200, 240, 255, 0.8)' : 'rgba(255, 200, 255, 0.8)'}, transparent)`,
|
||||
opacity: flareOpacity * intensity,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hexagonal flare (anamorphic style) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: direction === 'right' ? '70%' : direction === 'left' ? '30%' : '50%',
|
||||
top: '50%',
|
||||
width: 200,
|
||||
height: 30,
|
||||
background: `linear-gradient(90deg, transparent, ${colors.tertiary}, transparent)`,
|
||||
opacity: leakProgress * intensity * 0.7,
|
||||
transform: 'translate(-50%, -50%) rotate(-5deg)',
|
||||
filter: 'blur(10px)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Film grain for authenticity */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: leakProgress * 0.1,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const lightLeak = (
|
||||
props: LightLeakProps = {}
|
||||
): TransitionPresentation<LightLeakProps> => {
|
||||
return { component: LightLeakPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Pixelate Transition
|
||||
*
|
||||
* Digital pixelation/mosaic effect that dissolves the scene into blocks.
|
||||
* Creates a retro gaming or digital artifact aesthetic.
|
||||
*
|
||||
* Best for: Tech themes, retro/gaming content, digital transformations
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type PixelateProps = {
|
||||
/** Maximum block size at peak pixelation. Default: 40 */
|
||||
maxBlockSize?: number;
|
||||
/** Include color posterization with pixelation. Default: true */
|
||||
posterize?: boolean;
|
||||
/** Pixelation pattern: 'uniform' or 'random'. Default: 'uniform' */
|
||||
pattern?: 'uniform' | 'random';
|
||||
};
|
||||
|
||||
const PixelatePresentation: React.FC<
|
||||
TransitionPresentationComponentProps<PixelateProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
maxBlockSize = 40,
|
||||
posterize = true,
|
||||
pattern = 'uniform',
|
||||
} = passedProps;
|
||||
|
||||
const [filterId] = useState(() => `pixelate-${String(random(null)).slice(2, 10)}`);
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Pixelation intensity peaks in the middle
|
||||
const pixelIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Block size calculation (starts small, gets big, then small again)
|
||||
const blockSize = useMemo(() => {
|
||||
const minSize = 1;
|
||||
return Math.max(minSize, Math.round(maxBlockSize * pixelIntensity));
|
||||
}, [maxBlockSize, pixelIntensity]);
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.5], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.5, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Posterization reduces color depth
|
||||
const posterizeLevels = posterize
|
||||
? Math.max(2, Math.round(interpolate(pixelIntensity, [0, 1], [256, 4])))
|
||||
: 256;
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
imageRendering: blockSize > 2 ? 'pixelated' : 'auto',
|
||||
}), [blockSize]);
|
||||
|
||||
// For pattern === 'random', we add noise-based distortion
|
||||
const shouldApplyEffect = pixelIntensity > 0.05;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity,
|
||||
filter: shouldApplyEffect ? `url(#${filterId})` : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Scanline overlay for CRT effect */}
|
||||
{pixelIntensity > 0.3 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: pixelIntensity * 0.2,
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.3) 2px,
|
||||
rgba(0, 0, 0, 0.3) 4px
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color banding effect */}
|
||||
{posterize && pixelIntensity > 0.2 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: pixelIntensity * 0.15,
|
||||
background: `linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 255, 0, 0.05) 0%,
|
||||
rgba(255, 0, 255, 0.05) 50%,
|
||||
rgba(0, 255, 255, 0.05) 100%
|
||||
)`,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SVG filter for pixelation effect */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id={filterId} x="0%" y="0%" width="100%" height="100%">
|
||||
{/* Pixelation via mosaic effect */}
|
||||
{blockSize > 1 && (
|
||||
<>
|
||||
{/* Scale down */}
|
||||
<feImage
|
||||
result="scaled"
|
||||
width={`${100 / blockSize}%`}
|
||||
height={`${100 / blockSize}%`}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
{/* Create mosaic tiles */}
|
||||
<feMorphology
|
||||
operator="dilate"
|
||||
radius={Math.max(1, blockSize / 4)}
|
||||
in="SourceGraphic"
|
||||
result="dilated"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation={blockSize / 2}
|
||||
in="SourceGraphic"
|
||||
result="blurred"
|
||||
/>
|
||||
<feComponentTransfer in="blurred" result="posterized">
|
||||
{posterize && posterizeLevels < 256 && (
|
||||
<>
|
||||
<feFuncR type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
<feFuncG type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
<feFuncB type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
</>
|
||||
)}
|
||||
</feComponentTransfer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add subtle noise for random pattern */}
|
||||
{pattern === 'random' && pixelIntensity > 0.3 && (
|
||||
<>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency={0.05}
|
||||
numOctaves={1}
|
||||
result="noise"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="posterized"
|
||||
in2="noise"
|
||||
scale={pixelIntensity * 10}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate posterization lookup table
|
||||
function generatePosterizeTable(levels: number): string {
|
||||
const step = 1 / (levels - 1);
|
||||
return Array.from({ length: levels }, (_, i) => (i * step).toFixed(3)).join(' ');
|
||||
}
|
||||
|
||||
export const pixelate = (
|
||||
props: PixelateProps = {}
|
||||
): TransitionPresentation<PixelateProps> => {
|
||||
return { component: PixelatePresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* RGB Split Transition
|
||||
*
|
||||
* Chromatic aberration effect that separates RGB channels
|
||||
* with directional displacement. Creates a modern tech aesthetic
|
||||
* reminiscent of CRT displays and retro-futuristic visuals.
|
||||
*
|
||||
* Best for: Tech products, modern branding, energetic transitions
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate } from 'remotion';
|
||||
|
||||
export type RgbSplitProps = {
|
||||
/** Direction of the split: 'horizontal' | 'vertical' | 'diagonal'. Default: 'horizontal' */
|
||||
direction?: 'horizontal' | 'vertical' | 'diagonal';
|
||||
/** Maximum pixel displacement. Default: 30 */
|
||||
displacement?: number;
|
||||
/** Include subtle blur on channels. Default: true */
|
||||
channelBlur?: boolean;
|
||||
};
|
||||
|
||||
const RgbSplitPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<RgbSplitProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
direction = 'horizontal',
|
||||
displacement = 30,
|
||||
channelBlur = true,
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Split intensity peaks in the middle
|
||||
const splitIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Calculate channel offsets based on direction
|
||||
const getChannelOffset = (channel: 'red' | 'green' | 'blue') => {
|
||||
const multiplier = channel === 'red' ? -1 : channel === 'blue' ? 1 : 0;
|
||||
const offset = displacement * splitIntensity * multiplier;
|
||||
|
||||
switch (direction) {
|
||||
case 'horizontal':
|
||||
return { x: offset, y: 0 };
|
||||
case 'vertical':
|
||||
return { x: 0, y: offset };
|
||||
case 'diagonal':
|
||||
return { x: offset * 0.7, y: offset * 0.7 };
|
||||
}
|
||||
};
|
||||
|
||||
const redOffset = getChannelOffset('red');
|
||||
const blueOffset = getChannelOffset('blue');
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.4], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.6, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Blur amount for channels (subtle motion blur effect)
|
||||
const blurAmount = channelBlur ? splitIntensity * 2 : 0;
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}), []);
|
||||
|
||||
// Only show RGB separation when there's actual displacement
|
||||
const showSplit = splitIntensity > 0.05;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{showSplit ? (
|
||||
<>
|
||||
{/* Red channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
transform: `translate(${redOffset.x}px, ${redOffset.y}px)`,
|
||||
filter: blurAmount > 0 ? `blur(${blurAmount}px)` : undefined,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-red)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Green channel (center, no offset) */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-green)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Blue channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
transform: `translate(${blueOffset.x}px, ${blueOffset.y}px)`,
|
||||
filter: blurAmount > 0 ? `blur(${blurAmount}px)` : undefined,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-blue)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</>
|
||||
) : (
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* SVG filters for channel isolation */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id="rgbSplit-red" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
<filter id="rgbSplit-green" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
<filter id="rgbSplit-blue" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const rgbSplit = (
|
||||
props: RgbSplitProps = {}
|
||||
): TransitionPresentation<RgbSplitProps> => {
|
||||
return { component: RgbSplitPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Zoom Blur Transition
|
||||
*
|
||||
* Radial motion blur combined with scale for high-energy transitions.
|
||||
* Creates a sense of speed, impact, and forward momentum.
|
||||
*
|
||||
* Best for: CTAs, reveals, action sequences, energetic moments
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate } from 'remotion';
|
||||
|
||||
export type ZoomBlurProps = {
|
||||
/** Direction: 'in' zooms toward viewer, 'out' zooms away. Default: 'in' */
|
||||
direction?: 'in' | 'out';
|
||||
/** Maximum blur amount in pixels. Default: 20 */
|
||||
blurAmount?: number;
|
||||
/** Scale multiplier at peak. Default: 1.15 */
|
||||
scaleAmount?: number;
|
||||
/** Origin point for zoom. Default: 'center' */
|
||||
origin?: 'center' | 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
|
||||
const ZoomBlurPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<ZoomBlurProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
direction = 'in',
|
||||
blurAmount = 20,
|
||||
scaleAmount = 1.15,
|
||||
origin = 'center',
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Effect intensity peaks in the middle then settles
|
||||
const effectIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.4, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Scale animation
|
||||
const scale = useMemo(() => {
|
||||
if (direction === 'in') {
|
||||
// Start small, zoom in
|
||||
return interpolate(
|
||||
progress,
|
||||
[0, 0.5, 1],
|
||||
[1 / scaleAmount, scaleAmount, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
} else {
|
||||
// Start big, zoom out
|
||||
return interpolate(
|
||||
progress,
|
||||
[0, 0.5, 1],
|
||||
[scaleAmount, 1 / scaleAmount, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
}
|
||||
}, [progress, direction, scaleAmount]);
|
||||
|
||||
// Blur tracks with scale movement
|
||||
const blur = blurAmount * effectIntensity;
|
||||
|
||||
// Opacity
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.5], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.5, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Transform origin based on setting
|
||||
const transformOrigin = useMemo(() => {
|
||||
switch (origin) {
|
||||
case 'top': return 'center top';
|
||||
case 'bottom': return 'center bottom';
|
||||
case 'left': return 'left center';
|
||||
case 'right': return 'right center';
|
||||
default: return 'center center';
|
||||
}
|
||||
}, [origin]);
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}), []);
|
||||
|
||||
const contentStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin,
|
||||
filter: blur > 0.5 ? `blur(${blur}px)` : undefined,
|
||||
opacity,
|
||||
}), [scale, transformOrigin, blur, opacity]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
<div style={contentStyle}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Radial light streak overlay for extra energy */}
|
||||
{effectIntensity > 0.3 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: effectIntensity * 0.4,
|
||||
background: `radial-gradient(
|
||||
ellipse at ${origin === 'center' ? '50% 50%' : origin === 'top' ? '50% 0%' : origin === 'bottom' ? '50% 100%' : origin === 'left' ? '0% 50%' : '100% 50%'},
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 70%
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const zoomBlur = (
|
||||
props: ZoomBlurProps = {}
|
||||
): TransitionPresentation<ZoomBlurProps> => {
|
||||
return { component: ZoomBlurPresentation, props };
|
||||
};
|
||||
Generated
+2763
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "transitions-showcase",
|
||||
"version": "1.0.0",
|
||||
"description": "Visual gallery of all available transitions",
|
||||
"scripts": {
|
||||
"studio": "remotion studio",
|
||||
"render": "remotion render TransitionGallery out/transitions-gallery.mp4",
|
||||
"render:gif": "remotion render TransitionGallery out/transitions-gallery.gif --image-format=png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"@remotion/paths": "^4.0.0",
|
||||
"@remotion/shapes": "^4.0.0",
|
||||
"@remotion/transitions": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"remotion": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Config } from '@remotion/cli/config';
|
||||
|
||||
Config.setEntryPoint('./src/index.ts');
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Composition } from 'remotion';
|
||||
import { TransitionGallery, transitionGalleryConfig } from './TransitionGallery';
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id={transitionGalleryConfig.id}
|
||||
component={TransitionGallery}
|
||||
durationInFrames={transitionGalleryConfig.durationInFrames}
|
||||
fps={transitionGalleryConfig.fps}
|
||||
width={transitionGalleryConfig.width}
|
||||
height={transitionGalleryConfig.height}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Transition Gallery
|
||||
*
|
||||
* A visual showcase of all available transitions.
|
||||
* Each transition is demonstrated with consistent before/after scenes,
|
||||
* labeled clearly for easy comparison.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate, Sequence } from 'remotion';
|
||||
import { TransitionSeries, linearTiming } from '@remotion/transitions';
|
||||
import { slide } from '@remotion/transitions/slide';
|
||||
import { fade } from '@remotion/transitions/fade';
|
||||
import { wipe } from '@remotion/transitions/wipe';
|
||||
import { flip } from '@remotion/transitions/flip';
|
||||
import { glitch } from './presentations/glitch';
|
||||
import { rgbSplit } from './presentations/rgb-split';
|
||||
import { zoomBlur } from './presentations/zoom-blur';
|
||||
import { lightLeak } from './presentations/light-leak';
|
||||
import { clockWipe } from './presentations/clock-wipe';
|
||||
import { pixelate } from './presentations/pixelate';
|
||||
|
||||
// Scene colors for visual variety
|
||||
const SCENE_A_COLOR = '#1a1a2e';
|
||||
const SCENE_B_COLOR = '#e94560';
|
||||
|
||||
// Consistent scene component
|
||||
const GalleryScene: React.FC<{
|
||||
color: string;
|
||||
label: string;
|
||||
isAfter?: boolean;
|
||||
}> = ({ color, label, isAfter = false }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const labelOpacity = interpolate(frame, [0, 10], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Transition name label */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
opacity: labelOpacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
padding: '12px 32px',
|
||||
borderRadius: 8,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Before/After indicator */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 120,
|
||||
fontWeight: 800,
|
||||
color: 'rgba(255, 255, 255, 0.15)',
|
||||
letterSpacing: '-4px',
|
||||
}}
|
||||
>
|
||||
{isAfter ? 'B' : 'A'}
|
||||
</div>
|
||||
|
||||
{/* Scene indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
fontSize: 18,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isAfter ? 'After' : 'Before'}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Single transition demo segment
|
||||
const TransitionDemo: React.FC<{
|
||||
name: string;
|
||||
presentation: ReturnType<typeof glitch>;
|
||||
transitionDuration?: number;
|
||||
}> = ({ name, presentation, transitionDuration = 20 }) => {
|
||||
const sceneDuration = 45; // 1.5 seconds per scene
|
||||
|
||||
return (
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<GalleryScene color={SCENE_A_COLOR} label={name} />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={presentation}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<GalleryScene color={SCENE_B_COLOR} label={name} isAfter />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
);
|
||||
};
|
||||
|
||||
// Intro slide
|
||||
const IntroSlide: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const titleOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
|
||||
const subtitleOpacity = interpolate(frame, [15, 35], [0, 1], { extrapolateRight: 'clamp' });
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: '#0f0f1a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
margin: 0,
|
||||
opacity: titleOpacity,
|
||||
letterSpacing: '-2px',
|
||||
}}
|
||||
>
|
||||
Transitions Gallery
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 20,
|
||||
opacity: subtitleOpacity,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
claude-code-video-toolkit
|
||||
</p>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Define all transitions to showcase
|
||||
const TRANSITIONS = [
|
||||
{ name: 'glitch()', presentation: glitch({ intensity: 0.9 }), duration: 25 },
|
||||
{ name: 'rgbSplit()', presentation: rgbSplit({ direction: 'horizontal' }), duration: 25 },
|
||||
{ name: 'zoomBlur()', presentation: zoomBlur({ direction: 'in' }), duration: 25 },
|
||||
{ name: 'lightLeak()', presentation: lightLeak({ temperature: 'warm' }), duration: 35 },
|
||||
{ name: 'clockWipe()', presentation: clockWipe({ direction: 'clockwise' }), duration: 25 },
|
||||
{ name: 'pixelate()', presentation: pixelate({ maxBlockSize: 50 }), duration: 25 },
|
||||
{ name: 'slide()', presentation: slide(), duration: 20 },
|
||||
{ name: 'fade()', presentation: fade(), duration: 25 },
|
||||
{ name: 'wipe()', presentation: wipe(), duration: 20 },
|
||||
{ name: 'flip()', presentation: flip(), duration: 25 },
|
||||
];
|
||||
|
||||
// Calculate segment duration
|
||||
const getSegmentDuration = (transitionDuration: number) => {
|
||||
const sceneDuration = 45;
|
||||
return sceneDuration * 2 - transitionDuration;
|
||||
};
|
||||
|
||||
export const TransitionGallery: React.FC = () => {
|
||||
const introDuration = 60;
|
||||
|
||||
let currentFrame = introDuration;
|
||||
const segments: { name: string; from: number; duration: number }[] = [];
|
||||
|
||||
TRANSITIONS.forEach((t) => {
|
||||
const duration = getSegmentDuration(t.duration);
|
||||
segments.push({ name: t.name, from: currentFrame, duration });
|
||||
currentFrame += duration;
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: '#0f0f1a' }}>
|
||||
{/* Intro */}
|
||||
<Sequence durationInFrames={introDuration}>
|
||||
<IntroSlide />
|
||||
</Sequence>
|
||||
|
||||
{/* Each transition demo */}
|
||||
{TRANSITIONS.map((t, i) => (
|
||||
<Sequence
|
||||
key={t.name}
|
||||
from={segments[i].from}
|
||||
durationInFrames={segments[i].duration}
|
||||
>
|
||||
<TransitionDemo
|
||||
name={t.name}
|
||||
presentation={t.presentation}
|
||||
transitionDuration={t.duration}
|
||||
/>
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Export configuration
|
||||
export const transitionGalleryConfig = {
|
||||
id: 'TransitionGallery',
|
||||
component: TransitionGallery,
|
||||
durationInFrames: 60 + TRANSITIONS.reduce(
|
||||
(acc, t) => acc + getSegmentDuration(t.duration),
|
||||
0
|
||||
),
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Clock Wipe Transition
|
||||
*
|
||||
* A radial wipe that reveals the scene like clock hands sweeping.
|
||||
* Classic transition with a playful, dynamic quality.
|
||||
*
|
||||
* Best for: Time-related content, reveals, playful videos
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type ClockWipeProps = {
|
||||
/** Starting angle in degrees. Default: 0 (12 o'clock) */
|
||||
startAngle?: number;
|
||||
/** Direction: 'clockwise' or 'counterclockwise'. Default: 'clockwise' */
|
||||
direction?: 'clockwise' | 'counterclockwise';
|
||||
/** Number of wipe segments (1 = single wipe, 2+ = multiple arms). Default: 1 */
|
||||
segments?: number;
|
||||
/** Include soft edge blur. Default: true */
|
||||
softEdge?: boolean;
|
||||
};
|
||||
|
||||
const ClockWipePresentation: React.FC<
|
||||
TransitionPresentationComponentProps<ClockWipeProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
startAngle = 0,
|
||||
direction = 'clockwise',
|
||||
segments = 1,
|
||||
softEdge = true,
|
||||
} = passedProps;
|
||||
|
||||
const [clipId] = useState(() => `clock-wipe-${String(random(null)).slice(2, 10)}`);
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Calculate the sweep angle
|
||||
const sweepAngle = useMemo(() => {
|
||||
const totalSweep = 360 / segments;
|
||||
return interpolate(progress, [0, 1], [0, totalSweep], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress, segments]);
|
||||
|
||||
// For exiting, we use the inverse clip
|
||||
const isRevealing = presentationDirection === 'entering';
|
||||
|
||||
// Generate the SVG path for the pie slice(s)
|
||||
const generateClipPath = () => {
|
||||
const paths: string[] = [];
|
||||
const cx = 50; // Center X (percentage)
|
||||
const cy = 50; // Center Y (percentage)
|
||||
const r = 75; // Radius (large enough to cover corners)
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const segmentStartAngle = startAngle + (i * 360 / segments);
|
||||
const adjustedSweep = direction === 'clockwise' ? sweepAngle : -sweepAngle;
|
||||
const endAngle = segmentStartAngle + adjustedSweep;
|
||||
|
||||
// Convert angles to radians (SVG uses different coordinate system)
|
||||
const startRad = (segmentStartAngle - 90) * Math.PI / 180;
|
||||
const endRad = (endAngle - 90) * Math.PI / 180;
|
||||
|
||||
// Calculate arc endpoints
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
|
||||
// Determine if we need the large arc flag
|
||||
const largeArc = Math.abs(sweepAngle) > 180 ? 1 : 0;
|
||||
const sweepFlag = direction === 'clockwise' ? 1 : 0;
|
||||
|
||||
// Create pie slice path
|
||||
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} ${sweepFlag} ${x2} ${y2} Z`;
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
return paths.join(' ');
|
||||
};
|
||||
|
||||
// Opacity for smooth transition
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0.8, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0, 0.2], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{/* Clipped content */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
clipPath: isRevealing ? `url(#${clipId})` : undefined,
|
||||
WebkitClipPath: isRevealing ? `url(#${clipId})` : undefined,
|
||||
opacity: isRevealing ? opacity : 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* For exiting, show content disappearing */}
|
||||
{!isRevealing && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
clipPath: `url(#${clipId}-inverse)`,
|
||||
WebkitClipPath: `url(#${clipId}-inverse)`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Soft edge glow effect */}
|
||||
{softEdge && sweepAngle > 5 && sweepAngle < 355 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
||||
<defs>
|
||||
<radialGradient id={`${clipId}-glow`}>
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="90%" stopColor="white" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{/* Edge glow line */}
|
||||
{(() => {
|
||||
const edgeAngle = startAngle + (direction === 'clockwise' ? sweepAngle : -sweepAngle);
|
||||
const edgeRad = (edgeAngle - 90) * Math.PI / 180;
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const r = 75;
|
||||
const x = cx + r * Math.cos(edgeRad);
|
||||
const y = cy + r * Math.sin(edgeRad);
|
||||
return (
|
||||
<line
|
||||
x1={`${cx}%`}
|
||||
y1={`${cy}%`}
|
||||
x2={`${x}%`}
|
||||
y2={`${y}%`}
|
||||
stroke="rgba(255, 255, 255, 0.5)"
|
||||
strokeWidth="4"
|
||||
filter="blur(3px)"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* SVG clip path definitions */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d={generateClipPath()}
|
||||
transform="scale(0.01)"
|
||||
/>
|
||||
</clipPath>
|
||||
{/* Inverse clip for exiting */}
|
||||
<clipPath id={`${clipId}-inverse`} clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d={`M 0 0 L 100 0 L 100 100 L 0 100 Z ${generateClipPath()}`}
|
||||
transform="scale(0.01)"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const clockWipe = (
|
||||
props: ClockWipeProps = {}
|
||||
): TransitionPresentation<ClockWipeProps> => {
|
||||
return { component: ClockWipePresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Glitch Transition
|
||||
*
|
||||
* A digital distortion effect perfect for tech-focused videos.
|
||||
* Creates horizontal slice displacement, RGB channel separation,
|
||||
* and scan line artifacts for an authentic glitch aesthetic.
|
||||
*
|
||||
* Best for: Tech demos, cyberpunk themes, edgy reveals
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, random, interpolate } from 'remotion';
|
||||
|
||||
export type GlitchProps = {
|
||||
/** Intensity of the glitch effect (0-1). Default: 0.8 */
|
||||
intensity?: number;
|
||||
/** Number of horizontal slices. Default: 8 */
|
||||
slices?: number;
|
||||
/** Include RGB channel separation. Default: true */
|
||||
rgbShift?: boolean;
|
||||
/** Include scan lines overlay. Default: true */
|
||||
scanLines?: boolean;
|
||||
};
|
||||
|
||||
const GlitchPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<GlitchProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
intensity = 0.8,
|
||||
slices = 8,
|
||||
rgbShift = true,
|
||||
scanLines = true,
|
||||
} = passedProps;
|
||||
|
||||
// For exiting scene, we reverse the effect
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Glitch is most intense in the middle of the transition
|
||||
const glitchIntensity = useMemo(() => {
|
||||
const peak = interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
return peak * intensity;
|
||||
}, [progress, intensity]);
|
||||
|
||||
// Generate deterministic slice offsets
|
||||
const sliceOffsets = useMemo(() => {
|
||||
return Array.from({ length: slices }, (_, i) => {
|
||||
const seed = `glitch-slice-${i}`;
|
||||
const baseOffset = (random(seed) - 0.5) * 80 * glitchIntensity;
|
||||
// Add temporal variation for flicker effect
|
||||
const flicker = random(`${seed}-${Math.floor(progress * 8)}`) > 0.6 ? 1.8 : 1;
|
||||
return baseOffset * flicker;
|
||||
});
|
||||
}, [slices, glitchIntensity, progress]);
|
||||
|
||||
// RGB shift amounts
|
||||
const rgbShiftAmount = rgbShift ? glitchIntensity * 12 : 0;
|
||||
|
||||
// Opacity for the entering/exiting effect
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.4], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.6, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
const sliceHeightPercent = 100 / slices;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: 'hidden' }}>
|
||||
{/* Main content with slice displacement using clip-path */}
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
{sliceOffsets.map((offset, i) => {
|
||||
const topPercent = i * sliceHeightPercent;
|
||||
const bottomPercent = (i + 1) * sliceHeightPercent;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
key={i}
|
||||
style={{
|
||||
clipPath: `polygon(0% ${topPercent}%, 100% ${topPercent}%, 100% ${bottomPercent}%, 0% ${bottomPercent}%)`,
|
||||
transform: `translateX(${offset}px)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* RGB channel separation overlay */}
|
||||
{rgbShift && glitchIntensity > 0.1 && (
|
||||
<>
|
||||
{/* Red channel - shifted left */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity * 0.4 * glitchIntensity,
|
||||
transform: `translateX(${-rgbShiftAmount}px)`,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'saturate(2) hue-rotate(-30deg)',
|
||||
background: 'rgba(255, 0, 0, 0.3)',
|
||||
mixBlendMode: 'multiply',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
{/* Cyan channel - shifted right */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity * 0.4 * glitchIntensity,
|
||||
transform: `translateX(${rgbShiftAmount}px)`,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'saturate(2) hue-rotate(150deg)',
|
||||
background: 'rgba(0, 255, 255, 0.3)',
|
||||
mixBlendMode: 'multiply',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scan lines overlay */}
|
||||
{scanLines && glitchIntensity > 0.1 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: glitchIntensity * 0.4,
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.4) 2px,
|
||||
rgba(0, 0, 0, 0.4) 4px
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Random block glitches */}
|
||||
{glitchIntensity > 0.3 && (
|
||||
<AbsoluteFill style={{ pointerEvents: 'none' }}>
|
||||
{Array.from({ length: 3 }, (_, i) => {
|
||||
const blockSeed = `block-${i}-${Math.floor(progress * 6)}`;
|
||||
const show = random(blockSeed) > 0.5;
|
||||
if (!show) return null;
|
||||
|
||||
const x = random(`${blockSeed}-x`) * 80;
|
||||
const y = random(`${blockSeed}-y`) * 100;
|
||||
const w = 10 + random(`${blockSeed}-w`) * 30;
|
||||
const h = 2 + random(`${blockSeed}-h`) * 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
width: `${w}%`,
|
||||
height: `${h}%`,
|
||||
backgroundColor: random(`${blockSeed}-c`) > 0.5
|
||||
? `rgba(255, 0, 100, ${glitchIntensity * 0.5})`
|
||||
: `rgba(0, 255, 200, ${glitchIntensity * 0.5})`,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Noise texture overlay */}
|
||||
{glitchIntensity > 0.2 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: glitchIntensity * 0.2,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const glitch = (
|
||||
props: GlitchProps = {}
|
||||
): TransitionPresentation<GlitchProps> => {
|
||||
return { component: GlitchPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Light Leak Transition
|
||||
*
|
||||
* Cinematic light leak/lens flare effect that washes over the scene.
|
||||
* Creates warmth, nostalgia, and organic film-like quality.
|
||||
*
|
||||
* Best for: Emotional moments, celebrations, warm transitions, film aesthetic
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type LightLeakProps = {
|
||||
/** Color temperature: 'warm' (orange/gold), 'cool' (blue/cyan), 'rainbow'. Default: 'warm' */
|
||||
temperature?: 'warm' | 'cool' | 'rainbow';
|
||||
/** Direction the light enters from. Default: 'right' */
|
||||
direction?: 'left' | 'right' | 'top' | 'bottom' | 'center';
|
||||
/** Intensity of the overexposure. Default: 0.8 */
|
||||
intensity?: number;
|
||||
/** Include lens flare artifacts. Default: true */
|
||||
flareArtifacts?: boolean;
|
||||
};
|
||||
|
||||
const LightLeakPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<LightLeakProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
temperature = 'warm',
|
||||
direction = 'right',
|
||||
intensity = 0.8,
|
||||
flareArtifacts = true,
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Light leak sweeps across the scene
|
||||
const leakProgress = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.6, 1], [0, 1, 0.2], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Scene exposure (brightens during transition)
|
||||
const exposure = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.4, 0.6, 1], [1, 1.3, 1.3, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.6], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.4, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Color gradients based on temperature
|
||||
const getGradientColors = () => {
|
||||
switch (temperature) {
|
||||
case 'warm':
|
||||
return {
|
||||
primary: 'rgba(255, 180, 80, 0.9)',
|
||||
secondary: 'rgba(255, 120, 50, 0.7)',
|
||||
tertiary: 'rgba(255, 220, 150, 0.5)',
|
||||
};
|
||||
case 'cool':
|
||||
return {
|
||||
primary: 'rgba(100, 180, 255, 0.9)',
|
||||
secondary: 'rgba(150, 220, 255, 0.7)',
|
||||
tertiary: 'rgba(200, 240, 255, 0.5)',
|
||||
};
|
||||
case 'rainbow':
|
||||
return {
|
||||
primary: 'rgba(255, 100, 150, 0.8)',
|
||||
secondary: 'rgba(255, 200, 100, 0.6)',
|
||||
tertiary: 'rgba(100, 200, 255, 0.5)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getGradientColors();
|
||||
|
||||
// Calculate gradient position based on direction
|
||||
const getGradientPosition = () => {
|
||||
const pos = leakProgress * 150 - 50; // -50 to 100
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return `linear-gradient(90deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'right':
|
||||
return `linear-gradient(270deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'top':
|
||||
return `linear-gradient(180deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'bottom':
|
||||
return `linear-gradient(0deg, ${colors.primary} ${pos}%, ${colors.secondary} ${pos + 20}%, ${colors.tertiary} ${pos + 40}%, transparent ${pos + 60}%)`;
|
||||
case 'center':
|
||||
return `radial-gradient(ellipse at center, ${colors.primary} ${pos * 0.5}%, ${colors.secondary} ${pos * 0.7}%, ${colors.tertiary} ${pos}%, transparent ${pos + 30}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// Flare artifact positions (deterministic)
|
||||
const flarePositions = useMemo(() => {
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
x: random(`flare-x-${i}`) * 100,
|
||||
y: random(`flare-y-${i}`) * 100,
|
||||
size: 20 + random(`flare-size-${i}`) * 60,
|
||||
delay: random(`flare-delay-${i}`) * 0.3,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{/* Main content with exposure adjustment */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity,
|
||||
filter: `brightness(${exposure})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Light leak gradient overlay */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: getGradientPosition(),
|
||||
opacity: intensity * leakProgress,
|
||||
mixBlendMode: 'screen',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft glow overlay */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at ${direction === 'left' ? '20%' : direction === 'right' ? '80%' : '50%'} 50%, ${colors.tertiary}, transparent 70%)`,
|
||||
opacity: intensity * leakProgress * 0.5,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lens flare artifacts */}
|
||||
{flareArtifacts && leakProgress > 0.2 && (
|
||||
<AbsoluteFill style={{ pointerEvents: 'none' }}>
|
||||
{flarePositions.map((flare, i) => {
|
||||
const flareOpacity = interpolate(
|
||||
progress,
|
||||
[flare.delay, flare.delay + 0.3, 0.7, 1],
|
||||
[0, 0.6, 0.6, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${flare.x}%`,
|
||||
top: `${flare.y}%`,
|
||||
width: flare.size,
|
||||
height: flare.size,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${temperature === 'warm' ? 'rgba(255, 255, 200, 0.8)' : temperature === 'cool' ? 'rgba(200, 240, 255, 0.8)' : 'rgba(255, 200, 255, 0.8)'}, transparent)`,
|
||||
opacity: flareOpacity * intensity,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hexagonal flare (anamorphic style) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: direction === 'right' ? '70%' : direction === 'left' ? '30%' : '50%',
|
||||
top: '50%',
|
||||
width: 200,
|
||||
height: 30,
|
||||
background: `linear-gradient(90deg, transparent, ${colors.tertiary}, transparent)`,
|
||||
opacity: leakProgress * intensity * 0.7,
|
||||
transform: 'translate(-50%, -50%) rotate(-5deg)',
|
||||
filter: 'blur(10px)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Film grain for authenticity */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: leakProgress * 0.1,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const lightLeak = (
|
||||
props: LightLeakProps = {}
|
||||
): TransitionPresentation<LightLeakProps> => {
|
||||
return { component: LightLeakPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Pixelate Transition
|
||||
*
|
||||
* Digital pixelation/mosaic effect that dissolves the scene into blocks.
|
||||
* Creates a retro gaming or digital artifact aesthetic.
|
||||
*
|
||||
* Best for: Tech themes, retro/gaming content, digital transformations
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AbsoluteFill, interpolate, random } from 'remotion';
|
||||
|
||||
export type PixelateProps = {
|
||||
/** Maximum block size at peak pixelation. Default: 40 */
|
||||
maxBlockSize?: number;
|
||||
/** Include color posterization with pixelation. Default: true */
|
||||
posterize?: boolean;
|
||||
/** Pixelation pattern: 'uniform' or 'random'. Default: 'uniform' */
|
||||
pattern?: 'uniform' | 'random';
|
||||
};
|
||||
|
||||
const PixelatePresentation: React.FC<
|
||||
TransitionPresentationComponentProps<PixelateProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
maxBlockSize = 40,
|
||||
posterize = true,
|
||||
pattern = 'uniform',
|
||||
} = passedProps;
|
||||
|
||||
const [filterId] = useState(() => `pixelate-${String(random(null)).slice(2, 10)}`);
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Pixelation intensity peaks in the middle
|
||||
const pixelIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Block size calculation (starts small, gets big, then small again)
|
||||
const blockSize = useMemo(() => {
|
||||
const minSize = 1;
|
||||
return Math.max(minSize, Math.round(maxBlockSize * pixelIntensity));
|
||||
}, [maxBlockSize, pixelIntensity]);
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.5], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.5, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Posterization reduces color depth
|
||||
const posterizeLevels = posterize
|
||||
? Math.max(2, Math.round(interpolate(pixelIntensity, [0, 1], [256, 4])))
|
||||
: 256;
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
imageRendering: blockSize > 2 ? 'pixelated' : 'auto',
|
||||
}), [blockSize]);
|
||||
|
||||
// For pattern === 'random', we add noise-based distortion
|
||||
const shouldApplyEffect = pixelIntensity > 0.05;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity,
|
||||
filter: shouldApplyEffect ? `url(#${filterId})` : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Scanline overlay for CRT effect */}
|
||||
{pixelIntensity > 0.3 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: pixelIntensity * 0.2,
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.3) 2px,
|
||||
rgba(0, 0, 0, 0.3) 4px
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Color banding effect */}
|
||||
{posterize && pixelIntensity > 0.2 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: pixelIntensity * 0.15,
|
||||
background: `linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 255, 0, 0.05) 0%,
|
||||
rgba(255, 0, 255, 0.05) 50%,
|
||||
rgba(0, 255, 255, 0.05) 100%
|
||||
)`,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SVG filter for pixelation effect */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id={filterId} x="0%" y="0%" width="100%" height="100%">
|
||||
{/* Pixelation via mosaic effect */}
|
||||
{blockSize > 1 && (
|
||||
<>
|
||||
{/* Scale down */}
|
||||
<feImage
|
||||
result="scaled"
|
||||
width={`${100 / blockSize}%`}
|
||||
height={`${100 / blockSize}%`}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
{/* Create mosaic tiles */}
|
||||
<feMorphology
|
||||
operator="dilate"
|
||||
radius={Math.max(1, blockSize / 4)}
|
||||
in="SourceGraphic"
|
||||
result="dilated"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation={blockSize / 2}
|
||||
in="SourceGraphic"
|
||||
result="blurred"
|
||||
/>
|
||||
<feComponentTransfer in="blurred" result="posterized">
|
||||
{posterize && posterizeLevels < 256 && (
|
||||
<>
|
||||
<feFuncR type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
<feFuncG type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
<feFuncB type="discrete" tableValues={generatePosterizeTable(posterizeLevels)} />
|
||||
</>
|
||||
)}
|
||||
</feComponentTransfer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add subtle noise for random pattern */}
|
||||
{pattern === 'random' && pixelIntensity > 0.3 && (
|
||||
<>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency={0.05}
|
||||
numOctaves={1}
|
||||
result="noise"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="posterized"
|
||||
in2="noise"
|
||||
scale={pixelIntensity * 10}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate posterization lookup table
|
||||
function generatePosterizeTable(levels: number): string {
|
||||
const step = 1 / (levels - 1);
|
||||
return Array.from({ length: levels }, (_, i) => (i * step).toFixed(3)).join(' ');
|
||||
}
|
||||
|
||||
export const pixelate = (
|
||||
props: PixelateProps = {}
|
||||
): TransitionPresentation<PixelateProps> => {
|
||||
return { component: PixelatePresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* RGB Split Transition
|
||||
*
|
||||
* Chromatic aberration effect that separates RGB channels
|
||||
* with directional displacement. Creates a modern tech aesthetic
|
||||
* reminiscent of CRT displays and retro-futuristic visuals.
|
||||
*
|
||||
* Best for: Tech products, modern branding, energetic transitions
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate } from 'remotion';
|
||||
|
||||
export type RgbSplitProps = {
|
||||
/** Direction of the split: 'horizontal' | 'vertical' | 'diagonal'. Default: 'horizontal' */
|
||||
direction?: 'horizontal' | 'vertical' | 'diagonal';
|
||||
/** Maximum pixel displacement. Default: 30 */
|
||||
displacement?: number;
|
||||
/** Include subtle blur on channels. Default: true */
|
||||
channelBlur?: boolean;
|
||||
};
|
||||
|
||||
const RgbSplitPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<RgbSplitProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
direction = 'horizontal',
|
||||
displacement = 30,
|
||||
channelBlur = true,
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Split intensity peaks in the middle
|
||||
const splitIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.5, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Calculate channel offsets based on direction
|
||||
const getChannelOffset = (channel: 'red' | 'green' | 'blue') => {
|
||||
const multiplier = channel === 'red' ? -1 : channel === 'blue' ? 1 : 0;
|
||||
const offset = displacement * splitIntensity * multiplier;
|
||||
|
||||
switch (direction) {
|
||||
case 'horizontal':
|
||||
return { x: offset, y: 0 };
|
||||
case 'vertical':
|
||||
return { x: 0, y: offset };
|
||||
case 'diagonal':
|
||||
return { x: offset * 0.7, y: offset * 0.7 };
|
||||
}
|
||||
};
|
||||
|
||||
const redOffset = getChannelOffset('red');
|
||||
const blueOffset = getChannelOffset('blue');
|
||||
|
||||
// Opacity for entering/exiting
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.4], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.6, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Blur amount for channels (subtle motion blur effect)
|
||||
const blurAmount = channelBlur ? splitIntensity * 2 : 0;
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}), []);
|
||||
|
||||
// Only show RGB separation when there's actual displacement
|
||||
const showSplit = splitIntensity > 0.05;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
{showSplit ? (
|
||||
<>
|
||||
{/* Red channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
transform: `translate(${redOffset.x}px, ${redOffset.y}px)`,
|
||||
filter: blurAmount > 0 ? `blur(${blurAmount}px)` : undefined,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-red)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Green channel (center, no offset) */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-green)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Blue channel */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: opacity,
|
||||
transform: `translate(${blueOffset.x}px, ${blueOffset.y}px)`,
|
||||
filter: blurAmount > 0 ? `blur(${blurAmount}px)` : undefined,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: 'url(#rgbSplit-blue)',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</>
|
||||
) : (
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* SVG filters for channel isolation */}
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id="rgbSplit-red" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
<filter id="rgbSplit-green" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
<filter id="rgbSplit-blue" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 1 0"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const rgbSplit = (
|
||||
props: RgbSplitProps = {}
|
||||
): TransitionPresentation<RgbSplitProps> => {
|
||||
return { component: RgbSplitPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Zoom Blur Transition
|
||||
*
|
||||
* Radial motion blur combined with scale for high-energy transitions.
|
||||
* Creates a sense of speed, impact, and forward momentum.
|
||||
*
|
||||
* Best for: CTAs, reveals, action sequences, energetic moments
|
||||
*/
|
||||
import type {
|
||||
TransitionPresentation,
|
||||
TransitionPresentationComponentProps,
|
||||
} from '@remotion/transitions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { AbsoluteFill, interpolate } from 'remotion';
|
||||
|
||||
export type ZoomBlurProps = {
|
||||
/** Direction: 'in' zooms toward viewer, 'out' zooms away. Default: 'in' */
|
||||
direction?: 'in' | 'out';
|
||||
/** Maximum blur amount in pixels. Default: 20 */
|
||||
blurAmount?: number;
|
||||
/** Scale multiplier at peak. Default: 1.15 */
|
||||
scaleAmount?: number;
|
||||
/** Origin point for zoom. Default: 'center' */
|
||||
origin?: 'center' | 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
|
||||
const ZoomBlurPresentation: React.FC<
|
||||
TransitionPresentationComponentProps<ZoomBlurProps>
|
||||
> = ({ children, presentationDirection, presentationProgress, passedProps }) => {
|
||||
const {
|
||||
direction = 'in',
|
||||
blurAmount = 20,
|
||||
scaleAmount = 1.15,
|
||||
origin = 'center',
|
||||
} = passedProps;
|
||||
|
||||
const progress = presentationDirection === 'exiting'
|
||||
? 1 - presentationProgress
|
||||
: presentationProgress;
|
||||
|
||||
// Effect intensity peaks in the middle then settles
|
||||
const effectIntensity = useMemo(() => {
|
||||
return interpolate(progress, [0, 0.4, 1], [0, 1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
}, [progress]);
|
||||
|
||||
// Scale animation
|
||||
const scale = useMemo(() => {
|
||||
if (direction === 'in') {
|
||||
// Start small, zoom in
|
||||
return interpolate(
|
||||
progress,
|
||||
[0, 0.5, 1],
|
||||
[1 / scaleAmount, scaleAmount, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
} else {
|
||||
// Start big, zoom out
|
||||
return interpolate(
|
||||
progress,
|
||||
[0, 0.5, 1],
|
||||
[scaleAmount, 1 / scaleAmount, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
}
|
||||
}, [progress, direction, scaleAmount]);
|
||||
|
||||
// Blur tracks with scale movement
|
||||
const blur = blurAmount * effectIntensity;
|
||||
|
||||
// Opacity
|
||||
const opacity = presentationDirection === 'exiting'
|
||||
? interpolate(progress, [0, 0.5], [1, 0], { extrapolateRight: 'clamp' })
|
||||
: interpolate(progress, [0.5, 1], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
|
||||
// Transform origin based on setting
|
||||
const transformOrigin = useMemo(() => {
|
||||
switch (origin) {
|
||||
case 'top': return 'center top';
|
||||
case 'bottom': return 'center bottom';
|
||||
case 'left': return 'left center';
|
||||
case 'right': return 'right center';
|
||||
default: return 'center center';
|
||||
}
|
||||
}, [origin]);
|
||||
|
||||
const containerStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}), []);
|
||||
|
||||
const contentStyle: React.CSSProperties = useMemo(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin,
|
||||
filter: blur > 0.5 ? `blur(${blur}px)` : undefined,
|
||||
opacity,
|
||||
}), [scale, transformOrigin, blur, opacity]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={containerStyle}>
|
||||
<div style={contentStyle}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Radial light streak overlay for extra energy */}
|
||||
{effectIntensity > 0.3 && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: effectIntensity * 0.4,
|
||||
background: `radial-gradient(
|
||||
ellipse at ${origin === 'center' ? '50% 50%' : origin === 'top' ? '50% 0%' : origin === 'bottom' ? '50% 100%' : origin === 'left' ? '0% 50%' : '100% 50%'},
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 70%
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const zoomBlur = (
|
||||
props: ZoomBlurProps = {}
|
||||
): TransitionPresentation<ZoomBlurProps> => {
|
||||
return { component: ZoomBlurPresentation, props };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "../../lib/**/*"]
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"@remotion/paths": "^4.0.0",
|
||||
"@remotion/shapes": "^4.0.0",
|
||||
"@remotion/transitions": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"remotion": "^4.0.0"
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"@remotion/paths": "^4.0.0",
|
||||
"@remotion/shapes": "^4.0.0",
|
||||
"@remotion/transitions": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"remotion": "^4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user