
Mv Skill
- 1 installs
- 6 repo stars
- Updated April 12, 2026
- yizhiyanhua-ai/agent-skills
Produces a commercial-quality 9:16 vertical music video (50-60s MP4) from a theme and style using Remotion, AI music generation, storyboard planning, and asset fetching.
About
Automates music-video creation: generates AI music (Suno) or accepts local audio, plans a storyboard, fetches assets (Pexels/Pixabay or AI), and renders MP4 via Remotion. A developer uses it to make short vertical MVs for TikTok/Reels/Shorts from a theme and style.
- Style templates: anime-hype, cyberpunk, and lyric
- Storyboard preview with per-scene edits before rendering
Mv Skill by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yizhiyanhua-ai/agent-skills --skill mv-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | April 12, 2026 |
| Repository | yizhiyanhua-ai/agent-skills ↗ |
What it does
Produces a commercial-quality 9:16 vertical music video (50-60s MP4) from a theme and style using Remotion, AI music generation, storyboard planning, and asset fetching.
Files
MV Skill - 动感短视频生成器
你只需描述主题和风格,这个 skill 会自动完成:AI 音乐生成、智能分镜规划、素材获取、Remotion 渲染,最终输出一个商业级 MP4 文件。
---
快速决策:什么时候用这个 Skill?
| 场景 | 用 mv-skill? |
|---|---|
| 用户要做 MV / 音乐视频 / 动感短片 | YES |
| 用户有主题/歌词/风格偏好 | YES |
| 用户提供了本地音乐文件 | YES |
| 用户只要生成图片 | NO,用 zimage-skill |
| 用户要录屏 / 教程视频 | NO |
| 用户要纯文字动画 | NO |
---
功能特点
- 多风格模板 - 热血动漫、赛博朋克、抒情 MV
- AI 音乐生成 - 集成 Suno,自动生成配乐;也支持本地 MP3
- 智能素材获取 - 素材库(Pexels/Pixabay)优先,AI 生成兜底
- 可视化分镜预览 - 渲染前确认每一个镜头,支持逐镜修改
- 短视频优化 - 9:16 竖屏,50-60 秒,适配抖音/Reels/Shorts
---
Agent 行为步骤
当触发 mv-skill 时,agent 应按以下步骤执行:
1. 理解需求 - 提取用户描述中的:主题、风格、时长偏好、是否有本地音乐 2. 选择模板 - 根据风格关键词匹配最合适的模板(见下方模板表) 3. 生成分镜脚本 - 调用 storyboard_generator.py 生成场景列表 4. 获取/生成音乐 - 优先使用用户提供的音乐;否则调用 Suno API 生成;失败则使用占位音频 5. 获取素材 - 调用 asset_manager.py 从素材库下载图片/视频片段 6. 展示分镜预览 - 以文字表格形式展示各场景(场景名、时长、描述、素材) 7. 等待确认 - 询问用户是否确认,或需要修改哪个场景 8. 渲染输出 - 用户确认后调用 renderer.py,输出 MP4 到 output/ 目录 9. 报告结果 - 告知输出文件路径,提示可以进一步修改
如果用户在初始描述中已经足够清晰,可以跳过第 7 步直接渲染,渲染完成后展示结果。
---
快速开始
最简用法
"帮我做一个热血风格的 MV,主题是宇宙战士"指定更多细节
"制作一个赛博朋克风格的 MV
主题:霓虹都市的孤独黑客
歌词:在数据的海洋里漂流
时长:55 秒"使用本地音乐
"用我的音乐文件 /tmp/my_song.mp3 做一个抒情 MV,歌词是'代码里寻找诗意'"---
可用风格模板
| 风格 | 模板 ID | 关键词 | 适合场景 |
|---|---|---|---|
| 热血动漫 | anime-hype | 热血、动漫、燃、战斗、励志 | 战斗、励志、运动 |
| 赛博朋克 | cyberpunk | 赛博、科技、霓虹、未来、黑客 | 科幻、电子音乐 |
| 抒情 MV | lyric | 抒情、唯美、治愈、情歌、慢歌 | 情歌、慢歌、叙事 |
---
工作流程示例
示例 1:热血动漫风
用户: "做一个圣斗士星矢风格的 MV,主题是小宇宙觉醒"
AI: 正在生成分镜预览...
┌─────────────────────────────────────────────┐
│ 分镜预览 - 小宇宙觉醒 │
├────────┬──────┬────────────────────────────┤
│ 场景 │ 时长 │ 描述 │
├────────┼──────┼────────────────────────────┤
│ 标题 │ 5s │ 片名特效,粒子爆炸 │
│ 战士登场│ 10s │ 主角剪影,逆光慢镜头 │
│ 蓄力 │ 15s │ 能量聚集,环境震动 │
│ 爆发 │ 15s │ 全力爆发,高速剪辑 │
│ 升华 │ 10s │ 胜利定格,慢动作 │
│ 结尾 │ 5s │ 片尾 Logo │
└────────┴──────┴────────────────────────────┘
确认开始渲染?[确认 / 修改 Scene X / 重新生成]
用户: "确认"
AI: 渲染完成!
输出: ~/.claude/skills/mv-skill/output/小宇宙觉醒_1080x1920.mp4示例 2:逐镜修改
用户: "修改 Scene 3,换成我自己的图片 hero.png"
AI: 已更新 Scene 3 素材为 hero.png,预览已刷新。是否重新渲染?示例 3:使用本地音乐
用户: "使用这个音乐文件 /path/to/music.mp3 制作 MV"
AI: 已检测音乐 BPM: 128,时长: 58秒,正在生成匹配的分镜...---
首次配置
必需依赖
# 安装 Remotion 依赖
cd ~/.claude/skills/mv-skill/remotion && npm install
# 安装 Python 依赖
pip install requests librosa pydub pyyaml pillowAPI Keys(按需配置)
# 写入 ~/.zshrc 或 ~/.bashrc
export SUNO_API_KEY="your_key" # AI 音乐生成(可选)
export PEXELS_API_KEY="your_key" # 高质量素材(推荐)
export PIXABAY_API_KEY="your_key" # 备用素材源(可选)---
输出位置
~/.claude/skills/mv-skill/output/
└── {标题}_{分辨率}.mp4---
常见问题
Q: 音乐生成失败怎么办? A: 上传本地 MP3 文件代替:"使用这个音乐文件 /path/to/music.mp3"
Q: 素材质量不满意? A: 在预览阶段说 "Scene 2 换一张" 或提供自己的素材路径
Q: 渲染太慢? A: 首次渲染需要下载 npm 依赖(约 1-2 分钟),后续会快很多
Q: 如何添加自定义歌词? A: 在描述中包含歌词,如 "歌词:燃烧吧小宇宙"
Q: 支持横屏吗? A: 当前默认 9:16 竖屏(1080x1920);如需横屏请在描述中说明
---
技术架构
mv-skill/
├── SKILL.md # 本文档
├── evals/ # 测试用例
│ └── evals.json
├── templates/ # MV 风格模板
│ ├── anime-hype/ # 热血动漫风(template.yaml + 素材配置)
│ ├── cyberpunk/ # 赛博朋克风
│ └── lyric/ # 抒情风
├── schemas/ # 分镜脚本 JSON Schema
├── scripts/ # 核心 Python 脚本
│ ├── cli.py # 入口:解析用户输入,协调各模块
│ ├── storyboard_generator.py # 生成分镜脚本
│ ├── asset_manager.py # 素材获取(素材库 + AI 生成)
│ ├── music_generator.py # Suno AI 音乐生成
│ ├── music_analyzer.py # 本地音乐 BPM/时长分析
│ └── renderer.py # 调用 Remotion 渲染输出 MP4
└── remotion/ # Remotion 项目(TypeScript/React)
├── src/
│ ├── Root.tsx # 注册所有 Composition
│ ├── compositions/ # 各风格 Composition
│ └── components/ # 可复用场景组件
└── package.json数据流
用户描述
→ cli.py(参数解析)
→ storyboard_generator.py(生成分镜 JSON)
→ asset_manager.py(素材获取)+ music_generator.py(音乐生成)
→ [用户确认分镜]
→ renderer.py → Remotion CLI
→ output/*.mp4---
依赖的 Skills
remotion-best-practices- Remotion 视频制作知识库zimage-skill- AI 图像生成(素材兜底)media-downloader- 素材库下载(Pexels/Pixabay)
{
"skill_name": "mv-skill",
"evals": [
{
"id": 1,
"prompt": "帮我做一个热血动漫风格的 MV,主题是程序员深夜 debug,背景音乐要紧张刺激",
"expected_output": "生成分镜预览,包含场景描述、时长分配、音乐风格建议,最终输出 MP4 文件路径",
"files": []
},
{
"id": 2,
"prompt": "I want to create a music video about my startup journey, cyberpunk style, around 60 seconds",
"expected_output": "Storyboard preview with scene breakdown, music generation prompt, final MP4 output",
"files": []
},
{
"id": 3,
"prompt": "用我自己的音乐文件 /tmp/my_song.mp3 做一个抒情风格的 MV,歌词是'在代码的海洋里寻找诗意'",
"expected_output": "使用本地音乐文件,分析 BPM,生成匹配分镜,输出 MP4",
"files": []
}
]
}
{
"name": "mv-skill-remotion",
"version": "1.0.0",
"description": "Remotion project for MV Skill",
"scripts": {
"start": "remotion studio",
"build": "remotion render MVComposition out/video.mp4",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/player": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.0.0"
}
}
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
import { interpolate, Easing } from "remotion";
import { AnimationType } from "../../types";
/**
* 应用动画效果并返回 CSS 样式
*/
export function applyAnimation(
animation: AnimationType,
frame: number,
fps: number,
durationInFrames: number
): React.CSSProperties {
const progress = frame / durationInFrames;
switch (animation) {
case "zoom-in":
return {
transform: `scale(${interpolate(progress, [0, 1], [1, 1.3])})`,
};
case "zoom-out":
return {
transform: `scale(${interpolate(progress, [0, 1], [1.3, 1])})`,
};
case "pan-left":
return {
transform: `translateX(${interpolate(progress, [0, 1], [5, -5])}%)`,
};
case "pan-right":
return {
transform: `translateX(${interpolate(progress, [0, 1], [-5, 5])}%)`,
};
case "pan-up":
return {
transform: `translateY(${interpolate(progress, [0, 1], [5, -5])}%)`,
};
case "pan-down":
return {
transform: `translateY(${interpolate(progress, [0, 1], [-5, 5])}%)`,
};
case "shake":
return applyShake(frame, fps);
case "ken-burns":
return applyKenBurns(progress);
case "rotate":
return {
transform: `rotate(${interpolate(progress, [0, 1], [0, 5])}deg) scale(1.1)`,
};
case "flash":
case "speed-lines":
case "none":
default:
return {};
}
}
/**
* 震动效果
*/
function applyShake(frame: number, fps: number): React.CSSProperties {
const intensity = 10;
const frequency = 15;
// 使用正弦波创建震动
const phase = (frame / fps) * frequency * Math.PI * 2;
const offsetX = Math.sin(phase) * intensity;
const offsetY = Math.cos(phase * 1.3) * intensity * 0.7;
// 随时间衰减
const decay = Math.exp(-frame / (fps * 2));
return {
transform: `translate(${offsetX * decay}px, ${offsetY * decay}px)`,
};
}
/**
* Ken Burns 效果(缓慢推移)
*/
function applyKenBurns(progress: number): React.CSSProperties {
const scale = interpolate(progress, [0, 1], [1, 1.15], {
easing: Easing.inOut(Easing.ease),
});
const translateX = interpolate(progress, [0, 1], [0, 3], {
easing: Easing.inOut(Easing.ease),
});
return {
transform: `scale(${scale}) translateX(${translateX}%)`,
};
}
/**
* 速度线效果参数
*/
export interface SpeedLinesConfig {
count: number;
speed: number;
color: string;
angle: number;
}
export const defaultSpeedLinesConfig: SpeedLinesConfig = {
count: 20,
speed: 2000,
color: "#ffffff",
angle: 0,
};
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
} from "remotion";
import { TransitionType, StyleConfig } from "../../types";
interface TransitionOverlayProps {
type: TransitionType;
durationFrames: number;
styleConfig: StyleConfig;
}
export const TransitionOverlay: React.FC<TransitionOverlayProps> = ({
type,
durationFrames,
styleConfig,
}) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// 转场发生在场景结束前
const transitionStart = durationInFrames - durationFrames;
const localFrame = frame - transitionStart;
if (frame < transitionStart) {
return null;
}
const progress = localFrame / durationFrames;
switch (type) {
case "fade":
return <FadeTransition progress={progress} />;
case "cross-fade":
return <CrossFadeTransition progress={progress} />;
case "flash":
return <FlashTransition progress={progress} color="#ffffff" />;
case "impact-frame":
return (
<ImpactFrameTransition
progress={progress}
colors={[styleConfig.primaryColor, styleConfig.secondaryColor, "#ffffff"]}
/>
);
case "wipe-diagonal":
return <WipeDiagonalTransition progress={progress} />;
case "glitch":
return <GlitchTransition progress={progress} />;
default:
return null;
}
};
const FadeTransition: React.FC<{ progress: number }> = ({ progress }) => {
const opacity = interpolate(progress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
backgroundColor: "#000000",
opacity,
}}
/>
);
};
const CrossFadeTransition: React.FC<{ progress: number }> = ({ progress }) => {
const opacity = interpolate(progress, [0, 0.5, 1], [0, 0.5, 0]);
return (
<AbsoluteFill
style={{
backgroundColor: "#000000",
opacity,
}}
/>
);
};
interface FlashTransitionProps {
progress: number;
color: string;
}
const FlashTransition: React.FC<FlashTransitionProps> = ({ progress, color }) => {
// 快速闪白然后消失
const opacity = interpolate(
progress,
[0, 0.1, 0.3, 1],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
backgroundColor: color,
opacity,
}}
/>
);
};
interface ImpactFrameTransitionProps {
progress: number;
colors: string[];
}
const ImpactFrameTransition: React.FC<ImpactFrameTransitionProps> = ({
progress,
colors,
}) => {
// 多帧冲击效果
const frameIndex = Math.floor(progress * colors.length * 2);
const showFrame = frameIndex < colors.length;
if (!showFrame) {
return null;
}
const colorIndex = frameIndex % colors.length;
return (
<AbsoluteFill
style={{
backgroundColor: colors[colorIndex],
}}
/>
);
};
const WipeDiagonalTransition: React.FC<{ progress: number }> = ({ progress }) => {
const clipPath = `polygon(
${interpolate(progress, [0, 1], [100, 0])}% 0%,
100% 0%,
100% 100%,
${interpolate(progress, [0, 1], [100, 0])}% 100%
)`;
return (
<AbsoluteFill
style={{
backgroundColor: "#000000",
clipPath,
}}
/>
);
};
const GlitchTransition: React.FC<{ progress: number }> = ({ progress }) => {
const glitchIntensity = interpolate(
progress,
[0, 0.5, 1],
[0, 1, 0]
);
// 简单的故障效果:随机位移条纹
const stripes = Array.from({ length: 10 }, (_, i) => {
const y = i * 10;
const offset = Math.sin(progress * Math.PI * 10 + i) * glitchIntensity * 20;
return (
<div
key={i}
style={{
position: "absolute",
left: offset,
top: `${y}%`,
width: "100%",
height: "10%",
backgroundColor: i % 2 === 0 ? "rgba(255,0,0,0.3)" : "rgba(0,255,255,0.3)",
mixBlendMode: "difference",
}}
/>
);
});
if (glitchIntensity < 0.1) {
return null;
}
return <AbsoluteFill>{stripes}</AbsoluteFill>;
};
import React from "react";
import {
AbsoluteFill,
Img,
Video,
useCurrentFrame,
useVideoConfig,
interpolate,
staticFile,
} from "remotion";
import { Visual, AnimationType, StyleConfig } from "../../types";
import { applyAnimation } from "../animations/animationUtils";
interface ActionSceneProps {
visual: Visual;
animation: AnimationType;
lyrics?: string;
beatSync?: boolean;
beats: number[];
bpm: number;
styleConfig: StyleConfig;
}
export const ActionScene: React.FC<ActionSceneProps> = ({
visual,
animation,
lyrics,
beatSync,
beats,
bpm,
styleConfig,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// 节拍同步缩放
const beatScale = beatSync ? calculateBeatScale(frame, fps, bpm) : 1;
// 应用动画
const animationStyle = applyAnimation(animation, frame, fps, durationInFrames);
// 判断是图片还是视频
const isVideo = visual.media_type === "video" ||
(visual.file && (visual.file.endsWith(".mp4") || visual.file.endsWith(".mov")));
// 获取媒体文件路径
const mediaSrc = visual.file ? staticFile(visual.file) : null;
return (
<AbsoluteFill>
{/* 背景媒体 */}
{mediaSrc && (
<AbsoluteFill
style={{
...animationStyle,
transform: `${animationStyle.transform || ""} scale(${beatScale})`,
}}
>
{isVideo ? (
<Video
src={mediaSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<Img
src={mediaSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
</AbsoluteFill>
)}
{/* 歌词叠加 */}
{lyrics && (
<LyricsOverlay
text={lyrics}
frame={frame}
fps={fps}
styleConfig={styleConfig}
/>
)}
</AbsoluteFill>
);
};
interface LyricsOverlayProps {
text: string;
frame: number;
fps: number;
styleConfig: StyleConfig;
}
const LyricsOverlay: React.FC<LyricsOverlayProps> = ({
text,
frame,
fps,
styleConfig,
}) => {
// 歌词淡入
const opacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// 轻微上浮效果
const translateY = interpolate(frame, [0, fps * 0.5], [20, 0], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 150,
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
fontSize: 48,
fontWeight: "bold",
color: styleConfig.textColor,
fontFamily: styleConfig.fontFamily,
textAlign: "center",
textShadow: `
0 0 10px ${styleConfig.primaryColor},
2px 2px 4px rgba(0,0,0,0.9)
`,
padding: "0 40px",
maxWidth: "90%",
}}
>
{text}
</div>
</AbsoluteFill>
);
};
/**
* 计算节拍同步缩放
*/
function calculateBeatScale(frame: number, fps: number, bpm: number): number {
const beatInterval = (60 / bpm) * fps; // 每拍的帧数
const beatPhase = (frame % beatInterval) / beatInterval;
// 在节拍点有轻微放大效果
const scale = interpolate(
beatPhase,
[0, 0.1, 0.5, 1],
[1.05, 1.02, 1, 1],
{ extrapolateRight: "clamp" }
);
return scale;
}
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
spring,
} from "remotion";
// 辅助函数:平滑的脉冲动画(替代 Math.sin)
const smoothPulse = (frame: number, fps: number, frequency: number = 1): number => {
const time = frame / fps;
return interpolate(
Math.sin(time * frequency * Math.PI * 2),
[-1, 1],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
};
// 辅助函数:弹性缩放动画
const springScale = (frame: number, fps: number, delay: number = 0): number => {
return spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 100 },
});
};
interface DJSceneProps {
visualType: string;
colorScheme: {
primary: string;
secondary: string;
accent: string;
background: string;
};
intensity: number;
songTitle?: string;
}
export const DJScene: React.FC<DJSceneProps> = ({
visualType,
colorScheme,
intensity = 1,
songTitle,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// 根据类型渲染不同的视觉效果
const renderVisual = () => {
switch (visualType) {
case "nightclub":
return <NightclubVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "festival":
return <FestivalVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "lasershow":
return <LasershowVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "djbooth":
return <DJBoothVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "crowd":
return <CrowdVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "waveform":
return <WaveformVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "spectrum":
return <SpectrumVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "particles":
return <ParticleVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "geometric":
return <GeometricVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "tunnel":
return <TunnelVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "ledwall":
return <LEDWallVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "mirrorball":
return <MirrorBallVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "smokelaser":
return <SmokeAndLaserVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "strobevj":
return <StrobeVJVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "cube3d":
return <Cube3DVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "vortex3d":
return <Vortex3DVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "neonroom":
return <NeonRoomVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "cyberwave":
return <CyberwaveVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
case "hologram":
return <HologramVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
default:
return <Vortex3DVisual frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />;
}
};
return (
<AbsoluteFill style={{ backgroundColor: colorScheme.background }}>
{renderVisual()}
{/* 3D 多彩迷雾烟尘效果 */}
<ColorfulFog3D frame={frame} fps={fps} colors={colorScheme} intensity={intensity} />
{songTitle && (
<SongTitleOverlay title={songTitle} frame={frame} fps={fps} colors={colorScheme} />
)}
</AbsoluteFill>
);
};
// 3D 多彩迷雾烟尘效果(简化版)
const ColorfulFog3D: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", pointerEvents: "none" }}>
{/* 底部迷雾层 */}
{Array.from({ length: 3 }).map((_, i) => {
const hue = (i * 120 + time * 15) % 360;
const yOffset = Math.sin(time * 0.4 + i * 0.8) * 3;
const opacity = interpolate(
Math.sin(time * 0.6 + i),
[-1, 1],
[0.2, 0.4],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
key={`fog-bottom-${i}`}
style={{
position: "absolute",
bottom: `${-5 + i * 10 + yOffset}%`,
left: "-10%",
width: "120%",
height: "25%",
background: `radial-gradient(ellipse at 50% 100%, hsla(${hue}, 80%, 50%, ${opacity}), transparent 70%)`,
filter: "blur(30px)",
}}
/>
);
})}
{/* 顶部迷雾层 */}
{Array.from({ length: 2 }).map((_, i) => {
const hue = (i * 180 + time * 20 + 90) % 360;
const yOffset = Math.sin(time * 0.3 + i * 0.5) * 3;
const opacity = interpolate(
Math.sin(time * 0.5 + i * 1.2),
[-1, 1],
[0.15, 0.3],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
key={`fog-top-${i}`}
style={{
position: "absolute",
top: `${-10 + i * 8 + yOffset}%`,
left: "-5%",
width: "110%",
height: "20%",
background: `radial-gradient(ellipse at 50% 0%, hsla(${hue}, 70%, 55%, ${opacity}), transparent 70%)`,
filter: "blur(25px)",
}}
/>
);
})}
{/* 侧边飘动的烟尘 */}
{Array.from({ length: 4 }).map((_, i) => {
const side = i % 2 === 0 ? "left" : "right";
const hue = (i * 90 + time * 25) % 360;
const yPos = 20 + i * 20 + Math.sin(time * 0.4 + i) * 10;
const opacity = interpolate(
Math.sin(time * 0.5 + i * 0.7),
[-1, 1],
[0.1, 0.25],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
key={`fog-side-${i}`}
style={{
position: "absolute",
top: `${yPos}%`,
[side]: "-10%",
width: "35%",
height: "18%",
background: `radial-gradient(ellipse, hsla(${hue}, 75%, 55%, ${opacity}), transparent 70%)`,
filter: "blur(25px)",
}}
/>
);
})}
</AbsoluteFill>
);
};
// 夜店场景
const NightclubVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden" }}>
{/* 深色背景渐变 */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `radial-gradient(ellipse at 50% 100%, ${colors.primary}33 0%, ${colors.background} 70%)`,
}}
/>
{/* 舞池地板反光 */}
<div
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "40%",
background: `linear-gradient(to top, ${colors.primary}22, transparent)`,
transform: `perspective(500px) rotateX(60deg)`,
transformOrigin: "bottom center",
}}
>
{/* 地板格子 */}
{Array.from({ length: 8 }).map((_, i) =>
Array.from({ length: 6 }).map((_, j) => {
const pulse = Math.sin(time * 4 + i * 0.5 + j * 0.3) > 0.3;
return (
<div
key={`${i}-${j}`}
style={{
position: "absolute",
left: `${i * 12.5}%`,
top: `${j * 16.6}%`,
width: "12%",
height: "15%",
background: pulse ? colors.primary : colors.secondary,
opacity: pulse ? 0.8 : 0.2,
boxShadow: pulse ? `0 0 30px ${colors.primary}` : "none",
transition: "all 0.1s",
}}
/>
);
})
)}
</div>
{/* 激光束 */}
{Array.from({ length: 6 }).map((_, i) => {
const angle = Math.sin(time * 2 + i) * 30;
const hue = (i * 60 + time * 50) % 360;
return (
<div
key={i}
style={{
position: "absolute",
top: "10%",
left: `${15 + i * 14}%`,
width: 4,
height: "60%",
background: `linear-gradient(to bottom, hsl(${hue}, 100%, 60%), transparent)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "top center",
boxShadow: `0 0 20px hsl(${hue}, 100%, 50%)`,
opacity: 0.8 * intensity,
}}
/>
);
})}
{/* 闪光灯效果 */}
{Math.sin(time * 8) > 0.7 && (
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `radial-gradient(circle at 50% 20%, ${colors.accent}88, transparent 50%)`,
}}
/>
)}
{/* 烟雾效果 */}
<div
style={{
position: "absolute",
width: "200%",
height: "100%",
background: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.02' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
opacity: 0.15,
transform: `translateX(${-time * 20 % 100}px)`,
}}
/>
</AbsoluteFill>
);
};
// 露天音乐节场景
const FestivalVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden" }}>
{/* 夜空背景 */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `linear-gradient(to bottom, #0a0a20 0%, #1a1a40 50%, ${colors.background} 100%)`,
}}
/>
{/* 星星 */}
{Array.from({ length: 50 }).map((_, i) => {
const x = (i * 37) % 100;
const y = (i * 23) % 40;
const twinkle = Math.sin(time * 3 + i) * 0.5 + 0.5;
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: 2 + (i % 3),
height: 2 + (i % 3),
borderRadius: "50%",
background: "#fff",
opacity: twinkle * 0.8,
boxShadow: `0 0 ${4 + twinkle * 4}px #fff`,
}}
/>
);
})}
{/* 舞台灯光 */}
{Array.from({ length: 8 }).map((_, i) => {
const angle = Math.sin(time * 1.5 + i * 0.8) * 40;
const hue = (i * 45 + time * 30) % 360;
return (
<div
key={i}
style={{
position: "absolute",
bottom: "30%",
left: `${5 + i * 12}%`,
width: 100,
height: 400,
background: `linear-gradient(to top, hsl(${hue}, 100%, 50%) 0%, transparent 100%)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "bottom center",
opacity: 0.4 * intensity,
filter: "blur(10px)",
}}
/>
);
})}
{/* 舞台轮廓 */}
<div
style={{
position: "absolute",
bottom: "25%",
left: "10%",
width: "80%",
height: "8%",
background: `linear-gradient(to top, ${colors.primary}44, transparent)`,
borderTop: `2px solid ${colors.primary}`,
boxShadow: `0 -10px 40px ${colors.primary}66`,
}}
/>
{/* 人群剪影 */}
<div
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "30%",
background: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 30'%3E%3Cpath d='M0,30 Q5,${20 + Math.sin(time * 4) * 5} 10,30 Q15,${18 + Math.sin(time * 4 + 1) * 5} 20,30 Q25,${22 + Math.sin(time * 4 + 2) * 5} 30,30 Q35,${19 + Math.sin(time * 4 + 3) * 5} 40,30 Q45,${21 + Math.sin(time * 4 + 4) * 5} 50,30 Q55,${18 + Math.sin(time * 4 + 5) * 5} 60,30 Q65,${23 + Math.sin(time * 4 + 6) * 5} 70,30 Q75,${20 + Math.sin(time * 4 + 7) * 5} 80,30 Q85,${19 + Math.sin(time * 4 + 8) * 5} 90,30 Q95,${22 + Math.sin(time * 4 + 9) * 5} 100,30 L100,30 L0,30 Z' fill='%23000'/%3E%3C/svg%3E") repeat-x`,
backgroundSize: "200px 100%",
opacity: 0.9,
}}
/>
{/* 手机闪光灯 */}
{Array.from({ length: 20 }).map((_, i) => {
const x = (i * 47 + time * 10) % 100;
const y = 70 + (i % 5) * 5;
const on = Math.sin(time * 5 + i * 2) > 0.6;
return on ? (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: 4,
height: 4,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 10px #fff",
}}
/>
) : null;
})}
</AbsoluteFill>
);
};
// 激光秀
const LasershowVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 中心光源 */}
<div
style={{
position: "absolute",
top: "20%",
left: "50%",
width: 20,
height: 20,
borderRadius: "50%",
background: colors.accent,
boxShadow: `0 0 60px 30px ${colors.accent}`,
transform: "translateX(-50%)",
}}
/>
{/* 激光束扇形 */}
{Array.from({ length: 12 }).map((_, i) => {
const baseAngle = (i / 12) * 360;
const angle = baseAngle + Math.sin(time * 2 + i * 0.5) * 20;
const length = 800 + Math.sin(time * 3 + i) * 200;
const hue = (baseAngle + time * 60) % 360;
return (
<div
key={i}
style={{
position: "absolute",
top: "20%",
left: "50%",
width: 3,
height: length,
background: `linear-gradient(to bottom, hsl(${hue}, 100%, 60%), transparent)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "top center",
boxShadow: `0 0 15px hsl(${hue}, 100%, 50%)`,
opacity: 0.7 * intensity,
}}
/>
);
})}
{/* 扫描激光 */}
{Array.from({ length: 4 }).map((_, i) => {
const sweep = (time * 100 + i * 90) % 360;
return (
<div
key={`sweep-${i}`}
style={{
position: "absolute",
top: "20%",
left: "50%",
width: 2,
height: 1000,
background: `linear-gradient(to bottom, ${colors.primary}, transparent)`,
transform: `rotate(${sweep}deg)`,
transformOrigin: "top center",
opacity: 0.5,
}}
/>
);
})}
{/* 烟雾中的光束散射 */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `radial-gradient(ellipse at 50% 20%, ${colors.primary}22, transparent 60%)`,
}}
/>
</AbsoluteFill>
);
};
// DJ 台场景
const DJBoothVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 背景光效 */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `radial-gradient(ellipse at 50% 30%, ${colors.primary}33, transparent 70%)`,
}}
/>
{/* DJ 台轮廓 */}
<div
style={{
position: "absolute",
bottom: "20%",
left: "20%",
width: "60%",
height: "35%",
background: `linear-gradient(to top, ${colors.background}, #1a1a2a)`,
borderRadius: "10px 10px 0 0",
border: `2px solid ${colors.primary}44`,
boxShadow: `0 0 30px ${colors.primary}33`,
}}
>
{/* 设备面板 */}
<div
style={{
position: "absolute",
top: "10%",
left: "5%",
width: "90%",
height: "60%",
display: "flex",
justifyContent: "space-around",
alignItems: "center",
}}
>
{/* 左转盘 */}
<div
style={{
width: 120,
height: 120,
borderRadius: "50%",
background: `conic-gradient(from ${time * 180}deg, #333, #111, #333, #111, #333)`,
border: `3px solid ${colors.secondary}`,
boxShadow: `0 0 20px ${colors.secondary}44`,
}}
/>
{/* 混音台 */}
<div
style={{
width: 100,
height: 140,
background: "#1a1a1a",
borderRadius: 5,
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
alignItems: "center",
padding: 10,
}}
>
{Array.from({ length: 4 }).map((_, i) => {
// 使用确定性伪随机替代 Math.random()
const pseudoRandom = Math.sin(i * 43.758 + time * 5) * 0.5 + 0.5;
const level = interpolate(
Math.sin(time * 6 + i * 2) * 0.5 + 0.5 + pseudoRandom * 0.3,
[0, 1.3],
[20, 90],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
key={i}
style={{
width: "80%",
height: 15,
background: "#333",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${level}%`,
height: "100%",
background: `linear-gradient(to right, ${colors.secondary}, ${colors.primary})`,
boxShadow: `0 0 10px ${colors.primary}`,
}}
/>
</div>
);
})}
</div>
{/* 右转盘 */}
<div
style={{
width: 120,
height: 120,
borderRadius: "50%",
background: `conic-gradient(from ${-time * 180}deg, #333, #111, #333, #111, #333)`,
border: `3px solid ${colors.secondary}`,
boxShadow: `0 0 20px ${colors.secondary}44`,
}}
/>
</div>
</div>
{/* 顶部灯光 */}
{Array.from({ length: 5 }).map((_, i) => {
const pulse = Math.sin(time * 4 + i) > 0;
return (
<div
key={i}
style={{
position: "absolute",
top: "5%",
left: `${15 + i * 17}%`,
width: 30,
height: 200,
background: `linear-gradient(to bottom, ${pulse ? colors.primary : colors.secondary}, transparent)`,
opacity: pulse ? 0.6 : 0.2,
filter: "blur(5px)",
}}
/>
);
})}
</AbsoluteFill>
);
};
// 人群狂欢
const CrowdVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 舞台灯光 */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: "50%",
background: `radial-gradient(ellipse at 50% 0%, ${colors.primary}66, transparent 70%)`,
}}
/>
{/* 频闪效果 */}
{Math.sin(time * 10) > 0.8 && (
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `rgba(255,255,255,0.3)`,
}}
/>
)}
{/* 人群 - 多层 */}
{[0.6, 0.75, 0.9].map((yPos, layer) => (
<div
key={layer}
style={{
position: "absolute",
bottom: `${(1 - yPos) * 100}%`,
width: "100%",
height: `${yPos * 50}%`,
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
}}
>
{Array.from({ length: 15 + layer * 5 }).map((_, i) => {
const jump = Math.sin(time * 6 + i * 0.5 + layer) * (10 + layer * 5);
const sway = Math.sin(time * 3 + i * 0.3) * 5;
const height = 60 + (i % 3) * 20 - layer * 10;
return (
<div
key={i}
style={{
width: 20 + layer * 5,
height: height,
background: `linear-gradient(to top, #000, #222)`,
borderRadius: "50% 50% 0 0",
margin: `0 ${2 + layer}px`,
transform: `translateY(${-Math.max(0, jump)}px) translateX(${sway}px)`,
}}
>
{/* 手臂 */}
{jump > 5 && (
<>
<div
style={{
position: "absolute",
top: 10,
left: -10,
width: 4,
height: 30,
background: "#000",
transform: `rotate(${-30 + sway}deg)`,
transformOrigin: "bottom right",
}}
/>
<div
style={{
position: "absolute",
top: 10,
right: -10,
width: 4,
height: 30,
background: "#000",
transform: `rotate(${30 - sway}deg)`,
transformOrigin: "bottom left",
}}
/>
</>
)}
</div>
);
})}
</div>
))}
{/* 彩带/纸屑 */}
{Array.from({ length: 30 }).map((_, i) => {
const x = (i * 37 + time * 50) % 120 - 10;
const y = (i * 23 + time * 100) % 120 - 10;
const hue = (i * 30) % 360;
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: 8,
height: 3,
background: `hsl(${hue}, 100%, 60%)`,
transform: `rotate(${time * 200 + i * 45}deg)`,
opacity: 0.7,
}}
/>
);
})}
</AbsoluteFill>
);
};
// 波形可视化
const WaveformVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const bars = 40;
const time = frame / fps;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, height: "60%" }}>
{Array.from({ length: bars }).map((_, i) => {
// 模拟音频波形
const phase = (i / bars) * Math.PI * 4 + time * 8;
const height = 50 + Math.sin(phase) * 40 * intensity + Math.sin(phase * 2.5) * 20;
return (
<div
key={i}
style={{
width: 12,
height: `${height}%`,
background: `linear-gradient(to top, ${colors.primary}, ${colors.secondary})`,
borderRadius: 6,
boxShadow: `0 0 20px ${colors.primary}`,
transform: `scaleY(${0.8 + Math.sin(phase + time * 4) * 0.2})`,
}}
/>
);
})}
</div>
</AbsoluteFill>
);
};
// 频谱可视化
const SpectrumVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const bars = 64;
const time = frame / fps;
return (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 100 }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: "70%" }}>
{Array.from({ length: bars }).map((_, i) => {
// 模拟频谱分析 - 使用确定性动画避免闪烁
const freq = i / bars;
const bassBoost = freq < 0.2 ? 1.5 : 1;
// 使用多个正弦波叠加模拟随机感,但保持确定性
const pseudoRandom = Math.sin(i * 12.9898 + time * 3) * 0.5 + 0.5;
const height = interpolate(
(Math.sin(time * 6 + i * 0.3) * 0.5 + 0.5) * 0.5 +
(Math.sin(time * 12 + i * 0.5) * 0.5 + 0.5) * 0.3 +
pseudoRandom * 0.2,
[0, 1],
[15, 85],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
) * intensity * bassBoost;
const hue = (i / bars) * 60 + 280; // 紫色到粉色渐变
return (
<div
key={i}
style={{
width: 8,
height: `${Math.max(5, height)}%`,
background: `hsl(${hue}, 100%, 60%)`,
borderRadius: "4px 4px 0 0",
boxShadow: `0 0 15px hsl(${hue}, 100%, 50%)`,
// 移除 CSS transition - Remotion 不支持
}}
/>
);
})}
</div>
</AbsoluteFill>
);
};
// 粒子效果
const ParticleVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const particleCount = 50;
const time = frame / fps;
return (
<AbsoluteFill>
{Array.from({ length: particleCount }).map((_, i) => {
const seed = i * 137.5;
const x = ((seed * 7.3) % 100);
const baseY = ((seed * 13.7) % 100);
const y = (baseY + time * (20 + (seed % 30))) % 120 - 10;
const size = 4 + (seed % 12);
const opacity = 0.3 + (Math.sin(time * 3 + seed) + 1) * 0.35;
const hue = (seed % 60) + 280;
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: size,
height: size,
borderRadius: "50%",
background: `hsl(${hue}, 100%, 70%)`,
opacity: opacity * intensity,
boxShadow: `0 0 ${size * 2}px hsl(${hue}, 100%, 60%)`,
transform: `scale(${1 + Math.sin(time * 5 + seed) * 0.3})`,
}}
/>
);
})}
</AbsoluteFill>
);
};
// 几何图形动画
const GeometricVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const shapes = 8;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{Array.from({ length: shapes }).map((_, i) => {
const angle = (i / shapes) * Math.PI * 2 + time * 0.5;
const radius = 200 + Math.sin(time * 2 + i) * 50;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const rotation = time * 60 + i * 45;
const scale = 0.8 + Math.sin(time * 3 + i * 0.5) * 0.3;
return (
<div
key={i}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: 80,
height: 80,
border: `3px solid ${colors.primary}`,
transform: `translate(${x}px, ${y}px) rotate(${rotation}deg) scale(${scale * intensity})`,
boxShadow: `0 0 30px ${colors.primary}, inset 0 0 20px ${colors.secondary}`,
}}
/>
);
})}
{/* 中心圆环 */}
<div
style={{
width: 150,
height: 150,
borderRadius: "50%",
border: `4px solid ${colors.secondary}`,
boxShadow: `0 0 40px ${colors.secondary}, 0 0 80px ${colors.primary}`,
transform: `scale(${1 + Math.sin(time * 4) * 0.2 * intensity})`,
}}
/>
</AbsoluteFill>
);
};
// 霓虹灯效果
const NeonVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{/* 霓虹网格 */}
<div
style={{
position: "absolute",
width: "200%",
height: "200%",
backgroundImage: `
linear-gradient(${colors.primary}22 1px, transparent 1px),
linear-gradient(90deg, ${colors.primary}22 1px, transparent 1px)
`,
backgroundSize: "50px 50px",
transform: `perspective(500px) rotateX(60deg) translateY(${-time * 100 % 50}px)`,
transformOrigin: "center center",
}}
/>
{/* 霓虹圆环 */}
{[1, 2, 3].map((ring) => (
<div
key={ring}
style={{
position: "absolute",
width: 200 + ring * 100,
height: 200 + ring * 100,
borderRadius: "50%",
border: `2px solid ${ring % 2 === 0 ? colors.primary : colors.secondary}`,
opacity: 0.6 - ring * 0.15,
boxShadow: `0 0 30px ${ring % 2 === 0 ? colors.primary : colors.secondary}`,
transform: `scale(${1 + Math.sin(time * 2 + ring) * 0.1 * intensity})`,
}}
/>
))}
{/* 中心光点 */}
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: colors.accent,
boxShadow: `0 0 60px 30px ${colors.accent}`,
transform: `scale(${1 + Math.sin(time * 6) * 0.5 * intensity})`,
}}
/>
</AbsoluteFill>
);
};
// 隧道效果
const TunnelVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const rings = 15;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
{Array.from({ length: rings }).map((_, i) => {
const progress = ((time * 0.5 + i / rings) % 1);
const scale = 0.1 + progress * 3;
const opacity = 1 - progress;
const rotation = time * 30 + i * 24;
return (
<div
key={i}
style={{
position: "absolute",
width: 400,
height: 400,
border: `3px solid ${i % 2 === 0 ? colors.primary : colors.secondary}`,
borderRadius: i % 3 === 0 ? "50%" : "0",
transform: `scale(${scale}) rotate(${rotation}deg)`,
opacity: opacity * intensity,
boxShadow: `0 0 20px ${i % 2 === 0 ? colors.primary : colors.secondary}`,
}}
/>
);
})}
</AbsoluteFill>
);
};
// 歌曲标题叠加 - 使用 spring 动画实现更自然的入场效果
const SongTitleOverlay: React.FC<{
title: string;
frame: number;
fps: number;
colors: any;
}> = ({ title, frame, fps, colors }) => {
// 使用 spring 实现弹性入场
const entranceProgress = spring({
frame,
fps,
config: { damping: 15, stiffness: 100 },
durationInFrames: Math.floor(fps * 0.8),
});
// 淡出动画
const exitProgress = spring({
frame: frame - Math.floor(fps * 2),
fps,
config: { damping: 200 },
durationInFrames: Math.floor(fps * 0.5),
});
const opacity = interpolate(
entranceProgress - exitProgress,
[0, 1],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateY = interpolate(
entranceProgress,
[0, 1],
[50, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const scale = interpolate(
entranceProgress,
[0, 1],
[0.8, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 200,
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px) scale(${scale})`,
fontSize: 48,
fontWeight: "bold",
color: "#fff",
textShadow: `
0 0 20px ${colors.primary},
0 0 40px ${colors.primary},
0 0 60px ${colors.secondary}
`,
fontFamily: "Noto Sans SC, sans-serif",
letterSpacing: "0.1em",
}}
>
{title}
</div>
</AbsoluteFill>
);
};
// LED 屏幕墙效果
const LEDWallVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const cols = 20;
const rows = 35;
return (
<AbsoluteFill style={{ overflow: "hidden", background: "#000" }}>
{/* LED 像素网格 */}
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
width: "100%",
height: "100%",
gap: 2,
padding: 4,
}}
>
{Array.from({ length: cols * rows }).map((_, i) => {
const x = i % cols;
const y = Math.floor(i / cols);
// 波浪效果
const wave1 = Math.sin(time * 4 + x * 0.3 + y * 0.2) * 0.5 + 0.5;
const wave2 = Math.sin(time * 3 - x * 0.2 + y * 0.4) * 0.5 + 0.5;
const wave3 = Math.sin(time * 5 + (x + y) * 0.15) * 0.5 + 0.5;
// 随机闪烁
const flicker = Math.sin(time * 20 + i * 0.7) > 0.9 ? 1.5 : 1;
const brightness = (wave1 * 0.4 + wave2 * 0.3 + wave3 * 0.3) * intensity * flicker;
const hue = (time * 30 + x * 5 + y * 3) % 360;
return (
<div
key={i}
style={{
background: `hsla(${hue}, 100%, ${50 * brightness}%, ${brightness})`,
boxShadow: brightness > 0.7 ? `0 0 10px hsla(${hue}, 100%, 60%, 0.8)` : "none",
}}
/>
);
})}
</div>
{/* 扫描线效果 */}
<div
style={{
position: "absolute",
top: `${(time * 50) % 100}%`,
left: 0,
width: "100%",
height: 4,
background: `linear-gradient(to right, transparent, ${colors.accent}, transparent)`,
opacity: 0.6,
}}
/>
</AbsoluteFill>
);
};
// 镜球效果
const MirrorBallVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const reflections = 40;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 镜球本体 */}
<div
style={{
position: "absolute",
top: "15%",
left: "50%",
width: 150,
height: 150,
borderRadius: "50%",
background: `radial-gradient(circle at 30% 30%, #fff, #888, #333)`,
transform: `translateX(-50%) rotate(${time * 30}deg)`,
boxShadow: "0 0 50px rgba(255,255,255,0.3)",
}}
>
{/* 镜面格子 */}
{Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
style={{
position: "absolute",
width: "100%",
height: "100%",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.3)",
transform: `rotate(${i * 15}deg)`,
}}
/>
))}
</div>
{/* 反射光束 */}
{Array.from({ length: reflections }).map((_, i) => {
const angle = (i / reflections) * 360 + time * 60;
const length = 300 + Math.sin(time * 3 + i) * 100;
const hue = (i * 20 + time * 40) % 360;
const opacity = (Math.sin(time * 4 + i * 0.5) + 1) * 0.3 * intensity;
return (
<div
key={i}
style={{
position: "absolute",
top: "20%",
left: "50%",
width: 3,
height: length,
background: `linear-gradient(to bottom, hsla(${hue}, 100%, 80%, 0.8), transparent)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "top center",
opacity,
}}
/>
);
})}
{/* 地面光斑 */}
{Array.from({ length: 20 }).map((_, i) => {
const x = (i * 47 + time * 30) % 100;
const y = 60 + (i % 4) * 10;
const size = 20 + Math.sin(time * 5 + i) * 10;
const hue = (i * 30 + time * 50) % 360;
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: size,
height: size * 0.3,
borderRadius: "50%",
background: `hsla(${hue}, 100%, 70%, 0.6)`,
filter: "blur(5px)",
}}
/>
);
})}
</AbsoluteFill>
);
};
// 烟雾机 + 激光效果
const SmokeAndLaserVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 烟雾层 */}
{[0, 1, 2].map((layer) => (
<div
key={layer}
style={{
position: "absolute",
width: "200%",
height: "100%",
background: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.015' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
opacity: 0.2 - layer * 0.05,
transform: `translateX(${(-time * (20 + layer * 10)) % 100}px) translateY(${Math.sin(time + layer) * 20}px)`,
}}
/>
))}
{/* 激光束阵列 */}
{Array.from({ length: 8 }).map((_, i) => {
const baseX = 10 + i * 11;
const angle = Math.sin(time * 2 + i * 0.5) * 25;
const hue = (i * 45 + time * 60) % 360;
const on = Math.sin(time * 3 + i * 0.8) > -0.3;
return on ? (
<div
key={i}
style={{
position: "absolute",
top: "5%",
left: `${baseX}%`,
width: 4,
height: "70%",
background: `linear-gradient(to bottom, hsl(${hue}, 100%, 60%), transparent)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "top center",
boxShadow: `0 0 20px hsl(${hue}, 100%, 50%), 0 0 40px hsl(${hue}, 100%, 40%)`,
opacity: 0.9 * intensity,
}}
/>
) : null;
})}
{/* 交叉激光 */}
{Array.from({ length: 4 }).map((_, i) => {
const angle = (time * 40 + i * 90) % 360;
const hue = (i * 90 + time * 30) % 360;
return (
<div
key={`cross-${i}`}
style={{
position: "absolute",
top: "30%",
left: "50%",
width: 2,
height: 600,
background: `linear-gradient(to bottom, transparent, hsl(${hue}, 100%, 60%), transparent)`,
transform: `rotate(${angle}deg)`,
transformOrigin: "center center",
opacity: 0.5 * intensity,
}}
/>
);
})}
{/* 底部烟雾 */}
<div
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "30%",
background: `linear-gradient(to top, rgba(255,255,255,0.15), transparent)`,
}}
/>
</AbsoluteFill>
);
};
// 频闪 + VJ 效果
const StrobeVJVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
// 频闪节奏
const strobeOn = Math.sin(time * 16) > 0.7;
const fastStrobe = Math.sin(time * 30) > 0.9;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* VJ 图形 - 旋转三角形 */}
{Array.from({ length: 6 }).map((_, i) => {
const rotation = time * 60 + i * 60;
const scale = 0.5 + Math.sin(time * 2 + i) * 0.3;
const hue = (i * 60 + time * 40) % 360;
return (
<div
key={i}
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 0,
height: 0,
borderLeft: "100px solid transparent",
borderRight: "100px solid transparent",
borderBottom: `180px solid hsla(${hue}, 100%, 50%, 0.3)`,
transform: `translate(-50%, -50%) rotate(${rotation}deg) scale(${scale})`,
filter: `drop-shadow(0 0 20px hsl(${hue}, 100%, 50%))`,
}}
/>
);
})}
{/* 脉冲圆环 */}
{Array.from({ length: 5 }).map((_, i) => {
const progress = ((time * 0.8 + i * 0.2) % 1);
const scale = progress * 4;
const opacity = (1 - progress) * 0.6;
return (
<div
key={`pulse-${i}`}
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 100,
height: 100,
borderRadius: "50%",
border: `3px solid ${colors.primary}`,
transform: `translate(-50%, -50%) scale(${scale})`,
opacity: opacity * intensity,
}}
/>
);
})}
{/* 频闪效果 */}
{strobeOn && (
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `rgba(255,255,255,${fastStrobe ? 0.8 : 0.4})`,
}}
/>
)}
{/* 扫描线 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
background: `repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.1) 2px,
rgba(0,0,0,0.1) 4px
)`,
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
// 3D 立方体效果
const Cube3DVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background, perspective: 1000 }}>
{/* 3D 旋转立方体阵列 */}
{Array.from({ length: 9 }).map((_, i) => {
const row = Math.floor(i / 3);
const col = i % 3;
const rotateX = time * 60 + i * 40;
const rotateY = time * 80 + i * 30;
const scale = 0.8 + Math.sin(time * 3 + i) * 0.2;
const hue = (i * 40 + time * 50) % 360;
return (
<div
key={i}
style={{
position: "absolute",
left: `${20 + col * 30}%`,
top: `${20 + row * 25}%`,
width: 100,
height: 100,
transformStyle: "preserve-3d",
transform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(${scale})`,
}}
>
{/* 立方体六个面 */}
{[0, 1, 2, 3, 4, 5].map((face) => {
const transforms = [
"translateZ(50px)",
"translateZ(-50px) rotateY(180deg)",
"translateX(50px) rotateY(90deg)",
"translateX(-50px) rotateY(-90deg)",
"translateY(-50px) rotateX(90deg)",
"translateY(50px) rotateX(-90deg)",
];
return (
<div
key={face}
style={{
position: "absolute",
width: 100,
height: 100,
background: `hsla(${hue + face * 20}, 100%, 50%, 0.3)`,
border: `2px solid hsl(${hue + face * 20}, 100%, 60%)`,
transform: transforms[face],
boxShadow: `inset 0 0 30px hsla(${hue + face * 20}, 100%, 50%, 0.5)`,
}}
/>
);
})}
</div>
);
})}
{/* 中心发光球 */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 60,
height: 60,
borderRadius: "50%",
background: `radial-gradient(circle, ${colors.accent}, transparent)`,
transform: `translate(-50%, -50%) scale(${1 + Math.sin(time * 6) * 0.3})`,
boxShadow: `0 0 80px 40px ${colors.accent}`,
}}
/>
</AbsoluteFill>
);
};
// 3D 漩涡效果
const Vortex3DVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const rings = 20;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background, perspective: 800 }}>
{/* 3D 漩涡环 */}
{Array.from({ length: rings }).map((_, i) => {
const progress = (i / rings);
const z = -500 + progress * 1000;
const scale = 0.3 + progress * 1.5;
const rotation = time * 120 + i * 18;
const opacity = 0.3 + progress * 0.5;
const hue = (time * 30 + i * 15) % 360;
return (
<div
key={i}
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 300,
height: 300,
borderRadius: "50%",
border: `3px solid hsl(${hue}, 100%, 60%)`,
transform: `translate(-50%, -50%) translateZ(${z}px) rotate(${rotation}deg) scale(${scale})`,
opacity: opacity * intensity,
boxShadow: `0 0 20px hsl(${hue}, 100%, 50%), inset 0 0 20px hsl(${hue}, 100%, 50%)`,
}}
/>
);
})}
{/* 中心光点 */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 20,
height: 20,
borderRadius: "50%",
background: colors.accent,
transform: "translate(-50%, -50%)",
boxShadow: `0 0 100px 50px ${colors.accent}`,
}}
/>
{/* 粒子流 */}
{Array.from({ length: 30 }).map((_, i) => {
const angle = (i / 30) * Math.PI * 2 + time * 2;
const radius = 50 + ((time * 100 + i * 20) % 400);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const size = 4 + (i % 4);
return (
<div
key={`p-${i}`}
style={{
position: "absolute",
top: "50%",
left: "50%",
width: size,
height: size,
borderRadius: "50%",
background: colors.primary,
transform: `translate(${x}px, ${y}px)`,
boxShadow: `0 0 10px ${colors.primary}`,
opacity: 0.8,
}}
/>
);
})}
</AbsoluteFill>
);
};
// 霓虹房间效果
const NeonRoomVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: "#050510", perspective: 600 }}>
{/* 3D 房间 - 地板 */}
<div
style={{
position: "absolute",
bottom: 0,
left: "-50%",
width: "200%",
height: "60%",
background: `
repeating-linear-gradient(
90deg,
transparent,
transparent 48px,
${colors.primary}44 48px,
${colors.primary}44 50px
),
repeating-linear-gradient(
0deg,
transparent,
transparent 48px,
${colors.secondary}44 48px,
${colors.secondary}44 50px
)
`,
transform: `perspective(600px) rotateX(70deg) translateY(${time * 50 % 50}px)`,
transformOrigin: "bottom center",
}}
/>
{/* 侧墙霓虹灯条 */}
{[0, 1].map((side) => (
<div
key={side}
style={{
position: "absolute",
top: "10%",
[side === 0 ? "left" : "right"]: "5%",
width: 8,
height: "60%",
background: `linear-gradient(to bottom, ${colors.primary}, ${colors.secondary}, ${colors.accent})`,
boxShadow: `0 0 30px ${colors.primary}, 0 0 60px ${colors.secondary}`,
opacity: 0.8 + Math.sin(time * 4 + side) * 0.2,
}}
/>
))}
{/* 天花板灯光 */}
{Array.from({ length: 5 }).map((_, i) => {
const pulse = Math.sin(time * 6 + i * 1.2) > 0;
const hue = (i * 72 + time * 40) % 360;
return (
<div
key={i}
style={{
position: "absolute",
top: "5%",
left: `${15 + i * 17}%`,
width: 60,
height: 200,
background: `linear-gradient(to bottom, hsl(${hue}, 100%, 60%), transparent)`,
opacity: pulse ? 0.7 : 0.2,
filter: "blur(10px)",
transform: `rotate(${Math.sin(time * 2 + i) * 10}deg)`,
transformOrigin: "top center",
}}
/>
);
})}
{/* 中心舞台光柱 */}
<div
style={{
position: "absolute",
top: 0,
left: "50%",
width: 100,
height: "100%",
background: `linear-gradient(to bottom, ${colors.accent}88, transparent 70%)`,
transform: `translateX(-50%) scaleX(${1 + Math.sin(time * 4) * 0.3})`,
filter: "blur(20px)",
}}
/>
</AbsoluteFill>
);
};
// 赛博波浪效果
const CyberwaveVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: "linear-gradient(to bottom, #0a0020, #1a0040)" }}>
{/* 太阳/月亮 */}
<div
style={{
position: "absolute",
top: "15%",
left: "50%",
width: 200,
height: 200,
borderRadius: "50%",
background: `linear-gradient(to bottom, ${colors.accent}, ${colors.primary})`,
transform: "translateX(-50%)",
boxShadow: `0 0 100px ${colors.accent}, 0 0 200px ${colors.primary}`,
}}
/>
{/* 水平线条 */}
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
style={{
position: "absolute",
top: `${25 + i * 3}%`,
left: 0,
width: "100%",
height: 2,
background: colors.primary,
opacity: 0.5 - i * 0.05,
}}
/>
))}
{/* 3D 网格地面 */}
<div
style={{
position: "absolute",
bottom: 0,
left: "-50%",
width: "200%",
height: "50%",
background: `
repeating-linear-gradient(
90deg,
transparent,
transparent 38px,
${colors.secondary}66 38px,
${colors.secondary}66 40px
),
repeating-linear-gradient(
0deg,
transparent,
transparent 38px,
${colors.primary}66 38px,
${colors.primary}66 40px
)
`,
transform: `perspective(400px) rotateX(75deg) translateY(${time * 80 % 40}px)`,
transformOrigin: "bottom center",
}}
/>
{/* 山脉轮廓 */}
<svg
style={{
position: "absolute",
bottom: "45%",
left: 0,
width: "100%",
height: "20%",
}}
viewBox="0 0 100 20"
preserveAspectRatio="none"
>
<path
d="M0,20 L10,12 L20,15 L35,5 L50,10 L65,3 L80,12 L90,8 L100,15 L100,20 Z"
fill="#0a0020"
stroke={colors.primary}
strokeWidth="0.3"
/>
</svg>
{/* 飞行粒子 */}
{Array.from({ length: 20 }).map((_, i) => {
const x = (i * 47 + time * 100) % 120 - 10;
const y = 20 + (i % 5) * 15;
const size = 2 + (i % 3);
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
width: size,
height: size,
borderRadius: "50%",
background: colors.accent,
boxShadow: `0 0 10px ${colors.accent}`,
}}
/>
);
})}
</AbsoluteFill>
);
};
// 全息投影效果
const HologramVisual: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
return (
<AbsoluteFill style={{ overflow: "hidden", background: colors.background }}>
{/* 全息投影底座 */}
<div
style={{
position: "absolute",
bottom: "15%",
left: "50%",
width: 300,
height: 20,
background: `linear-gradient(to right, transparent, ${colors.primary}, transparent)`,
transform: "translateX(-50%)",
borderRadius: "50%",
boxShadow: `0 0 40px ${colors.primary}`,
}}
/>
{/* 全息光柱 */}
<div
style={{
position: "absolute",
bottom: "15%",
left: "50%",
width: 200,
height: "60%",
background: `linear-gradient(to top, ${colors.primary}44, transparent)`,
transform: "translateX(-50%)",
clipPath: "polygon(20% 100%, 80% 100%, 60% 0%, 40% 0%)",
}}
/>
{/* 全息图形 - 旋转多边形 */}
{Array.from({ length: 3 }).map((_, i) => {
const rotation = time * 60 + i * 120;
const scale = 0.8 + Math.sin(time * 2 + i) * 0.2;
const y = 35 + i * 5;
return (
<div
key={i}
style={{
position: "absolute",
top: `${y}%`,
left: "50%",
width: 150 - i * 30,
height: 150 - i * 30,
border: `2px solid ${colors.secondary}`,
transform: `translateX(-50%) rotate(${rotation}deg) scale(${scale})`,
opacity: 0.6 + i * 0.1,
boxShadow: `0 0 20px ${colors.secondary}, inset 0 0 20px ${colors.secondary}`,
}}
/>
);
})}
{/* 扫描线效果 */}
<div
style={{
position: "absolute",
top: `${(time * 30) % 100}%`,
left: "30%",
width: "40%",
height: 2,
background: colors.accent,
boxShadow: `0 0 20px ${colors.accent}`,
opacity: 0.8,
}}
/>
{/* 数据流 */}
{Array.from({ length: 10 }).map((_, i) => {
const x = 35 + (i % 5) * 6;
const y = (time * 50 + i * 30) % 100;
// 使用确定性方式生成 0/1,基于 frame 和 index
const binaryValue = Math.sin(frame * 0.5 + i * 7.123) > 0 ? "1" : "0";
return (
<div
key={i}
style={{
position: "absolute",
left: `${x}%`,
top: `${y}%`,
fontSize: 12,
fontFamily: "monospace",
color: colors.secondary,
opacity: 0.6,
textShadow: `0 0 10px ${colors.secondary}`,
}}
>
{binaryValue}
</div>
);
})}
{/* 闪烁干扰效果 */}
{Math.sin(time * 20) > 0.95 && (
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
background: `${colors.primary}22`,
}}
/>
)}
</AbsoluteFill>
);
};
export default DJScene;
import React from "react";
import {
AbsoluteFill,
Img,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
staticFile,
} from "remotion";
import { Visual, AnimationType, StyleConfig } from "../../types";
import { applyAnimation } from "../animations/animationUtils";
interface TitleSceneProps {
text: string;
subtitle?: string;
visual: Visual;
animation: AnimationType;
styleConfig: StyleConfig;
}
export const TitleScene: React.FC<TitleSceneProps> = ({
text,
subtitle,
visual,
animation,
styleConfig,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// 文字动画
const textOpacity = interpolate(frame, [0, fps * 0.5], [0, 1], {
extrapolateRight: "clamp",
});
const textScale = spring({
frame,
fps,
config: {
damping: 100,
stiffness: 200,
mass: 0.5,
},
});
// 副标题延迟出现
const subtitleOpacity = interpolate(
frame,
[fps * 0.3, fps * 0.8],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// 背景动画
const bgStyle = applyAnimation(animation, frame, fps, durationInFrames);
// 获取媒体文件路径
const mediaSrc = visual.file ? staticFile(visual.file) : null;
return (
<AbsoluteFill>
{/* 背景图像 */}
{mediaSrc && (
<AbsoluteFill style={bgStyle}>
<Img
src={mediaSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* 暗化遮罩 */}
<AbsoluteFill
style={{
background: `linear-gradient(
to bottom,
rgba(0,0,0,0.3) 0%,
rgba(0,0,0,0.6) 50%,
rgba(0,0,0,0.3) 100%
)`,
}}
/>
</AbsoluteFill>
)}
{/* 标题文字 */}
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity: textOpacity,
transform: `scale(${textScale})`,
textAlign: "center",
}}
>
{/* 主标题 */}
<h1
style={{
fontSize: 80,
fontWeight: "bold",
color: styleConfig.textColor,
fontFamily: styleConfig.fontFamily,
textShadow: `
0 0 20px ${styleConfig.primaryColor},
0 0 40px ${styleConfig.primaryColor},
2px 2px 4px rgba(0,0,0,0.8)
`,
margin: 0,
letterSpacing: "0.1em",
}}
>
{text}
</h1>
{/* 副标题 */}
{subtitle && (
<h2
style={{
opacity: subtitleOpacity,
fontSize: 32,
fontWeight: "normal",
color: styleConfig.secondaryColor,
fontFamily: "Bebas Neue, sans-serif",
textShadow: "2px 2px 4px rgba(0,0,0,0.8)",
marginTop: 20,
letterSpacing: "0.3em",
}}
>
{subtitle}
</h2>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
OffthreadVideo,
} from "remotion";
interface VideoMixSceneProps {
videoSrc: string;
colorScheme: {
primary: string;
secondary: string;
accent: string;
background: string;
};
intensity: number;
overlayEffect: string;
songTitle?: string;
}
export const VideoMixScene: React.FC<VideoMixSceneProps> = ({
videoSrc,
colorScheme,
intensity = 1,
overlayEffect = "flash",
songTitle,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const time = frame / fps;
// 视频缩放动画 - 轻微的呼吸效果
const videoScale = interpolate(
Math.sin(time * 2),
[-1, 1],
[1, 1.08],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ backgroundColor: colorScheme.background }}>
{/* 视频背景 */}
<AbsoluteFill
style={{
transform: `scale(${videoScale})`,
transformOrigin: "center center",
}}
>
<OffthreadVideo
src={videoSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
muted
/>
</AbsoluteFill>
{/* 3D 粒子效果叠加 */}
<Particles3DOverlay
frame={frame}
fps={fps}
colors={colorScheme}
intensity={intensity}
width={width}
height={height}
/>
{/* 3D 光线效果 */}
<LightBeams3DOverlay
frame={frame}
fps={fps}
colors={colorScheme}
intensity={intensity}
/>
{/* 3D 几何图形效果 */}
<Geometric3DOverlay
frame={frame}
fps={fps}
colors={colorScheme}
intensity={intensity}
/>
{/* 节拍闪光效果 */}
<BeatFlashOverlay
frame={frame}
fps={fps}
colors={colorScheme}
intensity={intensity}
/>
{/* 动态效果叠加 */}
<DynamicOverlay
effect={overlayEffect}
frame={frame}
fps={fps}
colors={colorScheme}
intensity={intensity}
/>
{/* 颜色调色叠加 */}
<ColorGradingOverlay colors={colorScheme} intensity={intensity * 0.25} />
{/* 边缘光晕 */}
<VignetteOverlay colors={colorScheme} intensity={intensity * 0.4} />
{/* 扫描线效果 */}
<ScanlineOverlay intensity={intensity * 0.15} />
{/* 歌曲标题 */}
{songTitle && (
<SongTitleOverlay
title={songTitle}
frame={frame}
fps={fps}
colors={colorScheme}
/>
)}
</AbsoluteFill>
);
};
// 3D 粒子效果
const Particles3DOverlay: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
width: number;
height: number;
}> = ({ frame, fps, colors, intensity, width, height }) => {
const time = frame / fps;
const particleCount = 30;
const particles = Array.from({ length: particleCount }, (_, i) => {
// 使用确定性的伪随机
const seed = i * 137.5;
const baseX = ((seed * 7) % 100) / 100;
const baseY = ((seed * 13) % 100) / 100;
const speed = 0.5 + ((seed * 3) % 100) / 200;
const size = 2 + ((seed * 11) % 100) / 25;
const phase = (seed * 17) % (Math.PI * 2);
// 3D 运动效果
const z = Math.sin(time * speed + phase) * 0.5 + 0.5;
const scale = 0.3 + z * 0.7;
const x = baseX * width + Math.sin(time * speed * 2 + phase) * 50;
const y = (baseY * height + time * 100 * speed) % height;
// 颜色循环
const colorIndex = Math.floor((time + i * 0.1) * 2) % 3;
const particleColors = [colors.primary, colors.secondary, colors.accent];
const color = particleColors[colorIndex];
return (
<div
key={i}
style={{
position: "absolute",
left: x,
top: y,
width: size * scale,
height: size * scale,
borderRadius: "50%",
background: color,
boxShadow: `0 0 ${size * 2}px ${color}, 0 0 ${size * 4}px ${color}`,
opacity: 0.6 * intensity * scale,
transform: `scale(${scale})`,
}}
/>
);
});
return (
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
{particles}
</AbsoluteFill>
);
};
// 3D 光线效果
const LightBeams3DOverlay: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
const beamCount = 6;
const beams = Array.from({ length: beamCount }, (_, i) => {
const angle = (i / beamCount) * 360 + time * 30;
const length = 150 + Math.sin(time * 3 + i) * 50;
const width = 3 + Math.sin(time * 2 + i * 0.5) * 2;
const opacity = 0.3 + Math.sin(time * 4 + i) * 0.2;
const colorIndex = i % 3;
const beamColors = [colors.primary, colors.secondary, colors.accent];
return (
<div
key={i}
style={{
position: "absolute",
left: "50%",
top: "30%",
width: `${length}%`,
height: width,
background: `linear-gradient(90deg, ${beamColors[colorIndex]}00, ${beamColors[colorIndex]}, ${beamColors[colorIndex]}00)`,
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: "left center",
opacity: opacity * intensity,
filter: `blur(${1}px)`,
}}
/>
);
});
return (
<AbsoluteFill
style={{
pointerEvents: "none",
mixBlendMode: "screen",
}}
>
{beams}
</AbsoluteFill>
);
};
// 3D 几何图形效果
const Geometric3DOverlay: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
// 旋转的三角形
const triangleRotation = time * 60;
const triangleScale = 0.8 + Math.sin(time * 2) * 0.2;
// 旋转的六边形
const hexRotation = -time * 40;
const hexScale = 0.7 + Math.sin(time * 1.5 + 1) * 0.3;
// 脉冲圆环
const ringScale = 1 + Math.sin(time * 3) * 0.3;
const ringOpacity = 0.3 + Math.sin(time * 3) * 0.2;
return (
<AbsoluteFill
style={{
pointerEvents: "none",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{/* 外圈旋转三角形 */}
<div
style={{
position: "absolute",
width: 400,
height: 400,
border: `2px solid ${colors.primary}`,
opacity: 0.4 * intensity,
transform: `rotate(${triangleRotation}deg) scale(${triangleScale})`,
clipPath: "polygon(50% 0%, 0% 100%, 100% 100%)",
boxShadow: `0 0 20px ${colors.primary}, inset 0 0 20px ${colors.primary}`,
}}
/>
{/* 中圈六边形 */}
<div
style={{
position: "absolute",
width: 300,
height: 300,
border: `2px solid ${colors.secondary}`,
opacity: 0.35 * intensity,
transform: `rotate(${hexRotation}deg) scale(${hexScale})`,
clipPath: "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)",
boxShadow: `0 0 15px ${colors.secondary}, inset 0 0 15px ${colors.secondary}`,
}}
/>
{/* 内圈脉冲圆环 */}
<div
style={{
position: "absolute",
width: 200,
height: 200,
borderRadius: "50%",
border: `3px solid ${colors.accent}`,
opacity: ringOpacity * intensity,
transform: `scale(${ringScale})`,
boxShadow: `0 0 25px ${colors.accent}, inset 0 0 25px ${colors.accent}`,
}}
/>
{/* 中心点 */}
<div
style={{
position: "absolute",
width: 20,
height: 20,
borderRadius: "50%",
background: colors.accent,
boxShadow: `0 0 30px ${colors.accent}, 0 0 60px ${colors.accent}`,
opacity: 0.8 * intensity,
}}
/>
</AbsoluteFill>
);
};
// 节拍闪光效果
const BeatFlashOverlay: React.FC<{
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ frame, fps, colors, intensity }) => {
const time = frame / fps;
// 模拟节拍 - 每 0.5 秒一次
const beatPhase = (time * 2) % 1;
const flashOpacity = interpolate(
beatPhase,
[0, 0.05, 0.15],
[0.5, 0.3, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// 只在节拍点闪光
if (beatPhase > 0.15) return null;
return (
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 30%, ${colors.accent}${Math.floor(flashOpacity * 255 * intensity).toString(16).padStart(2, '0')}, transparent 60%)`,
mixBlendMode: "screen",
}}
/>
);
};
// 动态效果选择器
const DynamicOverlay: React.FC<{
effect: string;
frame: number;
fps: number;
colors: any;
intensity: number;
}> = ({ effect, frame, fps, colors, intensity }) => {
const time = frame / fps;
switch (effect) {
case "glitch":
return <GlitchEffect time={time} frame={frame} colors={colors} intensity={intensity} />;
case "strobe":
return <StrobeEffect time={time} intensity={intensity} />;
case "color-shift":
return <ColorShiftEffect time={time} colors={colors} intensity={intensity} />;
default:
return null;
}
};
// 故障效果
const GlitchEffect: React.FC<{
time: number;
frame: number;
colors: any;
intensity: number;
}> = ({ time, frame, colors, intensity }) => {
const glitchTrigger = Math.sin(time * 15 + frame * 0.1) > 0.9;
if (!glitchTrigger) return null;
const offsetX = Math.sin(frame * 0.5) * 8 * intensity;
return (
<>
<AbsoluteFill
style={{
background: colors.primary,
mixBlendMode: "multiply",
opacity: 0.2 * intensity,
transform: `translateX(${offsetX}px)`,
}}
/>
<AbsoluteFill
style={{
background: colors.secondary,
mixBlendMode: "multiply",
opacity: 0.2 * intensity,
transform: `translateX(${-offsetX}px)`,
}}
/>
</>
);
};
// 频闪效果
const StrobeEffect: React.FC<{
time: number;
intensity: number;
}> = ({ time, intensity }) => {
const strobeOn = Math.sin(time * 20) > 0.85;
if (!strobeOn) return null;
return (
<AbsoluteFill
style={{
background: `rgba(255,255,255,${0.3 * intensity})`,
mixBlendMode: "overlay",
}}
/>
);
};
// 颜色偏移效果
const ColorShiftEffect: React.FC<{
time: number;
colors: any;
intensity: number;
}> = ({ time, colors, intensity }) => {
const hueShift = (time * 45) % 360;
return (
<AbsoluteFill
style={{
background: `linear-gradient(${hueShift}deg, ${colors.primary}15, ${colors.secondary}15, ${colors.accent}15)`,
mixBlendMode: "overlay",
opacity: intensity * 0.6,
}}
/>
);
};
// 颜色调色叠加
const ColorGradingOverlay: React.FC<{
colors: any;
intensity: number;
}> = ({ colors, intensity }) => {
return (
<AbsoluteFill
style={{
background: `linear-gradient(135deg, ${colors.primary}${Math.floor(intensity * 40).toString(16).padStart(2, '0')}, transparent 50%, ${colors.secondary}${Math.floor(intensity * 40).toString(16).padStart(2, '0')})`,
mixBlendMode: "overlay",
}}
/>
);
};
// 暗角效果
const VignetteOverlay: React.FC<{
colors: any;
intensity: number;
}> = ({ colors, intensity }) => {
return (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse at center, transparent 35%, ${colors.background}${Math.floor(intensity * 180).toString(16).padStart(2, '0')} 100%)`,
pointerEvents: "none",
}}
/>
);
};
// 扫描线效果
const ScanlineOverlay: React.FC<{
intensity: number;
}> = ({ intensity }) => {
return (
<AbsoluteFill
style={{
background: `repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,${0.1 * intensity}) 2px,
rgba(0,0,0,${0.1 * intensity}) 4px
)`,
pointerEvents: "none",
}}
/>
);
};
// 歌曲标题叠加
const SongTitleOverlay: React.FC<{
title: string;
frame: number;
fps: number;
colors: any;
}> = ({ title, frame, fps, colors }) => {
const entranceProgress = spring({
frame,
fps,
config: { damping: 15, stiffness: 100 },
durationInFrames: Math.floor(fps * 0.8),
});
const exitProgress = spring({
frame: frame - Math.floor(fps * 2.5),
fps,
config: { damping: 200 },
durationInFrames: Math.floor(fps * 0.5),
});
const opacity = interpolate(
entranceProgress - exitProgress,
[0, 1],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateY = interpolate(
entranceProgress,
[0, 1],
[50, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const scale = interpolate(
entranceProgress,
[0, 1],
[0.8, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 180,
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px) scale(${scale})`,
fontSize: 52,
fontWeight: "bold",
color: "#fff",
textShadow: `
0 0 20px ${colors.primary},
0 0 40px ${colors.primary},
0 0 60px ${colors.secondary},
2px 2px 4px rgba(0,0,0,0.8)
`,
fontFamily: "Orbitron, Noto Sans SC, sans-serif",
letterSpacing: "0.15em",
textTransform: "uppercase",
}}
>
{title}
</div>
</AbsoluteFill>
);
};
export default VideoMixScene;
import React from "react";
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
Audio,
staticFile,
} from "remotion";
import { TitleScene } from "../components/scenes/TitleScene";
import { ActionScene } from "../components/scenes/ActionScene";
import { DJScene } from "../components/scenes/DJScene";
import { VideoMixScene } from "../components/scenes/VideoMixScene";
import { TransitionOverlay } from "../components/effects/TransitionOverlay";
import { Scene, Storyboard } from "../types";
interface MVCompositionProps extends Storyboard {}
export const MVComposition: React.FC<MVCompositionProps> = ({
meta,
music,
scenes,
}) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
// 加载风格配置
const styleConfig = getStyleConfig(meta.style);
return (
<AbsoluteFill style={{ backgroundColor: styleConfig.background }}>
{/* 音乐轨道 */}
{music.file && (
<Audio src={staticFile(music.file)} volume={1} />
)}
{/* 场景序列 */}
{scenes.map((scene, index) => {
const startFrame = Math.round(scene.start * fps);
const durationFrames = Math.round((scene.end - scene.start) * fps);
return (
<Sequence
key={scene.id}
from={startFrame}
durationInFrames={durationFrames}
name={scene.id}
>
<SceneRenderer
scene={scene}
styleConfig={styleConfig}
beats={music.beats || []}
bpm={music.bpm || 128}
/>
{/* 转场效果 */}
{scene.transition && scene.transition !== "none" && (
<TransitionOverlay
type={scene.transition}
durationFrames={Math.round(0.3 * fps)}
styleConfig={styleConfig}
/>
)}
</Sequence>
);
})}
</AbsoluteFill>
);
};
interface SceneRendererProps {
scene: Scene;
styleConfig: StyleConfig;
beats: number[];
bpm: number;
}
const SceneRenderer: React.FC<SceneRendererProps> = ({
scene,
styleConfig,
beats,
bpm,
}) => {
switch (scene.type) {
case "title":
return (
<TitleScene
text={scene.content?.text || ""}
subtitle={scene.content?.subtitle}
visual={scene.visual}
animation={scene.animation}
styleConfig={styleConfig}
/>
);
case "dj":
return (
<DJScene
visualType={scene.visual?.visual_type || "waveform"}
colorScheme={scene.visual?.color_scheme || {
primary: "#FF00FF",
secondary: "#00FFFF",
accent: "#FFFF00",
background: "#0a0a0f",
}}
intensity={scene.visual?.intensity || 1}
songTitle={scene.metadata?.song_keyword}
/>
);
case "video_mix":
// 如果没有视频文件,回退到 DJ 程序化场景
if (!scene.visual?.file) {
return (
<DJScene
visualType={scene.visual?.visual_type || "waveform"}
colorScheme={scene.visual?.color_scheme || {
primary: "#FF00FF",
secondary: "#00FFFF",
accent: "#FFFF00",
background: "#0a0a0f",
}}
intensity={scene.visual?.intensity || 1}
songTitle={scene.metadata?.song_keyword}
/>
);
}
return (
<VideoMixScene
videoSrc={staticFile(scene.visual.file)}
colorScheme={scene.visual?.color_scheme || {
primary: "#FF00FF",
secondary: "#00FFFF",
accent: "#FFFF00",
background: "#0a0a0f",
}}
intensity={scene.visual?.intensity || 1}
overlayEffect={scene.visual?.overlay_effect || "flash"}
songTitle={scene.metadata?.song_keyword}
/>
);
case "action":
case "lyrics":
default:
return (
<ActionScene
visual={scene.visual}
animation={scene.animation}
lyrics={scene.content?.lyrics}
beatSync={scene.beat_sync}
beats={beats}
bpm={bpm}
styleConfig={styleConfig}
/>
);
}
};
// 风格配置
interface StyleConfig {
background: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
textColor: string;
fontFamily: string;
}
function getStyleConfig(style: string): StyleConfig {
const configs: Record<string, StyleConfig> = {
"anime-hype": {
background: "#0D0D0D",
primaryColor: "#FF4500",
secondaryColor: "#FFD700",
accentColor: "#1E90FF",
textColor: "#FFFFFF",
fontFamily: "Noto Sans SC, sans-serif",
},
cyberpunk: {
background: "#0a0a0f",
primaryColor: "#ff00ff",
secondaryColor: "#00ffff",
accentColor: "#ff0080",
textColor: "#ffffff",
fontFamily: "Orbitron, sans-serif",
},
lyric: {
background: "#1a1a2e",
primaryColor: "#e94560",
secondaryColor: "#533483",
accentColor: "#16213e",
textColor: "#ffffff",
fontFamily: "Noto Serif SC, serif",
},
"medley-dj-remix": {
background: "#0a0a0f",
primaryColor: "#FF00FF",
secondaryColor: "#00FFFF",
accentColor: "#FFFF00",
textColor: "#ffffff",
fontFamily: "Orbitron, sans-serif",
},
};
// 支持 medley-* 风格
if (style.startsWith("medley-")) {
const medleyStyle = style.replace("medley-", "");
if (configs[`medley-${medleyStyle}`]) {
return configs[`medley-${medleyStyle}`];
}
// 默认 medley 风格
return {
background: "#0D0D0D",
primaryColor: "#FF4500",
secondaryColor: "#FFD700",
accentColor: "#1E90FF",
textColor: "#FFFFFF",
fontFamily: "Noto Sans SC, sans-serif",
};
}
return configs[style] || configs["anime-hype"];
}
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
import { Composition, getInputProps } from "remotion";
import { MVComposition } from "./compositions/MVComposition";
import { Storyboard } from "./types";
export const RemotionRoot: React.FC = () => {
// 从命令行获取 props
const inputProps = getInputProps() as Partial<Storyboard>;
const duration = inputProps?.meta?.duration || 55;
const fps = inputProps?.meta?.fps || 30;
const resolution = inputProps?.meta?.resolution || "1080x1920";
const [width, height] = resolution.split("x").map(Number);
return (
<>
<Composition
id="MVComposition"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component={MVComposition as any}
durationInFrames={fps * duration}
fps={fps}
width={width || 1080}
height={height || 1920}
defaultProps={{
meta: {
title: "MV Preview",
style: "anime-hype",
duration: duration,
resolution: resolution,
fps: fps,
},
music: {
source: "none",
file: null,
bpm: 128,
beats: [],
},
scenes: [],
}}
/>
</>
);
};
/**
* MV Skill 类型定义
*/
export interface Storyboard {
meta: Meta;
music: Music;
scenes: Scene[];
}
export interface Meta {
title: string;
style: string; // "anime-hype" | "cyberpunk" | "lyric" | "medley-*"
duration: number;
resolution: string;
fps: number;
type?: "single" | "medley";
song_count?: number;
}
export interface Music {
source: "suno" | "local" | "none" | "medley";
prompt?: string;
file: string | null;
bpm: number | null;
beats: number[];
segments?: MusicSegment[];
}
export interface MusicSegment {
keyword: string;
duration: number;
}
export interface Scene {
id: string;
start: number;
end: number;
type: "title" | "action" | "transition" | "lyrics" | "dj" | "video_mix";
content?: SceneContent;
visual: Visual;
animation: AnimationType;
transition?: TransitionType;
beat_sync?: boolean;
metadata?: SceneMetadata;
}
export interface SceneMetadata {
segment_index?: number;
song_keyword?: string;
video_category?: string;
scene_index?: number;
dj_visual_type?: string;
}
export interface SceneContent {
text?: string;
subtitle?: string;
lyrics?: string;
}
export interface Visual {
source: "auto" | "ai" | "stock" | "user" | "programmatic" | "video";
prompt?: string;
stock_keywords?: string[];
quality_priority?: "high" | "medium" | "low";
allow_ai_fallback?: boolean;
file: string | null;
media_type?: "image" | "video";
// DJ scene properties
visual_type?: string;
color_scheme?: ColorScheme;
intensity?: number;
// Video mix scene properties
video_category?: string;
overlay_effect?: string;
}
export interface ColorScheme {
primary: string;
secondary: string;
accent: string;
background: string;
}
export type AnimationType =
| "none"
| "zoom-in"
| "zoom-out"
| "pan-left"
| "pan-right"
| "pan-up"
| "pan-down"
| "shake"
| "flash"
| "ken-burns"
| "speed-lines"
| "rotate";
export type TransitionType =
| "none"
| "fade"
| "cross-fade"
| "flash"
| "wipe-left"
| "wipe-right"
| "wipe-diagonal"
| "impact-frame"
| "glitch"
| "pixelate";
export interface StyleConfig {
background: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
textColor: string;
fontFamily: string;
}
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
# MV 分镜脚本示例
# 主题:小宇宙觉醒(圣斗士星矢风格)
meta:
title: "小宇宙觉醒"
style: "anime-hype"
duration: 55
resolution: "1080x1920"
fps: 30
music:
source: "suno"
prompt: "epic anime battle theme, orchestral, intense drums, heroic melody, 128bpm, japanese style"
file: null
bpm: null
beats: []
scenes:
# 开场标题 (0-5秒)
- id: "intro"
start: 0
end: 5
type: "title"
content:
text: "小宇宙觉醒"
subtitle: "COSMOS AWAKENING"
visual:
source: "auto"
prompt: "cosmic explosion, galaxy forming, golden light rays, epic scale, anime style"
stock_keywords: ["cosmos", "galaxy", "explosion", "light"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "zoom-in"
transition: "flash"
beat_sync: false
# 战士登场 (5-12秒)
- id: "hero_intro"
start: 5
end: 12
type: "action"
content:
lyrics: "燃烧吧"
visual:
source: "auto"
prompt: "anime warrior in golden armor, standing pose, dramatic lighting, saint seiya style"
stock_keywords: ["warrior", "hero", "armor", "gold"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "shake"
transition: "none"
beat_sync: true
# 能量聚集 (12-20秒)
- id: "power_up_1"
start: 12
end: 20
type: "action"
content:
lyrics: "小宇宙"
visual:
source: "auto"
prompt: "energy aura gathering, golden particles, cosmic background, power charging, anime style"
stock_keywords: ["energy", "particles", "golden", "aura"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "zoom-in"
transition: "cross-fade"
beat_sync: true
# 爆发瞬间 (20-28秒)
- id: "explosion"
start: 20
end: 28
type: "action"
content:
lyrics: "奇迹终将降临"
visual:
source: "stock"
prompt: null
stock_keywords: ["explosion", "energy burst", "light rays", "impact"]
quality_priority: "high"
allow_ai_fallback: true
file: null
media_type: "video"
animation: "shake"
transition: "flash"
beat_sync: true
# 战斗场面 (28-38秒)
- id: "battle"
start: 28
end: 38
type: "action"
visual:
source: "auto"
prompt: "epic anime battle scene, two warriors clashing, energy waves, dynamic action pose"
stock_keywords: ["battle", "fight", "action", "clash"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "speed-lines"
transition: "impact-frame"
beat_sync: true
# 情感桥段 (38-48秒)
- id: "emotional"
start: 38
end: 48
type: "action"
content:
lyrics: "为了守护重要的人"
visual:
source: "auto"
prompt: "anime warrior looking at stars, peaceful moment, hopeful expression, night sky"
stock_keywords: ["stars", "night sky", "peaceful", "hope"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "ken-burns"
transition: "fade"
beat_sync: false
# 终结画面 (48-55秒)
- id: "finale"
start: 48
end: 55
type: "title"
content:
text: "小宇宙觉醒"
subtitle: "THE END"
visual:
source: "auto"
prompt: "hero silhouette against sunrise, golden armor glowing, victorious pose, anime style"
stock_keywords: ["sunrise", "silhouette", "victory", "hero"]
quality_priority: "high"
allow_ai_fallback: true
file: null
animation: "zoom-out"
transition: "fade"
beat_sync: false
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MV Storyboard Schema",
"description": "分镜脚本数据结构定义",
"type": "object",
"required": ["meta", "music", "scenes"],
"properties": {
"meta": {
"type": "object",
"required": ["title", "style", "duration", "resolution", "fps"],
"properties": {
"title": {
"type": "string",
"description": "MV 标题"
},
"style": {
"type": "string",
"enum": ["anime-hype", "cyberpunk", "lyric"],
"description": "风格模板"
},
"duration": {
"type": "integer",
"minimum": 30,
"maximum": 120,
"description": "总时长(秒)"
},
"resolution": {
"type": "string",
"enum": ["1080x1920", "720x1280"],
"default": "1080x1920",
"description": "分辨率(宽x高)"
},
"fps": {
"type": "integer",
"enum": [24, 30, 60],
"default": 30,
"description": "帧率"
}
}
},
"music": {
"type": "object",
"required": ["source"],
"properties": {
"source": {
"type": "string",
"enum": ["suno", "local", "none"],
"description": "音乐来源"
},
"prompt": {
"type": "string",
"description": "Suno 生成提示词"
},
"file": {
"type": ["string", "null"],
"description": "音乐文件路径"
},
"bpm": {
"type": ["integer", "null"],
"description": "节拍速度"
},
"beats": {
"type": "array",
"items": { "type": "number" },
"description": "节拍时间点数组"
}
}
},
"scenes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "start", "end", "type", "visual"],
"properties": {
"id": {
"type": "string",
"description": "场景唯一标识"
},
"start": {
"type": "number",
"minimum": 0,
"description": "开始时间(秒)"
},
"end": {
"type": "number",
"minimum": 0,
"description": "结束时间(秒)"
},
"type": {
"type": "string",
"enum": ["title", "action", "transition", "lyrics"],
"description": "场景类型"
},
"content": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "标题文字"
},
"subtitle": {
"type": "string",
"description": "副标题"
},
"lyrics": {
"type": "string",
"description": "歌词文字"
}
}
},
"visual": {
"type": "object",
"required": ["source"],
"properties": {
"source": {
"type": "string",
"enum": ["auto", "ai", "stock", "user"],
"description": "素材来源"
},
"prompt": {
"type": "string",
"description": "AI 生成提示词"
},
"stock_keywords": {
"type": "array",
"items": { "type": "string" },
"description": "素材库搜索关键词"
},
"quality_priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"default": "high",
"description": "质量优先级"
},
"allow_ai_fallback": {
"type": "boolean",
"default": true,
"description": "是否允许 AI 生成兜底"
},
"file": {
"type": ["string", "null"],
"description": "素材文件路径"
},
"media_type": {
"type": "string",
"enum": ["image", "video"],
"default": "image",
"description": "素材类型"
}
}
},
"animation": {
"type": "string",
"enum": [
"none",
"zoom-in",
"zoom-out",
"pan-left",
"pan-right",
"pan-up",
"pan-down",
"shake",
"flash",
"ken-burns",
"speed-lines",
"rotate"
],
"default": "none",
"description": "动画效果"
},
"transition": {
"type": "string",
"enum": [
"none",
"fade",
"cross-fade",
"flash",
"wipe-left",
"wipe-right",
"wipe-diagonal",
"impact-frame",
"glitch",
"pixelate"
],
"default": "none",
"description": "转场效果"
},
"beat_sync": {
"type": "boolean",
"default": false,
"description": "是否同步节拍"
}
}
}
}
}
}
"""
MV Skill - 动感短视频生成器
"""
from .storyboard_generator import StoryboardGenerator, generate_storyboard
from .asset_manager import AssetManager
from .music_generator import MusicGenerator, analyze_music
from .preview_generator import PreviewGenerator, generate_preview
from .renderer import Renderer, render_mv
from .config import AVAILABLE_STYLES, DEFAULT_CONFIG
from .exceptions import (
MVSkillError,
MusicGenerationError,
AssetNotFoundError,
RenderError,
TemplateNotFoundError,
)
__all__ = [
"StoryboardGenerator",
"generate_storyboard",
"AssetManager",
"MusicGenerator",
"analyze_music",
"PreviewGenerator",
"generate_preview",
"Renderer",
"render_mv",
"AVAILABLE_STYLES",
"DEFAULT_CONFIG",
"MVSkillError",
"MusicGenerationError",
"AssetNotFoundError",
"RenderError",
"TemplateNotFoundError",
]
__version__ = "1.0.0"
"""
素材管理器
负责从素材库和 AI 生成服务获取视觉素材
质量优先策略:素材库 > AI 生成
"""
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import json
from .config import (
CACHE_DIR,
QUALITY_REQUIREMENTS,
TIMEOUTS,
ASSET_SOURCES_PRIORITY,
ZIMAGE_SKILL_DIR,
MEDIA_DOWNLOADER_DIR,
PEXELS_API_KEY,
PIXABAY_API_KEY,
)
from .exceptions import AssetNotFoundError, AssetQualityError, DependencyError
class AssetManager:
"""素材管理器"""
def __init__(self, style_config: Optional[Dict] = None):
"""
初始化素材管理器
Args:
style_config: 风格配置,包含 ai_prompt_suffix 等
"""
self.style_config = style_config or {}
self.cache_dir = CACHE_DIR / "assets"
self.cache_dir.mkdir(exist_ok=True)
self._check_dependencies()
def _check_dependencies(self):
"""检查依赖的 Skills 是否存在"""
self.has_zimage = (ZIMAGE_SKILL_DIR / "generate.py").exists()
self.has_media_downloader = (MEDIA_DOWNLOADER_DIR / "media_cli.py").exists()
if not self.has_zimage and not self.has_media_downloader:
raise DependencyError(
"需要至少安装 zimage-skill 或 media-downloader 之一"
)
def fetch_asset(
self,
scene: Dict,
scene_id: str,
) -> str:
"""
获取场景素材
质量优先策略:
1. 先从素材库搜索高质量匹配
2. 评估匹配度,不满意再用 AI 生成
3. AI 生成作为兜底方案
Args:
scene: 场景配置
scene_id: 场景 ID
Returns:
素材文件路径
"""
visual = scene.get("visual", {})
source = visual.get("source", "auto")
# DJ 场景是程序化生成的,不需要素材
if source == "programmatic" or scene.get("type") == "dj":
return "programmatic"
# 用户指定了文件
if source == "user" and visual.get("file"):
return self._validate_user_asset(visual["file"])
# 强制使用 AI
if source == "ai":
return self._generate_ai_image(visual, scene_id)
# 强制使用素材库
if source == "stock":
return self._fetch_stock_asset(visual, scene_id)
# auto 模式:质量优先策略
return self._fetch_auto(visual, scene_id)
def _fetch_auto(self, visual: Dict, scene_id: str) -> str:
"""自动模式:素材库优先,AI 兜底"""
quality_priority = visual.get("quality_priority", "high")
allow_ai_fallback = visual.get("allow_ai_fallback", True)
prefer_video = visual.get("prefer_video", False)
# 第一步:如果偏好视频,先尝试获取视频
if prefer_video and self.has_media_downloader:
try:
result = self._fetch_stock_asset(visual, scene_id, media_type="video")
if result:
return result
except (AssetNotFoundError, AssetQualityError) as e:
print(f"[AssetManager] 视频素材未找到,尝试图片: {e}")
# 第二步:尝试素材库(图片)
if self.has_media_downloader:
try:
result = self._fetch_stock_asset(visual, scene_id)
if result:
return result
except (AssetNotFoundError, AssetQualityError) as e:
print(f"[AssetManager] 素材库未找到合适素材: {e}")
# 第二步:AI 生成兜底
if allow_ai_fallback and self.has_zimage:
try:
return self._generate_ai_image(visual, scene_id)
except Exception as e:
print(f"[AssetManager] AI 生成失败: {e}")
# 第三步:降低标准重试素材库
if self.has_media_downloader:
try:
return self._fetch_stock_asset(
visual, scene_id, strict_quality=False
)
except Exception as e:
pass
raise AssetNotFoundError(f"无法获取素材: {scene_id}")
def _fetch_stock_asset(
self,
visual: Dict,
scene_id: str,
strict_quality: bool = True,
media_type: str = None,
) -> str:
"""从素材库获取素材"""
if not self.has_media_downloader:
raise DependencyError("media-downloader skill 未安装")
keywords = visual.get("stock_keywords", [])
if not keywords and visual.get("prompt"):
# 从 prompt 提取关键词
keywords = self._extract_keywords(visual["prompt"])
if not keywords:
raise AssetNotFoundError("没有可用的搜索关键词")
# 确定媒体类型
if media_type is None:
media_type = visual.get("media_type", "image")
output_dir = self.cache_dir / scene_id
output_dir.mkdir(exist_ok=True)
# 调用 media-downloader
cli_path = MEDIA_DOWNLOADER_DIR / "media_cli.py"
search_query = " ".join(keywords[:3])
cmd = [
sys.executable,
str(cli_path),
media_type,
search_query,
"-n", "1",
"-o", str(output_dir),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=TIMEOUTS["asset_download"],
)
if result.returncode == 0:
# 查找下载的文件
files = list(output_dir.glob("*"))
if files:
asset_path = files[0]
if strict_quality:
self._validate_quality(asset_path, media_type)
return str(asset_path)
except subprocess.TimeoutExpired:
print(f"[AssetManager] 素材下载超时: {scene_id}")
except Exception as e:
print(f"[AssetManager] 素材下载错误: {e}")
raise AssetNotFoundError(f"素材库未找到: {search_query}")
def _generate_ai_image(self, visual: Dict, scene_id: str) -> str:
"""使用 AI 生成图像"""
if not self.has_zimage:
raise DependencyError("zimage-skill 未安装")
prompt = visual.get("prompt", "")
if not prompt:
raise AssetNotFoundError("没有可用的生成提示词")
# 添加风格后缀
style_suffix = self.style_config.get(
"visual_style", {}
).get("ai_prompt_suffix", "")
full_prompt = f"{prompt}, {style_suffix}" if style_suffix else prompt
output_path = self.cache_dir / f"{scene_id}_ai.jpg"
# 调用 zimage-skill
generate_script = ZIMAGE_SKILL_DIR / "generate.py"
cmd = [
sys.executable,
str(generate_script),
full_prompt,
str(output_path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=TIMEOUTS["image_generation"],
)
if result.returncode == 0 and output_path.exists():
return str(output_path)
else:
raise Exception(result.stderr or "AI 生成失败")
except subprocess.TimeoutExpired:
raise AssetNotFoundError(f"AI 生成超时: {scene_id}")
def _validate_user_asset(self, file_path: str) -> str:
"""验证用户提供的素材"""
path = Path(file_path)
if not path.exists():
raise AssetNotFoundError(f"文件不存在: {file_path}")
# 检查格式
suffix = path.suffix.lower().lstrip(".")
all_formats = (
QUALITY_REQUIREMENTS["image"]["formats"] +
QUALITY_REQUIREMENTS["video"]["formats"]
)
if suffix not in all_formats:
raise AssetQualityError(f"不支持的格式: {suffix}")
return str(path)
def _validate_quality(self, file_path: Path, media_type: str) -> bool:
"""验证素材质量"""
requirements = QUALITY_REQUIREMENTS.get(media_type, {})
# 检查文件格式
suffix = file_path.suffix.lower().lstrip(".")
if suffix not in requirements.get("formats", []):
raise AssetQualityError(f"格式不符合要求: {suffix}")
# TODO: 使用 PIL 或 ffprobe 检查分辨率
# 暂时跳过分辨率检查
return True
def _extract_keywords(self, prompt: str) -> List[str]:
"""从提示词提取关键词"""
# 移除常见的修饰词
noise_words = {
"style", "anime", "dramatic", "epic", "dynamic",
"lighting", "vibrant", "colors", "high", "quality",
"beautiful", "amazing", "stunning", "cel-shaded",
}
words = prompt.lower().replace(",", " ").split()
keywords = [w for w in words if w not in noise_words and len(w) > 2]
return keywords[:5]
def fetch_all_assets(
self,
storyboard: Dict,
progress_callback=None,
) -> Dict[str, str]:
"""
获取分镜脚本中所有场景的素材
Args:
storyboard: 分镜脚本
progress_callback: 进度回调函数
Returns:
{scene_id: asset_path} 映射
"""
scenes = storyboard.get("scenes", [])
assets = {}
total = len(scenes)
for i, scene in enumerate(scenes):
scene_id = scene["id"]
if progress_callback:
progress_callback(i + 1, total, scene_id)
try:
asset_path = self.fetch_asset(scene, scene_id)
assets[scene_id] = asset_path
print(f"[{i+1}/{total}] {scene_id}: {asset_path}")
except Exception as e:
print(f"[{i+1}/{total}] {scene_id}: 失败 - {e}")
assets[scene_id] = None
return assets
def get_asset_report(self, assets: Dict[str, str]) -> str:
"""生成素材质量报告"""
lines = [
"┌─────────────────────────────────────────────────┐",
"│ 素材质量报告 │",
"├──────────┬──────────┬──────────┬────────────────┤",
"│ Scene │ 来源 │ 分辨率 │ 状态 │",
"├──────────┼──────────┼──────────┼────────────────┤",
]
for scene_id, path in assets.items():
if path:
source = "AI生成" if "_ai" in path else "素材库"
status = "✅ 高质量"
resolution = "1920x1080" # TODO: 实际检测
else:
source = "-"
status = "❌ 获取失败"
resolution = "-"
lines.append(
f"│ {scene_id:<8} │ {source:<8} │ {resolution:<8} │ {status:<14} │"
)
lines.append("└──────────┴──────────┴──────────┴────────────────┘")
return "\n".join(lines)
"""
MV Skill 异常定义
"""
class MVSkillError(Exception):
"""MV Skill 基础异常类"""
pass
class MusicGenerationError(MVSkillError):
"""音乐生成失败"""
pass
class AssetNotFoundError(MVSkillError):
"""素材获取失败"""
pass
class AssetQualityError(MVSkillError):
"""素材质量不符合要求"""
pass
class RenderError(MVSkillError):
"""渲染失败"""
pass
class APIQuotaExceeded(MVSkillError):
"""API 配额用尽"""
pass
class TemplateNotFoundError(MVSkillError):
"""模板不存在"""
pass
class StoryboardValidationError(MVSkillError):
"""分镜脚本验证失败"""
pass
class SessionError(MVSkillError):
"""会话管理错误"""
pass
class DependencyError(MVSkillError):
"""依赖检查失败"""
pass