|
@@ -1,7 +1,6 @@
|
|
|
import React, { useEffect, useState } from "react";
|
|
import React, { useEffect, useState } from "react";
|
|
|
import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
|
|
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 type { TemplateType, AspectRatio, RenderScene, RenderProps } from "@pipeline/shared";
|
|
|
import { formatCount } from "@pipeline/shared";
|
|
import { formatCount } from "@pipeline/shared";
|
|
|
import NewsScene from "./news/index";
|
|
import NewsScene from "./news/index";
|
|
|
import KnowledgeScene from "./knowledge/index";
|
|
import KnowledgeScene from "./knowledge/index";
|
|
@@ -10,48 +9,32 @@ import MarketingScene from "./marketing/index";
|
|
|
import GithubTrendingScene, { ColorDot } from "./github-trending/index";
|
|
import GithubTrendingScene, { ColorDot } from "./github-trending/index";
|
|
|
import { THEMES } from "./base/theme/colors";
|
|
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;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// Re-export the render-time contract so legacy imports (`import { RemotionScene
|
|
|
|
|
+// } from "../Root"`) keep resolving during the migration. New code should import
|
|
|
|
|
+// RenderScene / RenderProps directly from @pipeline/shared.
|
|
|
|
|
+export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
|
|
|
|
|
+import type { RenderSegmentExtension } from "@pipeline/shared";
|
|
|
|
|
|
|
|
-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;
|
|
|
|
|
- };
|
|
|
|
|
|
|
+/** Narrow a scene's per-template extension to the github-trending variant, or
|
|
|
|
|
+ * undefined. Template-specific data lives in `extension`, not on the scene root. */
|
|
|
|
|
+const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
|
|
|
|
|
+ s.extension?.type === "github-trending" ? s.extension : undefined;
|
|
|
|
|
+
|
|
|
|
|
+/** Default visual length (seconds) for a scene with no audio (e.g. a silent
|
|
|
|
|
+ * cover). Visual duration is the TEMPLATE's call — it may exceed the audio to
|
|
|
|
|
+ * add silent padding, holds, transitions, etc. */
|
|
|
|
|
+const SILENT_SCENE_SECONDS = 1;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Visual duration in FRAMES for a scene — computed by the TEMPLATE, not the
|
|
|
|
|
+ * renderer. `scene.duration` is the AUDIO length only; this is where a template
|
|
|
|
|
+ * decides the on-screen length (default = audio, silent scenes get a small
|
|
|
|
|
+ * floor). Override/extend per template to add silent padding or special timing.
|
|
|
|
|
+ */
|
|
|
|
|
+function sceneDurationFrames(scene: RenderScene, fps: number): number {
|
|
|
|
|
+ const audio = scene.duration ?? 0;
|
|
|
|
|
+ const seconds = audio > 0 ? audio : SILENT_SCENE_SECONDS;
|
|
|
|
|
+ return Math.max(1, Math.round(seconds * fps));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
|
|
const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
|
|
@@ -74,55 +57,66 @@ const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
|
|
|
|
|
|
|
|
const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
|
|
const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
|
|
|
|
|
|
|
|
-const TemplateComposition: React.FC<RemotionProps> = (props) => {
|
|
|
|
|
|
|
+const TemplateComposition: React.FC<RenderProps> = (props) => {
|
|
|
|
|
+ const { fps } = useVideoConfig();
|
|
|
const SceneComponent = SCENE_MAP[props.template];
|
|
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
|
|
// github-trending: the cover lists every repo (name / language / today's
|
|
|
- // star gain / one-line description). Content scenes each carry scene.github.
|
|
|
|
|
|
|
+ // star gain / one-line description). Content scenes carry repo data in extension.
|
|
|
const coverRepos = props.scenes.filter(
|
|
const coverRepos = props.scenes.filter(
|
|
|
- (s) => s.sceneType === "content" && s.github
|
|
|
|
|
|
|
+ (s) => s.kind === "content" && ghExt(s)
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // Compute the visual timeline (TEMPLATE-controlled). Each scene's visual
|
|
|
|
|
+ // frames come from sceneDurationFrames (default = audio duration); the
|
|
|
|
|
+ // cumulative cursor places Sequences. Visuals may exceed audio (silent pads).
|
|
|
|
|
+ let cursor = 0;
|
|
|
|
|
+ const layout = props.scenes.map((scene) => {
|
|
|
|
|
+ const durationFrames = sceneDurationFrames(scene, fps);
|
|
|
|
|
+ const startFrame = cursor;
|
|
|
|
|
+ cursor += durationFrames;
|
|
|
|
|
+ return { scene, startFrame, endFrame: startFrame + durationFrames, durationFrames };
|
|
|
|
|
+ });
|
|
|
|
|
+ const totalFrames = cursor || 90;
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<AbsoluteFill style={{ backgroundColor: "#000" }}>
|
|
<AbsoluteFill style={{ backgroundColor: "#000" }}>
|
|
|
- {props.scenes.map((scene) => {
|
|
|
|
|
- const duration = scene.endFrame - scene.startFrame;
|
|
|
|
|
|
|
+ {layout.map(({ scene, startFrame, durationFrames }) => {
|
|
|
const sceneAudio = scene.audioFilename ? (
|
|
const sceneAudio = scene.audioFilename ? (
|
|
|
<Audio src={staticFile(scene.audioFilename)} />
|
|
<Audio src={staticFile(scene.audioFilename)} />
|
|
|
) : null;
|
|
) : null;
|
|
|
|
|
|
|
|
- if (scene.sceneType === "cover") {
|
|
|
|
|
|
|
+ if (scene.kind === "cover") {
|
|
|
return (
|
|
return (
|
|
|
<Sequence
|
|
<Sequence
|
|
|
key={scene.id}
|
|
key={scene.id}
|
|
|
- from={scene.startFrame}
|
|
|
|
|
- durationInFrames={duration}
|
|
|
|
|
|
|
+ from={startFrame}
|
|
|
|
|
+ durationInFrames={durationFrames}
|
|
|
>
|
|
>
|
|
|
{sceneAudio}
|
|
{sceneAudio}
|
|
|
<CoverScene
|
|
<CoverScene
|
|
|
template={props.template}
|
|
template={props.template}
|
|
|
title={props.title}
|
|
title={props.title}
|
|
|
subtitle={props.subtitle}
|
|
subtitle={props.subtitle}
|
|
|
- keyframes={scene.keyframes}
|
|
|
|
|
|
|
+ cardList={scene.cardList}
|
|
|
backgroundAsset={scene.backgroundAsset}
|
|
backgroundAsset={scene.backgroundAsset}
|
|
|
coverRepos={coverRepos}
|
|
coverRepos={coverRepos}
|
|
|
- trendSummary={scene.trendSummary}
|
|
|
|
|
|
|
+ trendSummary={props.trendSummary}
|
|
|
totalFrames={totalFrames}
|
|
totalFrames={totalFrames}
|
|
|
/>
|
|
/>
|
|
|
</Sequence>
|
|
</Sequence>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
- if (scene.sceneType === "outro") {
|
|
|
|
|
|
|
+ if (scene.kind === "outro") {
|
|
|
return (
|
|
return (
|
|
|
<Sequence
|
|
<Sequence
|
|
|
key={scene.id}
|
|
key={scene.id}
|
|
|
- from={scene.startFrame}
|
|
|
|
|
- durationInFrames={duration}
|
|
|
|
|
|
|
+ from={startFrame}
|
|
|
|
|
+ durationInFrames={durationFrames}
|
|
|
>
|
|
>
|
|
|
{sceneAudio}
|
|
{sceneAudio}
|
|
|
<OutroScene
|
|
<OutroScene
|
|
|
- text={props.outro?.text || scene.narration}
|
|
|
|
|
- cta={props.outro?.cta}
|
|
|
|
|
|
|
+ text={scene.captionOrigin ?? ""}
|
|
|
|
|
+ cta={scene.title}
|
|
|
totalFrames={totalFrames}
|
|
totalFrames={totalFrames}
|
|
|
/>
|
|
/>
|
|
|
</Sequence>
|
|
</Sequence>
|
|
@@ -131,14 +125,14 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
|
|
|
return (
|
|
return (
|
|
|
<Sequence
|
|
<Sequence
|
|
|
key={scene.id}
|
|
key={scene.id}
|
|
|
- from={scene.startFrame}
|
|
|
|
|
- durationInFrames={duration}
|
|
|
|
|
|
|
+ from={startFrame}
|
|
|
|
|
+ durationInFrames={durationFrames}
|
|
|
>
|
|
>
|
|
|
{sceneAudio}
|
|
{sceneAudio}
|
|
|
<SceneComponent
|
|
<SceneComponent
|
|
|
scene={scene}
|
|
scene={scene}
|
|
|
- sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
|
|
|
|
|
- totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
|
|
|
|
|
|
|
+ sceneIndex={props.scenes.filter((s) => s.kind === "content").indexOf(scene)}
|
|
|
|
|
+ totalScenes={props.scenes.filter((s) => s.kind === "content").length}
|
|
|
totalFrames={totalFrames}
|
|
totalFrames={totalFrames}
|
|
|
title={props.title}
|
|
title={props.title}
|
|
|
channelName={props.channelName}
|
|
channelName={props.channelName}
|
|
@@ -147,7 +141,7 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
|
<GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
|
|
<GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
|
|
|
- {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
|
|
|
|
|
|
|
+ {props.template === "github-trending" && <ChapterToc layout={layout} />}
|
|
|
</AbsoluteFill>
|
|
</AbsoluteFill>
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|
|
@@ -193,12 +187,12 @@ const CoverScene: React.FC<{
|
|
|
template: TemplateType;
|
|
template: TemplateType;
|
|
|
title: string;
|
|
title: string;
|
|
|
subtitle?: string;
|
|
subtitle?: string;
|
|
|
- keyframes: Array<{ type: string; content: string }>;
|
|
|
|
|
|
|
+ cardList?: Array<{ kind?: string; desc?: string }>;
|
|
|
backgroundAsset?: { id: string; filename: string };
|
|
backgroundAsset?: { id: string; filename: string };
|
|
|
- coverRepos?: RemotionScene[];
|
|
|
|
|
|
|
+ coverRepos?: RenderScene[];
|
|
|
trendSummary?: string;
|
|
trendSummary?: string;
|
|
|
totalFrames: number;
|
|
totalFrames: number;
|
|
|
-}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => {
|
|
|
|
|
|
|
+}> = ({ template, title, subtitle, cardList, backgroundAsset, coverRepos, trendSummary }) => {
|
|
|
const accent = THEMES[template].primaryLight;
|
|
const accent = THEMES[template].primaryLight;
|
|
|
const isGithubTrending = template === "github-trending";
|
|
const isGithubTrending = template === "github-trending";
|
|
|
const { width, height } = useVideoConfig();
|
|
const { width, height } = useVideoConfig();
|
|
@@ -210,7 +204,7 @@ const CoverScene: React.FC<{
|
|
|
// landscape, vertical stack in portrait. Doubles as the opening narration.
|
|
// landscape, vertical stack in portrait. Doubles as the opening narration.
|
|
|
if (isGithubTrending) {
|
|
if (isGithubTrending) {
|
|
|
const topRepos = [...(coverRepos ?? [])]
|
|
const topRepos = [...(coverRepos ?? [])]
|
|
|
- .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
|
|
|
|
|
|
|
+ .sort((a, b) => (ghExt(b)!.repo.todayStars ?? 0) - (ghExt(a)!.repo.todayStars ?? 0))
|
|
|
.slice(0, 6);
|
|
.slice(0, 6);
|
|
|
const primary = THEMES[template].primary;
|
|
const primary = THEMES[template].primary;
|
|
|
|
|
|
|
@@ -280,7 +274,7 @@ const CoverScene: React.FC<{
|
|
|
maxWidth: isPortrait ? "90%" : "94%",
|
|
maxWidth: isPortrait ? "90%" : "94%",
|
|
|
}}>
|
|
}}>
|
|
|
{topRepos.map((s, i) => {
|
|
{topRepos.map((s, i) => {
|
|
|
- const g = s.github!;
|
|
|
|
|
|
|
+ const g = ghExt(s)!;
|
|
|
const repo = g.repo;
|
|
const repo = g.repo;
|
|
|
const language = repo.language || "";
|
|
const language = repo.language || "";
|
|
|
const languageColor = repo.languageColor || accent;
|
|
const languageColor = repo.languageColor || accent;
|
|
@@ -408,10 +402,10 @@ const CoverScene: React.FC<{
|
|
|
{subtitle}
|
|
{subtitle}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
- {keyframes.length > 0 && (
|
|
|
|
|
|
|
+ {(cardList?.length ?? 0) > 0 && (
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
- {keyframes.map((kf, i) => {
|
|
|
|
|
- const isHighlight = kf.type === "highlight";
|
|
|
|
|
|
|
+ {cardList!.map((card, i) => {
|
|
|
|
|
+ const isHighlight = card.kind === "highlight";
|
|
|
return (
|
|
return (
|
|
|
<div key={i} style={{
|
|
<div key={i} style={{
|
|
|
fontSize: isHighlight ? 30 : 24,
|
|
fontSize: isHighlight ? 30 : 24,
|
|
@@ -420,7 +414,7 @@ const CoverScene: React.FC<{
|
|
|
fontFamily: "Noto Sans SC",
|
|
fontFamily: "Noto Sans SC",
|
|
|
textShadow: "0 1px 8px rgba(0,0,0,0.85)",
|
|
textShadow: "0 1px 8px rgba(0,0,0,0.85)",
|
|
|
}}>
|
|
}}>
|
|
|
- {kf.content}
|
|
|
|
|
|
|
+ {card.desc}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -502,7 +496,9 @@ const GlobalProgressBar: React.FC<{
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
|
|
|
|
|
|
|
+const ChapterToc: React.FC<{
|
|
|
|
|
+ layout: { scene: RenderScene; startFrame: number; endFrame: number; durationFrames: number }[];
|
|
|
|
|
+}> = ({ layout }) => {
|
|
|
// github-trending only: a chapter table of contents pinned just above the
|
|
// github-trending only: a chapter table of contents pinned just above the
|
|
|
// global progress bar. Lists every repo's short name; the chapter whose
|
|
// global progress bar. Lists every repo's short name; the chapter whose
|
|
|
// [startFrame, endFrame) contains the current frame is highlighted. A faint
|
|
// [startFrame, endFrame) contains the current frame is highlighted. A faint
|
|
@@ -513,10 +509,8 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
|
|
|
const isPortrait = height > width;
|
|
const isPortrait = height > width;
|
|
|
const accent = THEMES["github-trending"].primary;
|
|
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
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const chapters = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
|
|
|
|
|
+ const active = chapters.findIndex((c) => frame >= c.startFrame && frame < c.endFrame);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
@@ -548,10 +542,10 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
|
|
|
>
|
|
>
|
|
|
{chapters.map((c, i) => {
|
|
{chapters.map((c, i) => {
|
|
|
const isActive = i === active;
|
|
const isActive = i === active;
|
|
|
- const repo = c.github!.repo;
|
|
|
|
|
- const label = repo.name || repo.fullName || c.displayText || "";
|
|
|
|
|
|
|
+ const repo = ghExt(c.scene)!.repo;
|
|
|
|
|
+ const label = repo.name || repo.fullName || c.scene.title || "";
|
|
|
return (
|
|
return (
|
|
|
- <React.Fragment key={c.id}>
|
|
|
|
|
|
|
+ <React.Fragment key={c.scene.id}>
|
|
|
{i > 0 && (
|
|
{i > 0 && (
|
|
|
<span
|
|
<span
|
|
|
style={{
|
|
style={{
|
|
@@ -586,51 +580,39 @@ const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-const defaultProps: RemotionProps = {
|
|
|
|
|
|
|
+const defaultProps: RenderProps = {
|
|
|
scenes: [
|
|
scenes: [
|
|
|
{
|
|
{
|
|
|
id: "cover",
|
|
id: "cover",
|
|
|
- sceneType: "cover",
|
|
|
|
|
- startFrame: 0,
|
|
|
|
|
- endFrame: 90,
|
|
|
|
|
- narration: "",
|
|
|
|
|
- wordTimestamps: [],
|
|
|
|
|
|
|
+ kind: "cover",
|
|
|
|
|
+ duration: 3,
|
|
|
audioFilename: "",
|
|
audioFilename: "",
|
|
|
- keyframes: [
|
|
|
|
|
- { type: "text", content: "文本转视频,一键生成" },
|
|
|
|
|
- ],
|
|
|
|
|
- layoutHint: "centered",
|
|
|
|
|
|
|
+ captionOrigin: "",
|
|
|
|
|
+ cardList: [{ kind: "text", desc: "文本转视频,一键生成" }],
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
id: "scene-1",
|
|
id: "scene-1",
|
|
|
- sceneType: "content",
|
|
|
|
|
- startFrame: 90,
|
|
|
|
|
- endFrame: 180,
|
|
|
|
|
- narration: "欢迎使用 Pipeline 视频生成工具",
|
|
|
|
|
- wordTimestamps: [],
|
|
|
|
|
|
|
+ kind: "content",
|
|
|
|
|
+ duration: 3,
|
|
|
audioFilename: "",
|
|
audioFilename: "",
|
|
|
- keyframes: [
|
|
|
|
|
- { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
|
|
|
|
|
- ],
|
|
|
|
|
- layoutHint: "centered",
|
|
|
|
|
|
|
+ title: "欢迎使用 Pipeline",
|
|
|
|
|
+ captionOrigin: "欢迎使用 Pipeline 视频生成工具",
|
|
|
|
|
+ caption: [{ text: "欢迎使用 Pipeline 视频生成工具", duration: 3 }],
|
|
|
|
|
+ cardList: [{ kind: "text", desc: "支持资讯、知识、观点、营销、GitHub热榜模板" }],
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
id: "outro",
|
|
id: "outro",
|
|
|
- sceneType: "outro",
|
|
|
|
|
- startFrame: 180,
|
|
|
|
|
- endFrame: 240,
|
|
|
|
|
- narration: "感谢观看",
|
|
|
|
|
- wordTimestamps: [],
|
|
|
|
|
|
|
+ kind: "outro",
|
|
|
|
|
+ duration: 2,
|
|
|
audioFilename: "",
|
|
audioFilename: "",
|
|
|
- keyframes: [],
|
|
|
|
|
- layoutHint: "centered",
|
|
|
|
|
|
|
+ captionOrigin: "感谢观看",
|
|
|
|
|
+ title: "点赞 | 关注",
|
|
|
},
|
|
},
|
|
|
],
|
|
],
|
|
|
template: "news",
|
|
template: "news",
|
|
|
platform: "bilibili",
|
|
platform: "bilibili",
|
|
|
title: "Pipeline Demo",
|
|
title: "Pipeline Demo",
|
|
|
subtitle: "结构化视频生成",
|
|
subtitle: "结构化视频生成",
|
|
|
- outro: { text: "感谢观看", cta: "点赞 | 关注" },
|
|
|
|
|
channelName: "Pipeline",
|
|
channelName: "Pipeline",
|
|
|
globalStyle: { tone: "formal", pace: "normal" },
|
|
globalStyle: { tone: "formal", pace: "normal" },
|
|
|
};
|
|
};
|
|
@@ -703,9 +685,15 @@ export const RemotionRoot: React.FC = () => {
|
|
|
height={dims.height}
|
|
height={dims.height}
|
|
|
defaultProps={defaultProps}
|
|
defaultProps={defaultProps}
|
|
|
calculateMetadata={async (params) => {
|
|
calculateMetadata={async (params) => {
|
|
|
- const p = params.props as unknown as RemotionProps;
|
|
|
|
|
|
|
+ const p = params.props as unknown as RenderProps;
|
|
|
|
|
+ // Visual timeline is template-computed: sum each scene's visual
|
|
|
|
|
+ // frames (default = audio duration; silent scenes get a floor).
|
|
|
|
|
+ const total = p.scenes.reduce(
|
|
|
|
|
+ (sum, s) => sum + sceneDurationFrames(s, 30),
|
|
|
|
|
+ 0
|
|
|
|
|
+ );
|
|
|
return {
|
|
return {
|
|
|
- durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
|
|
|
|
|
|
|
+ durationInFrames: total || 90,
|
|
|
props: params.props,
|
|
props: params.props,
|
|
|
};
|
|
};
|
|
|
}}
|
|
}}
|