| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717 |
- import React, { useEffect, useState } from "react";
- import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
- import type { TemplateType, AspectRatio } from "@pipeline/shared";
- import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
- import { formatCount } from "@pipeline/shared";
- import NewsScene from "./news/index";
- import KnowledgeScene from "./knowledge/index";
- import OpinionScene from "./opinion/index";
- import MarketingScene from "./marketing/index";
- import GithubTrendingScene, { ColorDot } from "./github-trending/index";
- import { THEMES } from "./base/theme/colors";
- export interface RemotionScene {
- id: string;
- sceneType: "cover" | "content" | "summary" | "outro";
- startFrame: number;
- endFrame: number;
- narration: string;
- displayText?: string;
- title?: string;
- wordTimestamps: WordTimestamp[];
- keyframes: Array<{
- type: string;
- content: string;
- startFrame?: number;
- endFrame?: number;
- style?: Record<string, string>;
- }>;
- images?: Array<{ url?: string; query?: string; filename: string }>;
- audioFilename: string;
- backgroundAsset?: { id: string; filename: string };
- layoutHint: string;
- github?: GithubSceneData;
- // github-trending cover scene only: theme tags + trend line.
- coverTags?: string[];
- trendSummary?: string;
- }
- export interface RemotionProps {
- scenes: RemotionScene[];
- template: TemplateType;
- platform: string;
- title: string;
- subtitle?: string;
- outro?: {
- text: string;
- narration?: string;
- cta?: string;
- };
- channelName: string;
- globalStyle: {
- tone: string;
- pace: string;
- };
- }
- const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
- news: NewsScene,
- knowledge: KnowledgeScene,
- opinion: OpinionScene,
- marketing: MarketingScene,
- "github-trending": GithubTrendingScene,
- };
- const RATIO_ID: Record<AspectRatio, string> = {
- "16:9": "landscape",
- "9:16": "portrait",
- };
- const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
- "16:9": { width: 1920, height: 1080 },
- "9:16": { width: 1080, height: 1920 },
- };
- const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
- const TemplateComposition: React.FC<RemotionProps> = (props) => {
- const SceneComponent = SCENE_MAP[props.template];
- const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
- // github-trending: the cover lists every repo (name / language / today's
- // star gain / one-line description). Content scenes each carry scene.github.
- const coverRepos = props.scenes.filter(
- (s) => s.sceneType === "content" && s.github
- );
- return (
- <AbsoluteFill style={{ backgroundColor: "#000" }}>
- {props.scenes.map((scene) => {
- const duration = scene.endFrame - scene.startFrame;
- const sceneAudio = scene.audioFilename ? (
- <Audio src={staticFile(scene.audioFilename)} />
- ) : null;
- if (scene.sceneType === "cover") {
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <CoverScene
- template={props.template}
- title={props.title}
- subtitle={props.subtitle}
- keyframes={scene.keyframes}
- backgroundAsset={scene.backgroundAsset}
- coverRepos={coverRepos}
- trendSummary={scene.trendSummary}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- if (scene.sceneType === "outro") {
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <OutroScene
- text={props.outro?.text || scene.narration}
- cta={props.outro?.cta}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <SceneComponent
- scene={scene}
- sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
- totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
- totalFrames={totalFrames}
- title={props.title}
- channelName={props.channelName}
- />
- </Sequence>
- );
- })}
- <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
- {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
- </AbsoluteFill>
- );
- };
- // --- Cover Scene ---
- // Faint, sparse code/data motifs on the github-trending cover background.
- // Low-opacity and edge-anchored so the cover stays clean — the motifs read
- // only as subtle texture, never competing with the cards.
- const CoverDecor: React.FC<{ accent: string }> = ({ accent }) => {
- const tok = (css: React.CSSProperties): React.CSSProperties => ({
- position: "absolute",
- fontFamily: "monospace",
- fontWeight: 700,
- color: "#0f172a",
- opacity: 0.06,
- letterSpacing: "0.02em",
- ...css,
- });
- return (
- <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
- <span style={tok({ top: 44, left: 52, fontSize: 56 })}>{"</>"}</span>
- <span style={tok({ top: 58, right: 60, fontSize: 48 })}>{"{ }"}</span>
- <span style={tok({ bottom: 168, left: 54, fontSize: 28 })}>{"01 10 01"}</span>
- <span style={tok({ bottom: 150, right: 58, fontSize: 26 })}>{"git push"}</span>
- {/* Faint data line chart */}
- <svg width="180" height="84" viewBox="0 0 180 84" style={{ position: "absolute", top: 64, right: 168, opacity: 0.08 }}>
- <polyline points="0,66 34,50 68,56 102,28 136,34 180,8" fill="none" stroke={accent} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
- </svg>
- {/* Faint data bar cluster */}
- <div style={{ position: "absolute", bottom: 206, left: 80, display: "flex", alignItems: "flex-end", gap: 7, opacity: 0.08 }}>
- <div style={{ width: 12, height: 32, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 58, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 24, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 76, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 46, background: accent, borderRadius: 3 }} />
- </div>
- </div>
- );
- };
- const CoverScene: React.FC<{
- template: TemplateType;
- title: string;
- subtitle?: string;
- keyframes: Array<{ type: string; content: string }>;
- backgroundAsset?: { id: string; filename: string };
- coverRepos?: RemotionScene[];
- trendSummary?: string;
- totalFrames: number;
- }> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => {
- const accent = THEMES[template].primaryLight;
- const isGithubTrending = template === "github-trending";
- const { width, height } = useVideoConfig();
- const isPortrait = height > width;
- // github-trending cover: a DARK title card (contrast over a light bg with
- // faint code/data motifs) centered near the top, then the TOP 3 repos by
- // today's star gain as large rounded "floating" cards — horizontal row in
- // landscape, vertical stack in portrait. Doubles as the opening narration.
- if (isGithubTrending) {
- const topRepos = [...(coverRepos ?? [])]
- .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
- .slice(0, 6);
- const primary = THEMES[template].primary;
- return (
- <AbsoluteFill style={{
- background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
- }}>
- <CoverDecor accent={accent} />
- {/* Top accent line */}
- <div style={{
- position: "absolute", top: 0, left: 0, width: "100%", height: 6,
- background: `linear-gradient(90deg, ${primary}, ${accent})`,
- }} />
- <div style={{
- position: "absolute", inset: 0, display: "flex", flexDirection: "column",
- alignItems: "center",
- justifyContent: isPortrait ? "center" : "flex-start",
- padding: isPortrait ? "72px 64px 130px" : "58px 80px 112px",
- }}>
- {/* DARK title card — centered, near the top (contrast over light bg) */}
- <div style={{
- display: "flex", flexDirection: "column", alignItems: "center", gap: 14,
- padding: isPortrait ? "38px 76px" : "32px 88px",
- borderRadius: 26,
- background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
- border: `1.5px solid ${primary}55`,
- boxShadow: "0 18px 44px rgba(15,23,42,0.22)",
- marginBottom: isPortrait ? 44 : 38,
- maxWidth: isPortrait ? "84%" : "74%",
- textAlign: "center",
- }}>
- <div style={{
- fontSize: isPortrait ? 72 : 66, fontWeight: 800, color: "#ffffff",
- fontFamily: "Noto Sans SC", lineHeight: 1.15, letterSpacing: "-0.02em",
- }}>
- {title}
- </div>
- <div style={{
- display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
- justifyContent: "center",
- }}>
- {subtitle && (
- <span style={{
- fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: accent,
- fontFamily: "Noto Sans SC",
- }}>
- {subtitle}
- </span>
- )}
- {trendSummary && (
- <span style={{
- fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#cbd5e1",
- fontFamily: "Noto Sans SC",
- }}>
- · {trendSummary}
- </span>
- )}
- </div>
- </div>
- {/* TOP 6 repos by today's star gain (3 per row → wraps to a grid) */}
- {topRepos.length > 0 && (
- <div style={{
- display: "flex", flexWrap: "wrap",
- gap: 24,
- width: "100%",
- maxWidth: isPortrait ? "90%" : "94%",
- }}>
- {topRepos.map((s, i) => {
- const g = s.github!;
- const repo = g.repo;
- const language = repo.language || "";
- const languageColor = repo.languageColor || accent;
- const todayLabel = repo.todayStars != null ? `+${formatCount(repo.todayStars)}` : "";
- return (
- <div key={s.id} style={{
- flex: isPortrait ? "0 0 calc((100% - 24px) / 2)" : "0 0 calc((100% - 48px) / 3)",
- minWidth: 0,
- display: "flex", flexDirection: "column",
- padding: isPortrait ? "28px 30px" : "30px",
- borderRadius: 22,
- background: "#ffffff",
- border: `1px solid ${primary}22`,
- boxShadow: "0 16px 38px rgba(15,23,42,0.12), 0 2px 8px rgba(15,23,42,0.06)",
- }}>
- {/* Rank badge + today's gain (the selection signal) */}
- <div style={{
- display: "flex", alignItems: "center", justifyContent: "space-between",
- marginBottom: 18,
- }}>
- <div style={{
- display: "inline-flex", alignItems: "center", justifyContent: "center",
- width: isPortrait ? 56 : 52, height: isPortrait ? 56 : 52,
- borderRadius: "50%",
- background: `linear-gradient(135deg, ${accent}, ${primary})`,
- color: "#fff", fontWeight: 800, fontFamily: "Noto Sans SC",
- fontSize: isPortrait ? 30 : 26,
- boxShadow: "0 4px 12px rgba(34,197,94,0.35)",
- }}>
- {i + 1}
- </div>
- {todayLabel && (
- <span style={{
- fontSize: isPortrait ? 56 : 50, fontWeight: 800, color: primary,
- fontFamily: "Noto Sans SC", lineHeight: 1,
- }}>{todayLabel}</span>
- )}
- </div>
- {/* Repo full name */}
- <div style={{
- fontSize: isPortrait ? 40 : 34, fontWeight: 800, color: "#0f172a",
- fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 12,
- wordBreak: "break-word",
- }}>
- {repo.fullName}
- </div>
- {/* Language + total stars */}
- <div style={{
- display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
- marginBottom: 14, fontFamily: "Noto Sans SC",
- }}>
- {language && (
- <span style={{
- display: "inline-flex", alignItems: "center", gap: 8,
- fontSize: isPortrait ? 24 : 21, fontWeight: 600, color: "#334155",
- padding: "5px 14px", borderRadius: 999,
- background: `${languageColor}1a`, border: `1px solid ${languageColor}55`,
- }}>
- <ColorDot color={languageColor} size={isPortrait ? 14 : 12} />
- {language}
- </span>
- )}
- {repo.stars != null && (
- <span style={{
- fontSize: isPortrait ? 23 : 20, color: "#64748b", fontWeight: 500,
- }}>
- {formatCount(repo.stars)} stars
- </span>
- )}
- </div>
- {/* One-line description (highlights) */}
- <div style={{
- fontSize: isPortrait ? 27 : 23, lineHeight: 1.4, color: "#475569",
- fontFamily: "Noto Sans SC",
- display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
- overflow: "hidden",
- }}>
- {g.highlights}
- </div>
- </div>
- );
- })}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- }
- return (
- <AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
- {backgroundAsset && (
- <Img
- src={staticFile(backgroundAsset.filename)}
- style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
- />
- )}
- <div
- style={{
- position: "absolute",
- inset: 0,
- background:
- "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.55))",
- }}
- />
- <div style={{
- position: "absolute", inset: 0, display: "flex",
- flexDirection: "column", justifyContent: "center", alignItems: "center",
- padding: 80, textAlign: "center",
- }}>
- <div style={{
- fontSize: 72, fontWeight: 800, color: "white",
- fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
- maxWidth: "80%",
- textShadow: "0 2px 16px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.85)",
- }}>
- {title}
- </div>
- {subtitle && (
- <div style={{
- fontSize: 28, color: "white",
- fontFamily: "Noto Sans SC", marginBottom: 40,
- textShadow: "0 2px 12px rgba(0,0,0,0.85)",
- }}>
- {subtitle}
- </div>
- )}
- {keyframes.length > 0 && (
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
- {keyframes.map((kf, i) => {
- const isHighlight = kf.type === "highlight";
- return (
- <div key={i} style={{
- fontSize: isHighlight ? 30 : 24,
- fontWeight: isHighlight ? 700 : 500,
- color: isHighlight ? accent : "white",
- fontFamily: "Noto Sans SC",
- textShadow: "0 1px 8px rgba(0,0,0,0.85)",
- }}>
- {kf.content}
- </div>
- );
- })}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- };
- // --- Outro Scene ---
- const OutroScene: React.FC<{
- text: string;
- cta?: string;
- totalFrames: number;
- }> = ({ text, cta }) => {
- const frame = useCurrentFrame();
- const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
- return (
- <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
- <div style={{
- position: "absolute", inset: 0, display: "flex",
- flexDirection: "column", justifyContent: "center", alignItems: "center",
- padding: 80, textAlign: "center",
- }}>
- <div style={{
- fontSize: 48, fontWeight: 700, color: "white",
- fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
- maxWidth: "80%",
- }}>
- {text}
- </div>
- {cta && (
- <div style={{
- fontSize: 28, fontWeight: 600, color: "#fbbf24",
- fontFamily: "Noto Sans SC",
- padding: "16px 48px",
- borderRadius: 12,
- border: "2px solid #fbbf24",
- }}>
- {cta}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- };
- const GlobalProgressBar: React.FC<{
- totalFrames: number;
- color: string;
- }> = ({ totalFrames, color }) => {
- const frame = useCurrentFrame();
- const progress = interpolate(frame, [0, totalFrames], [0, 100], {
- extrapolateRight: "clamp",
- });
- return (
- <div
- style={{
- position: "absolute",
- bottom: 0,
- left: 0,
- width: "100%",
- height: 4,
- backgroundColor: "rgba(255,255,255,0.1)",
- }}
- >
- <div
- style={{
- width: `${progress}%`,
- height: "100%",
- backgroundColor: color,
- }}
- />
- </div>
- );
- };
- const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
- // github-trending only: a chapter table of contents pinned just above the
- // global progress bar. Lists every repo's short name; the chapter whose
- // [startFrame, endFrame) contains the current frame is highlighted. A faint
- // dark gradient strip behind the row keeps labels legible over light
- // backgrounds (e.g. the cover) and visually groups the TOC with the bar.
- const frame = useCurrentFrame();
- const { width, height } = useVideoConfig();
- const isPortrait = height > width;
- const accent = THEMES["github-trending"].primary;
- const chapters = scenes.filter((s) => s.sceneType === "content" && s.github);
- const active = chapters.findIndex(
- (c) => frame >= c.startFrame && frame < c.endFrame
- );
- return (
- <>
- <div
- style={{
- position: "absolute",
- bottom: 0,
- left: 0,
- width: "100%",
- height: 72,
- background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
- pointerEvents: "none",
- }}
- />
- <div
- style={{
- position: "absolute",
- bottom: 12,
- left: 0,
- width: "100%",
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- gap: isPortrait ? 16 : 12,
- padding: "0 32px",
- fontFamily: "Noto Sans SC",
- pointerEvents: "none",
- }}
- >
- {chapters.map((c, i) => {
- const isActive = i === active;
- const repo = c.github!.repo;
- const label = repo.name || repo.fullName || c.displayText || "";
- return (
- <React.Fragment key={c.id}>
- {i > 0 && (
- <span
- style={{
- color: "rgba(255,255,255,0.3)",
- fontSize: isPortrait ? 22 : 18,
- flexShrink: 0,
- }}
- >
- ·
- </span>
- )}
- <span
- style={{
- flex: "1 1 0",
- minWidth: 0,
- fontSize: isPortrait ? 26 : 22,
- fontWeight: isActive ? 700 : 500,
- color: isActive ? accent : "rgba(255,255,255,0.6)",
- whiteSpace: "nowrap",
- overflow: "hidden",
- textOverflow: "ellipsis",
- textAlign: "center",
- }}
- >
- {label}
- </span>
- </React.Fragment>
- );
- })}
- </div>
- </>
- );
- };
- const defaultProps: RemotionProps = {
- scenes: [
- {
- id: "cover",
- sceneType: "cover",
- startFrame: 0,
- endFrame: 90,
- narration: "",
- wordTimestamps: [],
- audioFilename: "",
- keyframes: [
- { type: "text", content: "文本转视频,一键生成" },
- ],
- layoutHint: "centered",
- },
- {
- id: "scene-1",
- sceneType: "content",
- startFrame: 90,
- endFrame: 180,
- narration: "欢迎使用 Pipeline 视频生成工具",
- wordTimestamps: [],
- audioFilename: "",
- keyframes: [
- { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
- ],
- layoutHint: "centered",
- },
- {
- id: "outro",
- sceneType: "outro",
- startFrame: 180,
- endFrame: 240,
- narration: "感谢观看",
- wordTimestamps: [],
- audioFilename: "",
- keyframes: [],
- layoutHint: "centered",
- },
- ],
- template: "news",
- platform: "bilibili",
- title: "Pipeline Demo",
- subtitle: "结构化视频生成",
- outro: { text: "感谢观看", cta: "点赞 | 关注" },
- channelName: "Pipeline",
- globalStyle: { tone: "formal", pace: "normal" },
- };
- export const RemotionRoot: React.FC = () => {
- // Load bundled Noto Sans SC at component mount (staticFile() requires the
- // bundle context which is only ready after the component tree mounts).
- const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC"));
- useEffect(() => {
- let cancelled = false;
- try {
- const style = document.createElement("style");
- style.textContent = `
- @font-face {
- font-family: "Noto Sans SC";
- src: local("Noto Sans CJK SC"),
- url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype");
- font-weight: 400;
- font-display: block;
- }
- @font-face {
- font-family: "Noto Sans SC";
- src: local("Noto Sans CJK SC"),
- url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype");
- font-weight: 700;
- font-display: block;
- }
- `;
- document.head.appendChild(style);
- const fonts = document.fonts as unknown as {
- load: (font: string) => Promise<unknown>;
- };
- Promise.all([
- fonts.load('400 16px "Noto Sans SC"'),
- fonts.load('700 16px "Noto Sans SC"'),
- ])
- .then(() => {
- if (!cancelled) continueRender(fontHandle);
- })
- .catch((err) => {
- console.error("Font loading failed:", err);
- if (!cancelled) continueRender(fontHandle);
- });
- } catch (err) {
- console.error("Font init failed:", err);
- continueRender(fontHandle);
- }
- return () => {
- cancelled = true;
- };
- }, [fontHandle]);
- return (
- <>
- {TEMPLATES.map((template) =>
- (
- Object.entries(ASPECT_RATIOS) as [
- AspectRatio,
- { width: number; height: number },
- ][]
- ).map(([ratio, dims]) => (
- <Composition
- key={`${template}-${RATIO_ID[ratio]}`}
- id={`${template}-${RATIO_ID[ratio]}`}
- component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
- durationInFrames={180}
- fps={30}
- width={dims.width}
- height={dims.height}
- defaultProps={defaultProps}
- calculateMetadata={async (params) => {
- const p = params.props as unknown as RemotionProps;
- return {
- durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
- props: params.props,
- };
- }}
- />
- ))
- )}
- </>
- );
- };
|