BRAND: animated synthwave banner for README (showcase/banner project)

New showcase/banner Remotion project renders a 5s 1080x500 banner —
amber phosphor CRT aesthetic with a NARRATE ▸ SCORE ▸ GENERATE ▸
COMPOSE ▸ RENDER pipeline that ignites left-to-right, on a scrolling
perspective grid with scanlines and a power-on sweep.

Three alternates (outrun / dusk / midnight) stay registered in
src/Root.tsx for future re-rendering. Palette-optimized GIF (2.8MB,
960x444, 64 colors via ffmpeg) + MP4 + poster PNG land in
assets/banner/ and are referenced from the README header.

.gitignore updated to allow committing the banner assets despite the
project-wide *.mp4 / *.gif exclusion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Conal Mullan
2026-04-14 10:54:46 +01:00
parent e017616066
commit 0043b9722f
18 changed files with 3629 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# banner-showcase
The animated banner shown at the top of the main `README.md`. Source for the file at `assets/banner/toolkit-banner.{gif,mp4,poster.png}`.
## Theme
The live banner uses the `amber` theme — mono phosphor CRT aesthetic on a dark grid. Three alternates (`outrun`, `dusk`, `midnight`) are kept registered in `src/Root.tsx` so we can revisit or re-render if the branding changes.
## Re-rendering
```bash
cd showcase/banner
npm install
npm run studio # Preview / scrub in Remotion Studio
npm run render # MP4 → out/toolkit-banner.mp4
npm run render:gif # GIF → out/toolkit-banner.gif
npm run render:poster # Static PNG poster frame
```
After rendering, copy the outputs over the top of `assets/banner/`:
```bash
cp out/toolkit-banner.{mp4,gif} out/toolkit-banner-poster.png ../../assets/banner/
```
### Shrinking the GIF
Remotion's native GIF is honest-quality but large. The published `assets/banner/toolkit-banner.gif` is a palette-optimized pass via ffmpeg:
```bash
ffmpeg -y -i out/toolkit-banner.mp4 \
-vf "fps=20,scale=960:444:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5" \
out/toolkit-banner.gif
```
64 colors is plenty for the amber theme. For the full-saturation variants (`outrun`, `dusk`) bump `max_colors=128` or accept a larger file.
## Structure
- `src/themes.ts` — all four color palettes in one file; swap `themes.amber` in `Root.tsx` to change the primary
- `src/SynthwaveBackground.tsx` — base color, optional banded sun, perspective grid
- `src/Wordmark.tsx` — "CLAUDE CODE VIDEO TOOLKIT" drop-in with glow + optional chromatic aberration
- `src/Pipeline.tsx``NARRATE ▸ SCORE ▸ GENERATE ▸ COMPOSE ▸ RENDER` sequential ignite
- `src/CRTOverlay.tsx` — scanlines, power-on sweep, vignette
+2926
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "banner-showcase",
"version": "1.0.0",
"description": "Animated synthwave banner for the claude-code-video-toolkit",
"scripts": {
"studio": "remotion studio",
"render": "remotion render ToolkitBanner out/toolkit-banner.mp4",
"render:gif": "remotion render ToolkitBanner out/toolkit-banner.gif --codec=gif",
"render:poster": "remotion still ToolkitBanner out/toolkit-banner-poster.png --frame=120"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/google-fonts": "^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"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { Config } from '@remotion/cli/config';
Config.setEntryPoint('./src/index.ts');
+70
View File
@@ -0,0 +1,70 @@
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
import type { Theme } from './themes';
export const CRTOverlay: React.FC<{ theme: Theme }> = ({ theme }) => {
const frame = useCurrentFrame();
const sweepY = interpolate(frame, [0, 9], [-20, 120], {
extrapolateRight: 'clamp',
});
const sweepOpacity = interpolate(frame, [0, 4, 9], [0, 1, 0], {
extrapolateRight: 'clamp',
});
const powerOn = interpolate(frame, [0, 9], [0.1, 1], {
extrapolateRight: 'clamp',
extrapolateLeft: 'clamp',
});
const flicker = frame % 90 === 0 || frame % 137 === 0 ? 0.85 : 1;
const scanAlpha = Math.min(0.08 * theme.crt.scanlineOpacity, 0.12);
return (
<>
<AbsoluteFill style={{ background: '#000', opacity: 1 - powerOn, pointerEvents: 'none' }} />
<AbsoluteFill
style={{
backgroundImage: `repeating-linear-gradient(to bottom, rgba(255,255,255,${scanAlpha}) 0px, rgba(255,255,255,${scanAlpha}) 1px, transparent 1px, transparent 3px)`,
mixBlendMode: 'overlay',
opacity: flicker,
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: `${sweepY}%`,
height: 4,
background: `linear-gradient(to bottom, transparent, ${theme.crt.sweepColor}, transparent)`,
filter: 'blur(2px)',
opacity: sweepOpacity,
pointerEvents: 'none',
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,${theme.crt.vignetteStrength}) 100%)`,
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
background: 'linear-gradient(to bottom, rgba(255,255,255,0.05), transparent)',
pointerEvents: 'none',
}}
/>
</>
);
};
+117
View File
@@ -0,0 +1,117 @@
import { Fragment } from 'react';
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
import { loadFont } from '@remotion/google-fonts/VT323';
import type { Theme } from './themes';
const { fontFamily } = loadFont();
const STAGES = ['NARRATE', 'SCORE', 'GENERATE', 'COMPOSE', 'RENDER'];
const STAGE_START = 55;
const STAGE_STRIDE = 12;
export const Pipeline: React.FC<{ theme: Theme }> = ({ theme }) => {
const frame = useCurrentFrame();
return (
<AbsoluteFill
style={{
alignItems: 'center',
justifyContent: 'flex-end',
paddingBottom: 74,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 18,
fontFamily,
fontSize: 42,
letterSpacing: 3,
}}
>
{STAGES.map((stage, i) => {
const ignite = STAGE_START + i * STAGE_STRIDE;
const arrowIgnite = ignite + 6;
return (
<Fragment key={stage}>
<Stage label={stage} igniteFrame={ignite} frame={frame} theme={theme} />
{i < STAGES.length - 1 && (
<Arrow igniteFrame={arrowIgnite} frame={frame} theme={theme} />
)}
</Fragment>
);
})}
</div>
</AbsoluteFill>
);
};
const Stage: React.FC<{
label: string;
igniteFrame: number;
frame: number;
theme: Theme;
}> = ({ label, igniteFrame, frame, theme }) => {
const brightness = interpolate(
frame,
[igniteFrame - 2, igniteFrame, igniteFrame + 4, igniteFrame + 14],
[0, 1.35, 1, 0.88],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const hueMix = interpolate(frame, [igniteFrame, igniteFrame + 14], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const color = lerpHex(theme.pipeline.ignite, theme.pipeline.settle, hueMix);
return (
<span
style={{
color,
opacity: brightness,
textShadow: `0 0 8px ${color}, 0 0 20px ${color}bb, 0 0 36px ${color}55`,
}}
>
{label}
</span>
);
};
const Arrow: React.FC<{
igniteFrame: number;
frame: number;
theme: Theme;
}> = ({ igniteFrame, frame, theme }) => {
const opacity = interpolate(
frame,
[igniteFrame - 2, igniteFrame, igniteFrame + 4],
[0, 1.2, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
return (
<span
style={{
color: theme.pipeline.arrow,
opacity,
textShadow: `0 0 10px ${theme.pipeline.arrowShadow}, 0 0 22px ${theme.pipeline.arrowShadow}`,
}}
>
</span>
);
};
function lerpHex(a: string, b: string, t: number): string {
const parse = (h: string) => [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
const [ar, ag, ab] = parse(a);
const [br, bg, bb] = parse(b);
const mix = (x: number, y: number) => Math.round(x + (y - x) * t);
const hex = (n: number) => n.toString(16).padStart(2, '0');
return `#${hex(mix(ar, br))}${hex(mix(ag, bg))}${hex(mix(ab, bb))}`;
}
+29
View File
@@ -0,0 +1,29 @@
import { Composition } from 'remotion';
import { ToolkitBanner } from './ToolkitBanner';
import { themes } from './themes';
const shared = {
component: ToolkitBanner,
durationInFrames: 150,
fps: 30,
width: 1080,
height: 500,
};
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Primary — the banner rendered for the toolkit README */}
<Composition
id="ToolkitBanner"
{...shared}
defaultProps={{ theme: themes.amber }}
/>
{/* Alternates — kept registered so we can revisit or re-render if branding changes */}
<Composition id="Banner-Outrun" {...shared} defaultProps={{ theme: themes.outrun }} />
<Composition id="Banner-Dusk" {...shared} defaultProps={{ theme: themes.dusk }} />
<Composition id="Banner-Midnight" {...shared} defaultProps={{ theme: themes.midnight }} />
</>
);
};
+109
View File
@@ -0,0 +1,109 @@
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
import type { Theme } from './themes';
export const SynthwaveBackground: React.FC<{ theme: Theme }> = ({ theme }) => {
const frame = useCurrentFrame();
const fadeIn = interpolate(frame, [8, 25], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const gridScroll = (frame * 1.2) % 60;
return (
<AbsoluteFill style={{ background: theme.background, overflow: 'hidden' }}>
{theme.sun.enabled && (
<>
{/* Sun horizon glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '42%',
width: 620,
height: 620,
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
background: `radial-gradient(circle at 50% 50%, ${theme.sun.inner} 0%, ${theme.sun.mid} 28%, ${theme.sun.outer} 60%, ${theme.background} 82%)`,
filter: 'blur(2px)',
opacity: fadeIn * 0.85,
}}
/>
{/* Sun bands — synthwave stripes cutting the sun */}
<div
style={{
position: 'absolute',
left: '50%',
top: '42%',
width: 620,
height: 620,
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
overflow: 'hidden',
opacity: fadeIn * 0.9,
WebkitMaskImage:
'radial-gradient(circle at 50% 50%, black 0%, black 48%, transparent 52%)',
maskImage:
'radial-gradient(circle at 50% 50%, black 0%, black 48%, transparent 52%)',
}}
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
position: 'absolute',
left: 0,
right: 0,
top: `${62 + i * 7}%`,
height: `${3 + i * 1.2}%`,
background: theme.sun.bandColor,
}}
/>
))}
</div>
</>
)}
{/* Perspective grid */}
<div
style={{
position: 'absolute',
bottom: 0,
left: '-10%',
right: '-10%',
height: '52%',
perspective: '340px',
perspectiveOrigin: '50% 0%',
opacity: fadeIn,
}}
>
<div
style={{
position: 'absolute',
inset: 0,
transform: 'rotateX(58deg)',
transformOrigin: '50% 0%',
backgroundImage: `
linear-gradient(to right, ${theme.grid.vertical} ${theme.grid.thickness}px, transparent ${theme.grid.thickness}px),
linear-gradient(to bottom, ${theme.grid.horizontal} ${theme.grid.thickness}px, transparent ${theme.grid.thickness}px)
`,
backgroundSize: '60px 60px',
backgroundPosition: `0 ${gridScroll}px, 0 ${gridScroll}px`,
}}
/>
{/* Horizon haze */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '18%',
background: `linear-gradient(to bottom, ${theme.background} 0%, ${theme.grid.vertical} 50%, transparent 100%)`,
}}
/>
</div>
</AbsoluteFill>
);
};
+17
View File
@@ -0,0 +1,17 @@
import { AbsoluteFill } from 'remotion';
import { SynthwaveBackground } from './SynthwaveBackground';
import { Wordmark } from './Wordmark';
import { Pipeline } from './Pipeline';
import { CRTOverlay } from './CRTOverlay';
import type { Theme } from './themes';
export const ToolkitBanner: React.FC<{ theme: Theme }> = ({ theme }) => {
return (
<AbsoluteFill>
<SynthwaveBackground theme={theme} />
<Wordmark theme={theme} />
<Pipeline theme={theme} />
<CRTOverlay theme={theme} />
</AbsoluteFill>
);
};
+90
View File
@@ -0,0 +1,90 @@
import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from 'remotion';
import { loadFont } from '@remotion/google-fonts/Audiowide';
import type { Theme } from './themes';
const { fontFamily } = loadFont();
export const Wordmark: React.FC<{ theme: Theme }> = ({ theme }) => {
const frame = useCurrentFrame();
const drop = interpolate(frame, [20, 48], [-30, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.16, 1, 0.3, 1),
});
const fade = interpolate(frame, [20, 48], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const flickerA = frame > 48 && frame < 54 ? (frame % 2 === 0 ? 0.4 : 1) : 1;
const flickerB = frame > 78 && frame < 82 ? (frame % 2 === 0 ? 0.7 : 1) : 1;
const flicker = flickerA * flickerB;
const baseStyle: React.CSSProperties = {
fontFamily,
fontSize: 52,
fontWeight: 400,
letterSpacing: 4,
whiteSpace: 'nowrap',
};
return (
<AbsoluteFill
style={{
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 96,
}}
>
<div
style={{
position: 'relative',
transform: `translateY(${drop}px)`,
opacity: fade * flicker,
}}
>
{theme.wordmark.aberration.enabled && (
<>
<div
style={{
...baseStyle,
position: 'absolute',
inset: 0,
transform: `translate(-${theme.wordmark.aberration.offset}px, 0)`,
color: theme.wordmark.aberration.left,
opacity: 0.65,
}}
aria-hidden
>
CLAUDE CODE VIDEO TOOLKIT
</div>
<div
style={{
...baseStyle,
position: 'absolute',
inset: 0,
transform: `translate(${theme.wordmark.aberration.offset}px, 0)`,
color: theme.wordmark.aberration.right,
opacity: 0.65,
}}
aria-hidden
>
CLAUDE CODE VIDEO TOOLKIT
</div>
</>
)}
<div
style={{
...baseStyle,
color: theme.wordmark.color,
textShadow: theme.wordmark.glow.join(', '),
}}
>
CLAUDE CODE VIDEO TOOLKIT
</div>
</div>
</AbsoluteFill>
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
+174
View File
@@ -0,0 +1,174 @@
export type Theme = {
name: string;
background: string;
sun: {
enabled: boolean;
inner: string;
mid: string;
outer: string;
bandColor: string;
};
grid: {
vertical: string;
horizontal: string;
thickness: number;
};
wordmark: {
color: string;
glow: string[];
aberration: {
enabled: boolean;
offset: number;
left: string;
right: string;
};
};
pipeline: {
ignite: string;
settle: string;
arrow: string;
arrowShadow: string;
};
crt: {
scanlineOpacity: number;
vignetteStrength: number;
sweepColor: string;
};
};
// V1 — Outrun: classic synthwave, dialed back from the opening prototype
export const outrun: Theme = {
name: 'outrun',
background: '#120823',
sun: {
enabled: true,
inner: '#ffd2e8',
mid: '#d53b8a',
outer: '#5b1982',
bandColor: '#120823',
},
grid: {
vertical: 'rgba(213, 59, 138, 0.55)',
horizontal: 'rgba(74, 160, 204, 0.35)',
thickness: 1.5,
},
wordmark: {
color: '#fff8ff',
glow: ['0 0 3px #fff', '0 0 10px #ff9fc8', '0 0 24px #d53b8a'],
aberration: {
enabled: true,
offset: 2,
left: '#7adef5',
right: '#ff9fc8',
},
},
pipeline: {
ignite: '#7adef5',
settle: '#ff9fc8',
arrow: '#fff8ff',
arrowShadow: '#d53b8a',
},
crt: {
scanlineOpacity: 0.9,
vignetteStrength: 0.55,
sweepColor: 'rgba(122, 222, 245, 0.75)',
},
};
// V2 — Dusk: muted sunset, one main accent, no chromatic aberration
export const dusk: Theme = {
name: 'dusk',
background: '#1a1530',
sun: {
enabled: true,
inner: '#ffcfbd',
mid: '#c47892',
outer: '#2a2a4a',
bandColor: '#1a1530',
},
grid: {
vertical: 'rgba(196, 120, 146, 0.28)',
horizontal: 'rgba(147, 162, 220, 0.22)',
thickness: 1,
},
wordmark: {
color: '#f8e8ff',
glow: ['0 0 2px #fff', '0 0 14px #d4b3ff'],
aberration: { enabled: false, offset: 0, left: '', right: '' },
},
pipeline: {
ignite: '#f8e8ff',
settle: '#d4b3ff',
arrow: '#a192d0',
arrowShadow: 'rgba(196, 120, 146, 0.6)',
},
crt: {
scanlineOpacity: 0.7,
vignetteStrength: 0.5,
sweepColor: 'rgba(212, 179, 255, 0.6)',
},
};
// V3 — Amber: mono phosphor CRT terminal, no sun, no synthwave
export const amber: Theme = {
name: 'amber',
background: '#0a0603',
sun: {
enabled: false,
inner: '', mid: '', outer: '', bandColor: '',
},
grid: {
vertical: 'rgba(255, 165, 82, 0.22)',
horizontal: 'rgba(255, 165, 82, 0.18)',
thickness: 1,
},
wordmark: {
color: '#ffa552',
glow: ['0 0 2px #ffd8a8', '0 0 10px #ffa552', '0 0 26px #ff7a1a'],
aberration: { enabled: false, offset: 0, left: '', right: '' },
},
pipeline: {
ignite: '#ffd8a8',
settle: '#ffa552',
arrow: '#ffa552',
arrowShadow: 'rgba(255, 122, 26, 0.7)',
},
crt: {
scanlineOpacity: 1.4,
vignetteStrength: 0.7,
sweepColor: 'rgba(255, 216, 168, 0.8)',
},
};
// V4 — Midnight: minimal, no sun, cool cyan only
export const midnight: Theme = {
name: 'midnight',
background: '#0a0e1f',
sun: {
enabled: false,
inner: '', mid: '', outer: '', bandColor: '',
},
grid: {
vertical: 'rgba(110, 231, 255, 0.3)',
horizontal: 'rgba(110, 231, 255, 0.22)',
thickness: 1,
},
wordmark: {
color: '#fff',
glow: ['0 0 2px #fff', '0 0 12px #6ee7ff', '0 0 26px #3a9dbb'],
aberration: { enabled: false, offset: 0, left: '', right: '' },
},
pipeline: {
ignite: '#e0fbff',
settle: '#6ee7ff',
arrow: '#6ee7ff',
arrowShadow: 'rgba(110, 231, 255, 0.7)',
},
crt: {
scanlineOpacity: 0.6,
vignetteStrength: 0.45,
sweepColor: 'rgba(110, 231, 255, 0.7)',
},
};
export const themes = { outrun, dusk, amber, midnight };
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["dom", "dom.iterable", "esnext"]
},
"include": ["src"]
}