import React from "react";
import {Audio} from "@remotion/media";
import {
AbsoluteFill,
Img,
interpolate,
Sequence,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
characters,
initialVisibleCharacters,
timeline,
type AvatarDefinition,
type CharacterId,
type TimelineEvent,
} from "./data/script";
import {
audioFileForSpeech,
GAP_FRAMES,
durationForTimelineEvent,
hasAudioForSpeech,
} from "./data/timing";
type ScheduledTimelineEvent = Readonly<{
event: TimelineEvent;
from: number;
durationInFrames: number;
visibleCharacters: CharacterId[];
focusedCharacter: CharacterId;
}>;
const scheduleTimeline = (fps: number): ScheduledTimelineEvent[] => {
let cursor = 0;
const visibleCharacters = new Set<CharacterId>(initialVisibleCharacters);
return timeline.map((event, index) => {
visibleCharacters.add(event.character);
const durationInFrames = durationForTimelineEvent(event, fps);
const scheduledEvent = {
event,
from: cursor,
durationInFrames,
visibleCharacters: Array.from(visibleCharacters),
focusedCharacter: event.character,
};
cursor += durationInFrames;
if (index < timeline.length - 1) {
cursor += GAP_FRAMES;
}
return scheduledEvent;
});
};
const activeSegmentForFrame = (
scheduledEvents: ScheduledTimelineEvent[],
frame: number
) => {
let activeSegment = scheduledEvents[0];
for (const scheduledEvent of scheduledEvents) {
if (frame >= scheduledEvent.from) {
activeSegment = scheduledEvent;
} else {
break;
}
}
return activeSegment;
};
const Title: React.FC<Readonly<{progress: number}>> = ({progress}) => {
const opacity = interpolate(progress, [0, 1], [0, 1]);
const translateY = interpolate(progress, [0, 1], [-30, 0]);
return (
<div
style={{
fontFamily:
'"IPAexGothic", "IPAPGothic", "M PLUS Rounded 1c", "Hiragino Maru Gothic ProN", sans-serif',
fontSize: 54,
fontWeight: 700,
color: "#1f2a44",
letterSpacing: 1,
textAlign: "center",
marginTop: 40,
opacity,
transform: `translateY(${translateY}px)`,
textShadow: "0 6px 18px rgba(31, 42, 68, 0.2)",
}}
>
ネコミミはなぜかわいい?
</div>
);
};
const Subtitle: React.FC<
Readonly<{
text: string;
progress: number;
speakerName?: string;
accentColor?: string;
}>
> = ({text, progress, speakerName, accentColor = "#1f2a44"}) => {
const opacity = interpolate(progress, [0, 1], [0, 1]);
const translateY = interpolate(progress, [0, 1], [16, 0]);
return (
<div
style={{
fontFamily:
'"IPAexGothic", "IPAPGothic", "M PLUS Rounded 1c", "Hiragino Maru Gothic ProN", sans-serif',
fontSize: 36,
fontWeight: 700,
color: "#1a1a1a",
lineHeight: 1.4,
padding: "18px 28px",
backgroundColor: "rgba(255, 255, 255, 0.88)",
borderRadius: 18,
border: `2px solid ${accentColor}33`,
boxShadow: "0 10px 30px rgba(31, 42, 68, 0.15)",
maxWidth: 980,
opacity,
transform: `translateY(${translateY}px)`,
}}
>
{speakerName ? (
<div
style={{
display: "inline-block",
fontSize: 20,
color: "#ffffff",
backgroundColor: accentColor,
borderRadius: 999,
padding: "4px 14px",
marginBottom: 8,
}}
>
{speakerName}
</div>
) : null}
<div>{text}</div>
</div>
);
};
const ZundamonAvatar: React.FC = () => {
return (
<div
style={{
width: 320,
height: 320,
borderRadius: "50%",
background:
"radial-gradient(circle at 30% 30%, #c9f6a5, #79d36f 70%)",
border: "6px solid rgba(31, 42, 68, 0.1)",
boxShadow: "0 18px 40px rgba(31, 42, 68, 0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: -60,
left: 40,
width: 90,
height: 90,
background: "#79d36f",
borderRadius: "10% 60% 20% 60%",
transform: "rotate(-18deg)",
border: "6px solid rgba(31, 42, 68, 0.1)",
}}
/>
<div
style={{
position: "absolute",
top: -60,
right: 40,
width: 90,
height: 90,
background: "#79d36f",
borderRadius: "60% 10% 60% 20%",
transform: "rotate(18deg)",
border: "6px solid rgba(31, 42, 68, 0.1)",
}}
/>
<div
style={{
display: "flex",
gap: 36,
}}
>
<div
style={{
width: 36,
height: 46,
borderRadius: "50%",
background: "#1f2a44",
}}
/>
<div
style={{
width: 36,
height: 46,
borderRadius: "50%",
background: "#1f2a44",
}}
/>
</div>
<div
style={{
position: "absolute",
bottom: 68,
width: 90,
height: 38,
borderRadius: "0 0 80px 80px",
borderBottom: "8px solid #1f2a44",
}}
/>
</div>
);
};
const SayoAvatar: React.FC<Readonly<{accentColor: string}>> = ({
accentColor,
}) => {
return (
<div
style={{
width: 320,
height: 340,
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: 58,
top: 10,
width: 0,
height: 0,
borderLeft: "42px solid transparent",
borderRight: "34px solid transparent",
borderBottom: `112px solid ${accentColor}`,
transform: "rotate(-22deg)",
filter: "drop-shadow(0 8px 14px rgba(31, 42, 68, 0.18))",
}}
/>
<div
style={{
position: "absolute",
right: 58,
top: 10,
width: 0,
height: 0,
borderLeft: "34px solid transparent",
borderRight: "42px solid transparent",
borderBottom: `112px solid ${accentColor}`,
transform: "rotate(22deg)",
filter: "drop-shadow(0 8px 14px rgba(31, 42, 68, 0.18))",
}}
/>
<div
style={{
position: "absolute",
left: 78,
top: 42,
width: 0,
height: 0,
borderLeft: "24px solid transparent",
borderRight: "20px solid transparent",
borderBottom: "66px solid #f0bfd4",
transform: "rotate(-22deg)",
}}
/>
<div
style={{
position: "absolute",
right: 78,
top: 42,
width: 0,
height: 0,
borderLeft: "20px solid transparent",
borderRight: "24px solid transparent",
borderBottom: "66px solid #f0bfd4",
transform: "rotate(22deg)",
}}
/>
<div
style={{
position: "absolute",
left: 54,
top: 72,
width: 212,
height: 218,
borderRadius: "46% 46% 48% 48%",
background: "#2f2f42",
boxShadow: "0 18px 40px rgba(31, 42, 68, 0.22)",
}}
/>
<div
style={{
position: "absolute",
left: 80,
top: 100,
width: 160,
height: 160,
borderRadius: "48%",
background: "radial-gradient(circle at 36% 28%, #fff4ee, #f5c6b9)",
border: "6px solid rgba(31, 42, 68, 0.1)",
}}
/>
<div
style={{
position: "absolute",
left: 108,
top: 150,
display: "flex",
gap: 44,
}}
>
<div
style={{
width: 24,
height: 34,
borderRadius: "50%",
background: "#222236",
}}
/>
<div
style={{
width: 24,
height: 34,
borderRadius: "50%",
background: "#222236",
}}
/>
</div>
<div
style={{
position: "absolute",
left: 143,
top: 194,
width: 34,
height: 18,
borderRadius: "0 0 36px 36px",
borderBottom: "5px solid #222236",
}}
/>
<div
style={{
position: "absolute",
left: 96,
top: 254,
width: 128,
height: 74,
borderRadius: "46px 46px 20px 20px",
background: `linear-gradient(160deg, ${accentColor}, #4b4560)`,
border: "6px solid rgba(31, 42, 68, 0.1)",
}}
/>
</div>
);
};
const CharacterAvatar: React.FC<
Readonly<{
characterId: CharacterId;
focused: boolean;
hasMultipleCharacters: boolean;
bounce: number;
}>
> = ({characterId, focused, hasMultipleCharacters, bounce}) => {
const character = characters[characterId];
const {avatar}: {avatar: AvatarDefinition} = character;
const scale = focused ? 1.05 : hasMultipleCharacters ? 0.88 : 1;
const opacity = focused || !hasMultipleCharacters ? 1 : 0.72;
const translateY = focused ? bounce : bounce * 0.35;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
opacity,
transform: `translateY(${translateY}px) scale(${scale})`,
}}
>
{avatar.imagePath ? (
<Img
src={staticFile(avatar.imagePath)}
style={{
width: 320,
maxHeight: 360,
objectFit: "contain",
filter: "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))",
}}
/>
) : avatar.kind === "sayo" ? (
<SayoAvatar accentColor={avatar.accentColor} />
) : (
<ZundamonAvatar />
)}
<div
style={{
fontFamily:
'"IPAexGothic", "IPAPGothic", "M PLUS Rounded 1c", "Hiragino Maru Gothic ProN", sans-serif',
fontSize: 24,
fontWeight: 700,
color: "#ffffff",
backgroundColor: avatar.accentColor,
padding: "6px 18px",
borderRadius: 999,
boxShadow: "0 8px 20px rgba(31, 42, 68, 0.14)",
}}
>
{character.displayName}
</div>
</div>
);
};
const Stage: React.FC<
Readonly<{
visibleCharacters: CharacterId[];
focusedCharacter?: CharacterId;
bounce: number;
}>
> = ({visibleCharacters, focusedCharacter, bounce}) => {
const hasMultipleCharacters = visibleCharacters.length > 1;
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: hasMultipleCharacters ? 130 : 0,
width: "100%",
paddingBottom: 40,
}}
>
{visibleCharacters.map((characterId) => (
<CharacterAvatar
key={characterId}
characterId={characterId}
focused={focusedCharacter === characterId}
hasMultipleCharacters={hasMultipleCharacters}
bounce={bounce}
/>
))}
</div>
);
};
const TimelineOverlay: React.FC<Readonly<{event: TimelineEvent}>> = ({event}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const subtitleProgress = spring({
frame,
fps,
config: {damping: 20, mass: 0.7},
});
const text = event.type === "say" ? event.subtitle ?? event.text : event.caption;
if (!text) {
return event.type === "say" && hasAudioForSpeech(event) ? (
<Audio src={staticFile(audioFileForSpeech(event))} />
) : null;
}
const speech = event.type === "say" ? event : undefined;
const character = speech ? characters[speech.character] : undefined;
return (
<>
<div
style={{
position: "absolute",
bottom: 40,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
}}
>
<Subtitle
text={text}
progress={subtitleProgress}
speakerName={character?.displayName}
accentColor={character?.avatar.accentColor}
/>
</div>
{speech && hasAudioForSpeech(speech) ? (
<Audio src={staticFile(audioFileForSpeech(speech))} />
) : null}
</>
);
};
const keyForEvent = (event: TimelineEvent, index: number) => {
if (event.type === "say") {
return event.id;
}
return `show-${event.character}-${index}`;
};
export const YukkuriComposition: React.FC = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const scheduledEvents = scheduleTimeline(fps);
const activeSegment = activeSegmentForFrame(scheduledEvents, frame);
const isInsideActiveSegment =
frame < activeSegment.from + activeSegment.durationInFrames;
const titleProgress = spring({
frame,
fps,
config: {damping: 18, mass: 0.6},
});
const bounce = interpolate(
Math.sin((frame / fps) * Math.PI * 2),
[-1, 1],
[-10, 10]
);
const sequences = scheduledEvents.map((scheduledEvent, index) => (
<Sequence
key={keyForEvent(scheduledEvent.event, index)}
from={scheduledEvent.from}
durationInFrames={scheduledEvent.durationInFrames}
premountFor={Math.min(fps, scheduledEvent.from)}
>
<TimelineOverlay event={scheduledEvent.event} />
</Sequence>
));
return (
<AbsoluteFill
style={{
background:
"radial-gradient(circle at top, #ffe8c7 0%, #ffd3b4 45%, #ffb6b6 100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.6) 0, rgba(255,255,255,0) 40%), radial-gradient(circle at 80% 30%, rgba(255,255,255,0.5) 0, rgba(255,255,255,0) 45%), radial-gradient(circle at 30% 80%, rgba(255,255,255,0.4) 0, rgba(255,255,255,0) 50%)",
opacity: 0.8,
}}
/>
<Title progress={titleProgress} />
<Stage
visibleCharacters={activeSegment.visibleCharacters}
focusedCharacter={
isInsideActiveSegment ? activeSegment.focusedCharacter : undefined
}
bounce={bounce}
/>
{sequences}
</AbsoluteFill>
);
};