storytellurx/scripts/product-demo/composition.tsx
2026-10-06 12:18:03 +02:00

183 lines
4.5 KiB
TypeScript

import {
AbsoluteFill,
Easing,
interpolate,
useCurrentFrame,
useVideoConfig,
Composition,
OffthreadVideo,
Series,
delayRender,
continueRender,
cancelRender,
registerRoot,
staticFile,
} from "remotion";
import { useEffect, useState, type CSSProperties } from "react";
import { phaseCaption } from "./phase-captions";
import {
phoneViewport,
demoFrame,
phoneHeight,
phoneWidth,
phoneLeft,
demoFps,
} from "./dimensions";
export type DemoClip = {
id: string;
caption: string;
startFrame: number;
frames: number;
};
export type DemoProps = {
source: string;
background: string;
captionColor: string;
clips: DemoClip[];
opening?: DemoClip;
plain: boolean;
fps: number;
};
const defaults: DemoProps = {
source: "viewport.mp4",
background: "#000000",
captionColor: "#ffffff",
clips: [],
plain: false,
fps: demoFps,
};
function DemoMoment({
source,
captionColor,
clip,
plain,
}: Pick<DemoProps, "source" | "captionColor" | "plain"> & { clip: DemoClip }) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const isOpening = clip.id === "opening";
const left = isOpening
? interpolate(
frame,
[0.55 * fps, 1.45 * fps],
[(demoFrame.width - phoneWidth) / 2, phoneLeft],
{
easing: Easing.inOut((value) => Easing.cubic(value)),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
)
: phoneLeft;
const caption: CSSProperties = {
position: "absolute",
left: 48,
top: 0,
height: "100%",
display: "flex",
alignItems: "center",
fontFamily: "Arizona Sans, sans-serif",
fontWeight: 400,
fontSize: 32,
color: captionColor,
width: 660,
lineHeight: 1.45,
letterSpacing: "-.01em",
};
return (
<>
{!plain && !isOpening && (
<div style={caption}>
<p style={{ margin: 0 }}>
{phaseCaption(clip.id, clip.caption).map((segment, index) => (
<span key={index} style={{ opacity: segment.clips.includes(clip.id) ? 1 : 0.4 }}>
{segment.text}{" "}
</span>
))}
</p>
</div>
)}
<div
style={
plain
? { position: "absolute", inset: 0 }
: {
position: "absolute",
left,
top: 30,
width: phoneWidth,
height: phoneHeight,
}
}
>
<OffthreadVideo
src={staticFile(source)}
trimBefore={clip.startFrame}
muted
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</div>
</>
);
}
export function CommentedDemo({
source,
background,
captionColor,
clips,
opening,
plain,
}: DemoProps) {
const [fontReady] = useState(() => delayRender("Load Arizona Sans"));
useEffect(() => {
void document.fonts
.load('400 32px "Arizona Sans"')
.then(() => continueRender(fontReady))
.catch(cancelRender);
}, [fontReady]);
return (
<AbsoluteFill style={{ backgroundColor: background }}>
<style>{`
@font-face { font-family: 'Arizona Sans'; src: url('${staticFile("ArizonaSans.woff2")}') format('woff2'); font-weight: 100 900; }
`}</style>
<Series>
{!plain && opening && (
<Series.Sequence durationInFrames={opening.frames}>
<DemoMoment source={source} captionColor={captionColor} clip={opening} plain={false} />
</Series.Sequence>
)}
{clips.map((clip) => (
<Series.Sequence key={clip.id} durationInFrames={clip.frames}>
<DemoMoment source={source} captionColor={captionColor} clip={clip} plain={plain} />
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);
}
registerRoot(() => (
<>
{[false, true].map((plain) => (
<Composition
key={String(plain)}
id={plain ? "PlainViewport" : "CommentedDemo"}
component={CommentedDemo}
width={plain ? phoneViewport.width : demoFrame.width}
height={plain ? phoneViewport.height : demoFrame.height}
fps={demoFps}
durationInFrames={demoFps}
defaultProps={{ ...defaults, plain }}
calculateMetadata={({ props }) => ({
fps: props.fps,
durationInFrames: Math.max(
1,
props.clips.reduce((sum, clip) => sum + clip.frames, 0) +
(props.plain ? 0 : (props.opening?.frames ?? 0)),
),
})}
/>
))}
</>
));