import React from "react";
import {
AbsoluteFill,
Img,
Sequence,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
audioFileForSpeech,
hasAudioForSpeech,
zundamonReworkStandeeDemoScenario,
} from "./data/zundamon-rework-standee-demo/timing";
import {
characters,
type ExpressionEvent,
type MouthCycleEvent,
type SpeechEvent,
type TimelineEvent,
} from "./data/zundamon-rework-standee-demo/script";
import {roundedFontFamily} from "./fonts";
import {
activeVQChronologicalScenarioSegmentForFrame,
scheduleVQChronologicalScenario,
VQLipSyncedStandeeImage,
VQSpeechOverlay,
VQWarmGradientBackground,
type VQMouthShape,
} from "./lib/VQRemotionLib";
import {getMouthForSpeechFrame} from "./lipsync/manifest";
const zundamonAvatar = characters.zundamon.avatar;
const imageDropShadow = "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))";
const subtitleOptions = {
fontFamily: roundedFontFamily,
fontSize: 34,
lineHeight: 1.35,
backgroundColor: "rgba(255, 255, 255, 0.9)",
} as const;
// 用途: タイムラインイベントに共通して使う安定した React key を作る。
// 使用方法: scheduledEvents.map 内でイベントとインデックスを渡し、Sequence の key に使う。
// オプションや引数詳細: 現在のタイムラインイベントはすべて id を持つため、index は呼び出し側の形を保つために受け取る。
const keyForEvent = (event: TimelineEvent, index: number) =>
`${event.id}-${index}`;
// 用途: 明示口形確認イベントの現在フレームで表示する口形を決める。
// 使用方法: MouthCycleStandee から呼び、イベント尺を mouths の数で均等分割して使う。
// オプションや引数詳細: localFrame はイベント内フレーム、durationInFrames はイベント全体の尺。
const mouthForCycle = (
event: MouthCycleEvent,
localFrame: number,
durationInFrames: number
): VQMouthShape => {
const framesPerMouth = Math.max(
1,
Math.floor(durationInFrames / event.mouths.length)
);
const mouthIndex = Math.min(
event.mouths.length - 1,
Math.floor(localFrame / framesPerMouth)
);
return event.mouths[mouthIndex];
};
const titleStyle: React.CSSProperties = {
position: "absolute",
top: 34,
left: 0,
right: 0,
textAlign: "center",
fontFamily: roundedFontFamily,
fontSize: 42,
fontWeight: 800,
color: "#1f2a44",
};
const labelStyle: React.CSSProperties = {
position: "absolute",
top: 94,
left: 0,
right: 0,
textAlign: "center",
fontFamily: roundedFontFamily,
fontSize: 26,
fontWeight: 700,
color: "#2f6b40",
};
const standeeWrapStyle: React.CSSProperties = {
position: "absolute",
inset: "132px 0 116px",
display: "flex",
justifyContent: "center",
alignItems: "center",
};
const StandeeFrame: React.FC<Readonly<{children: React.ReactNode}>> = ({
children,
}) => <div style={standeeWrapStyle}>{children}</div>;
const ExpressionStandee: React.FC<Readonly<{event: ExpressionEvent}>> = ({
event,
}) => (
<StandeeFrame>
<Img
src={staticFile(event.imagePath)}
style={{
width: zundamonAvatar.imageLayout.width,
maxHeight: zundamonAvatar.imageLayout.maxHeight,
objectFit: "contain",
transform: "scaleX(-1)",
filter: imageDropShadow,
}}
/>
</StandeeFrame>
);
const LipSyncedDefaultStandee: React.FC<Readonly<{mouth: VQMouthShape}>> = ({
mouth,
}) => (
<StandeeFrame>
<VQLipSyncedStandeeImage
imagePath={zundamonAvatar.imagePath}
mouthImageDir={zundamonAvatar.mouthImageDir}
mouth={mouth}
width={zundamonAvatar.imageLayout.width ?? 540}
maxHeight={zundamonAvatar.imageLayout.maxHeight ?? 730}
transform="scaleX(-1)"
filter={imageDropShadow}
/>
</StandeeFrame>
);
const TimelineOverlay: React.FC<Readonly<{event: TimelineEvent}>> = ({
event,
}) => {
if (event.type !== "say") {
return null;
}
const character = characters[event.character];
return (
<VQSpeechOverlay
speech={event}
speakerName={character.displayName}
accentColor={character.avatar.accentColor}
hasAudio={hasAudioForSpeech}
getAudioPath={audioFileForSpeech}
subtitleOptions={subtitleOptions}
containerStyle={{zIndex: 3}}
/>
);
};
export const ZundamonReworkStandeeDemo: React.FC = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const scheduledEvents = scheduleVQChronologicalScenario(
zundamonReworkStandeeDemoScenario,
fps
);
const activeSegment = activeVQChronologicalScenarioSegmentForFrame(
scheduledEvents,
frame
);
const localFrame = activeSegment ? frame - activeSegment.from : 0;
const activeEvent = activeSegment?.event;
const activeMouth =
activeEvent?.type === "say"
? getMouthForSpeechFrame(
(activeEvent as SpeechEvent).id,
localFrame,
fps
)
: activeEvent?.type === "mouthCycle"
? mouthForCycle(
activeEvent,
localFrame,
activeSegment.durationInFrames
)
: "rest";
const activeLabel =
activeEvent?.type === "expression"
? `expression: ${activeEvent.label}`
: activeEvent?.type === "mouthCycle"
? `mouth: ${activeMouth}`
: "default rhubarb lip sync";
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>
<VQWarmGradientBackground />
<div style={titleStyle}>Zundamon Rework Standee Demo</div>
<div style={labelStyle}>{activeLabel}</div>
{activeEvent?.type === "expression" ? (
<ExpressionStandee event={activeEvent} />
) : (
<LipSyncedDefaultStandee mouth={activeMouth} />
)}
{sequences}
</AbsoluteFill>
);
};