mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
add project setup
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Composition } from 'remotion';
|
||||
import { CodemanDemo, TOTAL_FRAMES } from './compositions/CodemanDemo';
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="CodemanDemo"
|
||||
component={CodemanDemo}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
|
||||
type ClickCursorProps = {
|
||||
/** X position to move to */
|
||||
x: number;
|
||||
/** Y position to move to */
|
||||
y: number;
|
||||
/** Frame at which cursor starts moving (local frame) */
|
||||
moveStart: number;
|
||||
/** Frame at which click happens */
|
||||
clickAt: number;
|
||||
/** Starting X (defaults to center-right) */
|
||||
fromX?: number;
|
||||
/** Starting Y */
|
||||
fromY?: number;
|
||||
};
|
||||
|
||||
export const ClickCursor: React.FC<ClickCursorProps> = ({
|
||||
x,
|
||||
y,
|
||||
moveStart,
|
||||
clickAt,
|
||||
fromX = 960,
|
||||
fromY = 400,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Move animation (spring-based)
|
||||
const moveProgress = spring({
|
||||
frame: Math.max(0, frame - moveStart),
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
durationInFrames: Math.max(1, clickAt - moveStart),
|
||||
});
|
||||
|
||||
const cursorX = interpolate(moveProgress, [0, 1], [fromX, x]);
|
||||
const cursorY = interpolate(moveProgress, [0, 1], [fromY, y]);
|
||||
|
||||
// Click pulse
|
||||
const clickProgress =
|
||||
frame >= clickAt
|
||||
? interpolate(frame - clickAt, [0, 8], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: 0;
|
||||
const clickScale = interpolate(clickProgress, [0, 0.5, 1], [0, 1.2, 0], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const clickOpacity = interpolate(clickProgress, [0, 0.5, 1], [0, 0.6, 0], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// Hide cursor before it starts moving
|
||||
const cursorOpacity =
|
||||
frame < moveStart
|
||||
? 0
|
||||
: interpolate(frame - moveStart, [0, 5], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: cursorX,
|
||||
top: cursorY,
|
||||
opacity: cursorOpacity,
|
||||
zIndex: 100,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Click pulse ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid rgba(255,255,255,0.5)',
|
||||
transform: `translate(-50%, -50%) scale(${clickScale})`,
|
||||
opacity: clickOpacity,
|
||||
}}
|
||||
/>
|
||||
{/* Cursor arrow */}
|
||||
<svg
|
||||
width="20"
|
||||
height="24"
|
||||
viewBox="0 0 20 24"
|
||||
fill="none"
|
||||
style={{ filter: 'drop-shadow(1px 2px 3px rgba(0,0,0,0.5))' }}
|
||||
>
|
||||
<path
|
||||
d="M2 2L2 20L7 15L12 22L15 20L10 13L17 13L2 2Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { colors } from '../lib/theme';
|
||||
|
||||
type PhoneFrameProps = {
|
||||
children: React.ReactNode;
|
||||
/** Width of the phone viewport */
|
||||
width?: number;
|
||||
/** Height of the phone viewport */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export const PhoneFrame: React.FC<PhoneFrameProps> = ({
|
||||
children,
|
||||
width = 375,
|
||||
height = 812,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Scale-in animation
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 80 },
|
||||
});
|
||||
|
||||
const bezelW = width + 24;
|
||||
const bezelH = height + 24;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: bezelW,
|
||||
height: bezelH,
|
||||
borderRadius: 44,
|
||||
background: '#1a1a1a',
|
||||
border: '2px solid #333',
|
||||
padding: 12,
|
||||
position: 'relative',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.6)',
|
||||
}}
|
||||
>
|
||||
{/* Notch / Dynamic Island */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 120,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
background: '#000',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen content */}
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderRadius: 32,
|
||||
overflow: 'hidden',
|
||||
background: colors.bg.dark,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import { colors, fonts } from '../lib/theme';
|
||||
|
||||
type TitleCardProps = {
|
||||
text: string;
|
||||
subtitle: string;
|
||||
showUrl?: boolean;
|
||||
};
|
||||
|
||||
export const TitleCard: React.FC<TitleCardProps> = ({
|
||||
text,
|
||||
subtitle,
|
||||
showUrl = false,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Logo fade + scale in
|
||||
const logoOpacity = interpolate(frame, [0, 0.5 * fps], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const logoScale = interpolate(frame, [0, 0.5 * fps], [0.8, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// Subtitle fades in slightly after logo
|
||||
const subtitleOpacity = interpolate(
|
||||
frame,
|
||||
[0.3 * fps, 0.8 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
|
||||
);
|
||||
|
||||
// URL fades in last
|
||||
const urlOpacity = showUrl
|
||||
? interpolate(frame, [0.6 * fps, 1.1 * fps], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: colors.bg.dark,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Lightning bolt icon */}
|
||||
<div
|
||||
style={{
|
||||
opacity: logoOpacity,
|
||||
transform: `scale(${logoScale})`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#60a5fa" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="#0a0a0a" />
|
||||
<path d="M18 4L8 18h6l-2 10 10-14h-6z" fill="url(#g)" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.ui,
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
color: colors.accent.blue,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: subtitleOpacity,
|
||||
fontFamily: fonts.ui,
|
||||
fontSize: 28,
|
||||
color: colors.text.dim,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
|
||||
{showUrl && (
|
||||
<div
|
||||
style={{
|
||||
opacity: urlOpacity,
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 18,
|
||||
color: colors.text.muted,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
github.com/Ark0N/Codeman
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,485 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
interpolate,
|
||||
Sequence,
|
||||
spring,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import { colors } from '../lib/theme';
|
||||
import { TitleCard } from '../components/TitleCard';
|
||||
import { PhoneFrame } from '../components/PhoneFrame';
|
||||
import { ClickCursor } from '../components/ClickCursor';
|
||||
|
||||
// ─── Scene timing (frames @ 30fps) ───
|
||||
const TITLE_START = 0;
|
||||
const TITLE_DUR = 45;
|
||||
|
||||
const DESKTOP_START = 45;
|
||||
const WELCOME_DUR = 75; // 2.5s — show welcome screen
|
||||
const CLAUDE_DUR = 90; // 3s — single Claude tab
|
||||
const BOTH_OPENCODE_DUR = 90; // 3s — both tabs, OpenCode active
|
||||
const TAB_SWITCH_DUR = 120; // 4s — tab switching animation
|
||||
const DESKTOP_DUR = WELCOME_DUR + CLAUDE_DUR + BOTH_OPENCODE_DUR + TAB_SWITCH_DUR; // 375
|
||||
|
||||
const TRANSITION_START = DESKTOP_START + DESKTOP_DUR; // 420
|
||||
const TRANSITION_DUR = 60;
|
||||
|
||||
const MOBILE_START = TRANSITION_START + TRANSITION_DUR; // 480
|
||||
const MOBILE_DUR = 240;
|
||||
|
||||
const OUTRO_START = MOBILE_START + MOBILE_DUR; // 720
|
||||
const OUTRO_DUR = 75;
|
||||
|
||||
export const TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 795
|
||||
|
||||
// ─── Screenshot paths ───
|
||||
const SCREENSHOTS = {
|
||||
desktopWelcome: staticFile('desktop-welcome.png'),
|
||||
desktopClaude: staticFile('desktop-claude.png'),
|
||||
desktopBothClaude: staticFile('desktop-both-claude.png'),
|
||||
desktopBothOpencode: staticFile('desktop-both-opencode.png'),
|
||||
mobileClaude: staticFile('mobile-claude.png'),
|
||||
mobileOpencode: staticFile('mobile-opencode.png'),
|
||||
};
|
||||
|
||||
// ─── Desktop scene ───
|
||||
const DesktopDemo: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Key frames within the desktop scene (local frames)
|
||||
const claudeClickFrame = WELCOME_DUR - 15; // cursor clicks near end of welcome
|
||||
const claudeStart = WELCOME_DUR;
|
||||
|
||||
const bothStart = WELCOME_DUR + CLAUDE_DUR; // both tabs appear
|
||||
const switchBase = bothStart + BOTH_OPENCODE_DUR;
|
||||
const switch1Frame = switchBase + 30; // switch to Claude
|
||||
const switch2Frame = switchBase + 75; // switch back to OpenCode
|
||||
|
||||
// Determine which screenshot to show and cross-fade
|
||||
// Phase 1: Welcome (0 → WELCOME_DUR)
|
||||
// Phase 2: Claude single tab (WELCOME_DUR → bothStart)
|
||||
// Phase 3: Both tabs, OpenCode active (bothStart → switchBase)
|
||||
// Phase 4: Tab switching — OpenCode→Claude→OpenCode (switchBase → end)
|
||||
|
||||
// Cross-fade durations
|
||||
const FADE = 10;
|
||||
|
||||
// Welcome → Claude cross-fade
|
||||
const welcomeOpacity =
|
||||
frame < claudeStart - FADE
|
||||
? 1
|
||||
: frame < claudeStart
|
||||
? interpolate(frame, [claudeStart - FADE, claudeStart], [1, 0], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: 0;
|
||||
|
||||
const claudeSoloOpacity =
|
||||
frame < claudeStart
|
||||
? 0
|
||||
: frame < claudeStart + FADE
|
||||
? interpolate(frame, [claudeStart, claudeStart + FADE], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: frame < bothStart - FADE
|
||||
? 1
|
||||
: frame < bothStart
|
||||
? interpolate(frame, [bothStart - FADE, bothStart], [1, 0], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: 0;
|
||||
|
||||
// Both tabs — determine which one is active
|
||||
let bothOpenCodeOpacity = 0;
|
||||
let bothClaudeOpacity = 0;
|
||||
|
||||
if (frame >= bothStart) {
|
||||
// Fade in both-opencode initially
|
||||
if (frame < bothStart + FADE) {
|
||||
bothOpenCodeOpacity = interpolate(
|
||||
frame,
|
||||
[bothStart, bothStart + FADE],
|
||||
[0, 1],
|
||||
{ extrapolateRight: 'clamp' },
|
||||
);
|
||||
} else if (frame < switch1Frame) {
|
||||
// Showing OpenCode
|
||||
bothOpenCodeOpacity = 1;
|
||||
bothClaudeOpacity = 0;
|
||||
} else if (frame < switch1Frame + FADE) {
|
||||
// Cross-fade OpenCode → Claude
|
||||
const t = interpolate(
|
||||
frame,
|
||||
[switch1Frame, switch1Frame + FADE],
|
||||
[0, 1],
|
||||
{ extrapolateRight: 'clamp' },
|
||||
);
|
||||
bothOpenCodeOpacity = 1 - t;
|
||||
bothClaudeOpacity = t;
|
||||
} else if (frame < switch2Frame) {
|
||||
// Showing Claude
|
||||
bothOpenCodeOpacity = 0;
|
||||
bothClaudeOpacity = 1;
|
||||
} else if (frame < switch2Frame + FADE) {
|
||||
// Cross-fade Claude → OpenCode
|
||||
const t = interpolate(
|
||||
frame,
|
||||
[switch2Frame, switch2Frame + FADE],
|
||||
[0, 1],
|
||||
{ extrapolateRight: 'clamp' },
|
||||
);
|
||||
bothOpenCodeOpacity = t;
|
||||
bothClaudeOpacity = 1 - t;
|
||||
} else {
|
||||
// Back to OpenCode
|
||||
bothOpenCodeOpacity = 1;
|
||||
bothClaudeOpacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome button positions for cursor (measured from actual screenshot)
|
||||
const welcomeBtnClaudeX = 651;
|
||||
const welcomeBtnClaudeY = 434;
|
||||
const welcomeBtnOpenCodeX = 803;
|
||||
const welcomeBtnOpenCodeY = 434;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark }}>
|
||||
{/* Welcome screenshot */}
|
||||
{welcomeOpacity > 0 && (
|
||||
<AbsoluteFill style={{ opacity: welcomeOpacity }}>
|
||||
<Img
|
||||
src={SCREENSHOTS.desktopWelcome}
|
||||
style={{ width: 1920, height: 1080 }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Single Claude tab screenshot */}
|
||||
{claudeSoloOpacity > 0 && (
|
||||
<AbsoluteFill style={{ opacity: claudeSoloOpacity }}>
|
||||
<Img
|
||||
src={SCREENSHOTS.desktopClaude}
|
||||
style={{ width: 1920, height: 1080 }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Both tabs — OpenCode active */}
|
||||
{bothOpenCodeOpacity > 0 && (
|
||||
<AbsoluteFill style={{ opacity: bothOpenCodeOpacity }}>
|
||||
<Img
|
||||
src={SCREENSHOTS.desktopBothOpencode}
|
||||
style={{ width: 1920, height: 1080 }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Both tabs — Claude active */}
|
||||
{bothClaudeOpacity > 0 && (
|
||||
<AbsoluteFill style={{ opacity: bothClaudeOpacity }}>
|
||||
<Img
|
||||
src={SCREENSHOTS.desktopBothClaude}
|
||||
style={{ width: 1920, height: 1080 }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Cursor: click Claude Code button on welcome screen */}
|
||||
<Sequence
|
||||
from={claudeClickFrame - 20}
|
||||
durationInFrames={35}
|
||||
layout="none"
|
||||
>
|
||||
<ClickCursor
|
||||
fromX={960}
|
||||
fromY={300}
|
||||
x={welcomeBtnClaudeX}
|
||||
y={welcomeBtnClaudeY}
|
||||
moveStart={0}
|
||||
clickAt={20}
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
{/* Cursor: click to add OpenCode tab */}
|
||||
<Sequence
|
||||
from={bothStart - 20}
|
||||
durationInFrames={35}
|
||||
layout="none"
|
||||
>
|
||||
<ClickCursor
|
||||
fromX={welcomeBtnClaudeX}
|
||||
fromY={welcomeBtnClaudeY}
|
||||
x={welcomeBtnOpenCodeX}
|
||||
y={welcomeBtnOpenCodeY}
|
||||
moveStart={0}
|
||||
clickAt={20}
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
{/* Cursor: switch to Claude tab (first tab in header) */}
|
||||
<Sequence
|
||||
from={switch1Frame - 15}
|
||||
durationInFrames={20}
|
||||
layout="none"
|
||||
>
|
||||
<ClickCursor
|
||||
fromX={500}
|
||||
fromY={300}
|
||||
x={128}
|
||||
y={17}
|
||||
moveStart={0}
|
||||
clickAt={15}
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
{/* Cursor: switch back to OpenCode tab (second tab in header) */}
|
||||
<Sequence
|
||||
from={switch2Frame - 15}
|
||||
durationInFrames={20}
|
||||
layout="none"
|
||||
>
|
||||
<ClickCursor
|
||||
fromX={128}
|
||||
fromY={17}
|
||||
x={245}
|
||||
y={17}
|
||||
moveStart={0}
|
||||
clickAt={15}
|
||||
/>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Device transition ───
|
||||
const DeviceTransition: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Desktop shrinks down
|
||||
const shrinkProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
durationInFrames: 45,
|
||||
});
|
||||
|
||||
const desktopScale = interpolate(shrinkProgress, [0, 1], [1, 0.35]);
|
||||
const desktopOpacity = interpolate(shrinkProgress, [0, 1], [1, 0], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// Phone frame fades in
|
||||
const phoneOpacity = interpolate(frame, [20, 40], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const phoneScale = interpolate(frame, [20, 50], [0.8, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: colors.bg.dark,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Shrinking desktop screenshot */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
transform: `scale(${desktopScale})`,
|
||||
opacity: desktopOpacity,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={SCREENSHOTS.desktopBothClaude}
|
||||
style={{ width: 1920, height: 1080 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone frame appearing */}
|
||||
<div
|
||||
style={{
|
||||
opacity: phoneOpacity,
|
||||
transform: `scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame width={375} height={700}>
|
||||
<Img
|
||||
src={SCREENSHOTS.mobileClaude}
|
||||
style={{ width: 375, height: 700, objectFit: 'cover' }}
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Mobile scene ───
|
||||
const MobileDemo: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Mobile: Claude for 90 frames, then swipe to OpenCode
|
||||
const swipeFrame = 90;
|
||||
const swipeDuration = 20;
|
||||
|
||||
const isOpenCode = frame >= swipeFrame + swipeDuration;
|
||||
const isSwiping =
|
||||
frame >= swipeFrame && frame < swipeFrame + swipeDuration;
|
||||
|
||||
// Slide offset
|
||||
const slideProgress = isSwiping
|
||||
? interpolate(frame - swipeFrame, [0, swipeDuration], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
: isOpenCode
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const slideX = interpolate(slideProgress, [0, 1], [0, -375]);
|
||||
|
||||
return (
|
||||
<PhoneFrame width={375} height={700}>
|
||||
<div
|
||||
style={{
|
||||
width: 375,
|
||||
height: 700,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Sliding container with both screenshots */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 750,
|
||||
height: 700,
|
||||
transform: `translateX(${slideX}px)`,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={SCREENSHOTS.mobileClaude}
|
||||
style={{ width: 375, height: 700, flexShrink: 0, objectFit: 'cover' }}
|
||||
/>
|
||||
<Img
|
||||
src={SCREENSHOTS.mobileOpencode}
|
||||
style={{ width: 375, height: 700, flexShrink: 0, objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Swipe indicator */}
|
||||
{isSwiping && (
|
||||
<SwipeIndicator
|
||||
frame={frame - swipeFrame}
|
||||
duration={swipeDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
);
|
||||
};
|
||||
|
||||
// Swipe gesture indicator
|
||||
const SwipeIndicator: React.FC<{ frame: number; duration: number }> = ({
|
||||
frame,
|
||||
duration,
|
||||
}) => {
|
||||
const progress = interpolate(frame, [0, duration], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const x = interpolate(progress, [0, 1], [300, 75]);
|
||||
const opacity = interpolate(
|
||||
progress,
|
||||
[0, 0.2, 0.8, 1],
|
||||
[0, 0.7, 0.7, 0],
|
||||
{ extrapolateRight: 'clamp' },
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: x,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
opacity,
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main composition ───
|
||||
export const CodemanDemo: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark }}>
|
||||
{/* Scene 1: Title */}
|
||||
<Sequence durationInFrames={TITLE_DUR} premountFor={0}>
|
||||
<TitleCard text="Codeman" subtitle="AI Session Manager" />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 2-5: Desktop flow (cross-fading screenshots) */}
|
||||
<Sequence
|
||||
from={DESKTOP_START}
|
||||
durationInFrames={DESKTOP_DUR}
|
||||
premountFor={15}
|
||||
>
|
||||
<DesktopDemo />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 6: Desktop → Mobile transition */}
|
||||
<Sequence
|
||||
from={TRANSITION_START}
|
||||
durationInFrames={TRANSITION_DUR}
|
||||
premountFor={15}
|
||||
>
|
||||
<DeviceTransition />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 7-9: Mobile flow (swipe between Claude/OpenCode) */}
|
||||
<Sequence
|
||||
from={MOBILE_START}
|
||||
durationInFrames={MOBILE_DUR}
|
||||
premountFor={15}
|
||||
>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: colors.bg.dark,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<MobileDemo />
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 10: Outro */}
|
||||
<Sequence
|
||||
from={OUTRO_START}
|
||||
durationInFrames={OUTRO_DUR}
|
||||
premountFor={15}
|
||||
>
|
||||
<TitleCard
|
||||
text="Codeman"
|
||||
subtitle="Manage any AI coding tool"
|
||||
showUrl
|
||||
/>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Design tokens extracted from Codeman's styles.css and index.html
|
||||
|
||||
export const colors = {
|
||||
bg: {
|
||||
dark: '#0a0a0a',
|
||||
card: '#141414',
|
||||
input: '#1a1a1a',
|
||||
hover: '#222',
|
||||
terminal: '#0d0d0d',
|
||||
},
|
||||
border: {
|
||||
default: '#2a2a2a',
|
||||
light: '#333',
|
||||
},
|
||||
text: {
|
||||
primary: '#eee',
|
||||
dim: '#888',
|
||||
muted: '#555',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3b82f6',
|
||||
blueHover: '#60a5fa',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
red: '#ef4444',
|
||||
purple: '#a855f7',
|
||||
orange: '#f97316',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const layout = {
|
||||
headerHeight: 36,
|
||||
toolbarHeight: 40,
|
||||
tabHeight: 28,
|
||||
borderRadius: 4,
|
||||
} as const;
|
||||
|
||||
export const fonts = {
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
mono: "'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace",
|
||||
} as const;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Reference in New Issue
Block a user