24小时无人直播推流、短视频去水印下载—10天用Flutter撸一个项目

1,754 阅读7分钟

Flutter 撸一个无人直播推流+短视频去水印项目

运用技术:

·Flutter编写安卓和IOS端

·JAVA编写后端逻辑

·PHP编写视频解析逻辑

·VUE编写解析视频网页端

·sh脚本 ffmpeg视频解码转码推流

体验地址(firefix.cn)

应用介绍:最近在研究自媒体,发现抖音上面一些主播24小时不休息一直在播放几部电影,发现我们熬夜在看直播,主播其实已经在睡觉,我们看到的人不一定是真人,方便自己做自媒体 开发了TobeSaver 功能如下

1.无需真人出镜,也不需要电脑和手机 提前录制好的视频素材通过云服务器推流直播

2.视频图片去水印下载 支持抖音、快手、西瓜视频、TikTok、YouTobe、bilibili、小红书、微博、等180个平台。

微信图片_20231114170755.jpg

适配打包windows端

1702025002039.jpg

1.前端Flutter直播推流页面


import 'package:downloaderx/utils/exit.dart';
import 'package:downloaderx/widget/live_type_item.dart';
import 'package:downloaderx/widget/platform_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:url_launcher/url_launcher.dart';

import '../network/http_api.dart';
import '../network/http_utils.dart';
import '../utils/event_bus.dart';
import 'login_page.dart';
import 'tutorial_page.dart';

class PushStreamPage extends StatefulWidget {
  const PushStreamPage({super.key});

  @override
  State<PushStreamPage> createState() => _PushStreamPageState();
}

class _PushStreamPageState extends State<PushStreamPage>
    with SingleTickerProviderStateMixin {
  List<dynamic> platform = [
    {'title': '抖音', 'icon': 'douyin.png'},
    {'title': '快手', 'icon': 'ks.png'},
    {'title': '哔哩', 'icon': 'bili.png'},
    {'title': '微博', 'icon': 'weibo.png'},
    {'title': '知乎', 'icon': 'zhihu.png'},
    // {'title': 'YouTobe', 'icon': 'youtobe.png'},
  ];
  List<dynamic> liveType = [
    {'title': '催眠直播', 'icon': 'iconspdy.png', 'type': 0},
    {'title': '音乐直播', 'icon': 'iconspbilibili.png', 'type': 1},
    {'title': '电影直播', 'icon': 'icon_sp_acfun.png', 'type': 2},
  ];
  var currentPlatformIndex = 0;
  var currentLiveIndex = 0;
  bool isCircular = false;
  int status = -1;
  var countdown = 0;
  var controllerHost = TextEditingController(text: "");
  var controllerSecretKey = TextEditingController(text: "");
  var controllerLiveUrl = TextEditingController(text: "");

  @override
  void initState() {
    super.initState();
    loadPushStreamInfo();
    EventBus.getDefault().register(this, (event) {
      if (event.toString() == "refresh_push_stream") {
        loadPushStreamInfo();
      }
    });
  }

  @override
  void dispose() {
    super.dispose();
    EventBus.getDefault().unregister(this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: const Text(
          "直播推流",
        ),
      ),
      body: Container(
        margin: EdgeInsets.symmetric(vertical: 20.w, horizontal: 40.w),
        child: CustomScrollView(
          keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
          slivers: [
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.centerLeft,
                margin: EdgeInsets.fromLTRB(0, 0, 0, 25.w),
                child: Text(
                  "选择推流平台",
                  style:
                      TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
                ),
              ),
            ),
            SliverGrid.builder(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 3,
                  crossAxisSpacing: 10.w,
                  mainAxisSpacing: 10.w,
                  childAspectRatio: 2.2),
              itemCount: platform.length,
              itemBuilder: (BuildContext context, int index) {
                return PlatFormItem(
                  item: platform[index],
                  isSelected: index == currentPlatformIndex,
                  onItemClick: onItemClick,
                );
              },
            ),
            buildInputContainer("服务器地址:", '请输入服务器地址', controllerHost, context),
            buildInputContainer(
                "串流秘钥:", '请输入串流秘钥', controllerSecretKey, context),
            buildInputContainer(
                "直播间地址:", '请输入直播地址', controllerLiveUrl, context),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.centerLeft,
                margin: EdgeInsets.fromLTRB(0, 0, 0, 25.w),
                child: Text(
                  "直播类型",
                  style:
                      TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
                ),
              ),
            ),
            SliverGrid.builder(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 3,
                  crossAxisSpacing: 10.w,
                  mainAxisSpacing: 10.w,
                  childAspectRatio: 2.6),
              itemCount: liveType.length,
              itemBuilder: (BuildContext context, int index) {
                return LiveTypeItem(
                  item: liveType[index],
                  isSelected: index == currentLiveIndex,
                  onItemClick: onLiveItemClick,
                );
              },
            ),
            SliverToBoxAdapter(
              child: Container(
                margin: EdgeInsets.symmetric(vertical: 50.w, horizontal: 0),
                child: Column(
                  children: [
                    GestureDetector(
                      onTap: onTap,
                      child: AnimatedContainer(
                        duration: Duration(milliseconds: 600),
                        curve: Curves.linear,
                        width: isCircular ? 100.h : 600.w,
                        height: isCircular ? 100.h : 80.h,
                        decoration: BoxDecoration(
                            borderRadius:
                                BorderRadius.circular(isCircular ? 50.h : 40.h),
                            color: Theme.of(context).primaryColor),
                        alignment: Alignment.center,
                        child: isCircular
                            ? countdown > 0
                                ? Stack(
                                    alignment: Alignment.center,
                                    children: [
                                      LoadingAnimationWidget.threeArchedCircle(
                                        color: Colors.white,
                                        size: 60.h,
                                      ),
                                      Text(
                                        "${countdown}s",
                                        style: TextStyle(color: Colors.white),
                                      )
                                    ],
                                  )
                                : LoadingAnimationWidget.hexagonDots(
                                    color: Colors.white,
                                    size: 60.h,
                                  )
                            : Center(
                                child: Text(
                                status == -1
                                    ? '开始推流'
                                    : status == 0
                                        ? "正在排队中..."
                                        : "观看直播",
                                style: TextStyle(
                                    color: Colors.white,
                                    fontSize: 30.sp,
                                    fontWeight: FontWeight.bold),
                              )),
                        // child: isCircular
                        //     ? ClipOval(child: Container(color: Colors.blue))
                        //     : null,
                      ),
                    ),
                  ],
                ),
              ),
            )
          ],
        ),
      ),
    );
  }

  void onTap() async {
    var host = controllerHost.value.text;
    var secretKey = controllerSecretKey.value.text;
    var liveUrl = controllerLiveUrl.value.text;
    var plat = platform[currentPlatformIndex]['title'];
    var type = liveType[currentLiveIndex]['type'];
    if (await UserExit.isLogin() == null) {
      Navigator.push(
          context, MaterialPageRoute(builder: (context) => const LoginPage()));
      return;
    }
    if (host.isEmpty) {
      ToastExit.show('请输入服务器地址');
      return;
    }
    if (secretKey.isEmpty) {
      ToastExit.show('请输入秘钥地址');
      return;
    }
    if (liveUrl.isEmpty) {
      ToastExit.show('请输入直播地址');
      return;
    }
    if (isCircular) {
      return;
    }
    if (status == 0) {
      ToastExit.show("正在排队推流中");
      return;
    } else if (status == 1) {
      jumpLaunchUrl(liveUrl);
      return;
    }

    setState(() {
      countdown = 0;
      isCircular = !isCircular;
    });
    var map = <String, dynamic>{};
    map['liveHost'] = host;
    map['secretKey'] = secretKey;
    map['liveUrl'] = liveUrl;
    map['platform'] = plat;
    map['liveType'] = type;
    var respond = await HttpUtils.instance.requestNetWorkAy(
        Method.post, HttpApi.submitLiveStream,
        queryParameters: map);
    if (respond != null) {
      await Future.delayed(Duration(milliseconds: 400));
      status = 0;
      startTimer();
    }
    print(">>>>>>>>>>>>>>>${respond}");
  }

  void startTimer() {
    ToastExit.show("已提交,正在排队等候推流中~");
    countdown = 9;
    Timer.periodic(const Duration(seconds: 1), (Timer timer) {
      if (countdown == 1) {
        timer.cancel();
        setState(() {
          isCircular = false;
          status = 0;
        });
      } else {
        setState(() {
          countdown--;
        });
      }
    });
  }

  void loadPushStreamInfo() async {
    if (await UserExit.isLogin() != null) {
      var respond = await HttpUtils.instance
          .requestNetWorkAy(Method.get, HttpApi.getStreamInfo);
      print(">>>>>>loadPushStreamInfo>>>>>>>>${respond}");
      if (respond != null) {
        setState(() {
          controllerHost.text = respond['liveHost'];
          controllerSecretKey.text = respond['secretKey'];
          controllerLiveUrl.text = respond['liveUrl'];
          status = respond['status'];
          currentPlatformIndex = platform
              .indexWhere((element) => element['title'] == respond['platform']);
          currentLiveIndex = liveType
              .indexWhere((element) => element['type'] == respond['liveType']);
        });
      }
    }
  }

  Future<void> jumpLaunchUrl(webUrl) async {
    final Uri uri = Uri.parse(webUrl);
    if (!await launchUrl(uri)) {
      throw Exception('Could not launch $uri');
    }
  }

  void onItemClick(item) {
    currentPlatformIndex = platform.indexOf(item);
    setState(() {});
  }

  void onLiveItemClick(item) {
    currentLiveIndex = liveType.indexOf(item);
    setState(() {});
  }
}

SliverToBoxAdapter buildInputContainer(String title, String hitText,
    TextEditingController controller, BuildContext context) {
  return SliverToBoxAdapter(
    child: Container(
      width: double.infinity,
      margin: EdgeInsets.fromLTRB(0, 25.w, 0, 0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          GestureDetector(
            onTap: () {
              Navigator.push(
                  context,
                  MaterialPageRoute(
                      builder: (context) => const TutorialPage()));
            },
            child: Row(
              children: [
                Text(
                  title,
                  style:
                      TextStyle(fontWeight: FontWeight.bold, fontSize: 30.sp),
                ),
                const Icon(
                  Icons.help,
                  color: Colors.grey,
                  size: 16,
                ),
              ],
            ),
          ),
          Container(
            height: 80.w,
            width: double.infinity,
            child: TextField(
              maxLines: 1,
              keyboardType: TextInputType.url,
              textAlignVertical: TextAlignVertical.center,
              controller: controller,
              textInputAction: TextInputAction.done,
              decoration: InputDecoration(
                hintText: hitText,
                hintStyle: const TextStyle(color: Colors.grey),
                border: const OutlineInputBorder(borderSide: BorderSide.none),
                focusedBorder:
                    const OutlineInputBorder(borderSide: BorderSide.none),
                enabledBorder: const OutlineInputBorder(
                  borderSide: BorderSide.none,
                ),
                contentPadding: EdgeInsets.symmetric(horizontal: 0.w),
              ),
            ),
          ),
        ],
      ),
    ),
  );
}

2.java后端 springboot全家桶 服务端处理简单业务

1700032691965.jpg

3.直播推流sh脚本(另一台服务器挂起一个定时任务处理待推流的任务)

e
w='\033[0;33m'
font="\033[0m"

ffmpeg_install(){
# 安装FFMPEG
read -p "你的机器内是否已经安装过FFmpeg4.x?安装FFmpeg才能正常推流,是否现在安装FFmpeg?(yes/no):" Choose
if [ $Choose = "yes" ];then
	yum -y install wget
	wget --no-check-certificate https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-4.0.3-64bit-static.tar.xz
	tar -xJf ffmpeg-4.0.3-64bit-static.tar.xz
	cd ffmpeg-4.0.3-64bit-static
	mv ffmpeg /usr/bin && mv ffprobe /usr/bin && mv qt-faststart /usr/bin && mv ffmpeg-10bit /usr/bin
fi
if [ $Choose = "no" ]
then
    echo -e "${yellow} 你选择不安装FFmpeg,请确定你的机器内已经自行安装过FFmpeg,否则程序无法正常工作! ${font}"
    sleep 2
fi
	}

stream_start(){
# 定义推流地址和推流码
read -p "输入你的推流地址和推流码(rtmp协议):" rtmp

# 判断用户输入的地址是否合法
if [[ $rtmp =~ "rtmp://" ]];then
	echo -e "${green} 推流地址输入正确,程序将进行下一步操作. ${font}"
  	sleep 2
	else  
  	echo -e "${red} 你输入的地址不合法,请重新运行程序并输入! ${font}"
  	exit 1
fi 

# 定义视频存放目录
read -p "输入你的视频存放目录 (格式仅支持mp4,并且要绝对路径,例如/opt/video):" folder

# 判断是否需要添加水印
read -p "是否需要为视频添加水印?水印位置默认在右上方,需要较好CPU支持(yes/no):" watermark
if [ $watermark = "yes" ];then
	read -p "输入你的水印图片存放绝对路径,例如/opt/image/watermark.jpg (格式支持jpg/png/bmp):" image
	echo -e "${yellow} 添加水印完成,程序将开始推流. ${font}"
	# 循环
	while true
	do
		cd $folder
		for video in $(ls *.mp4)
		do
		ffmpeg -re -i "$video" -i "$image" -filter_complex overlay=W-w-5:5 -c:v libx264 -c:a aac -b:a 192k -strict -2 -f flv ${rtmp}
		done
	done
fi
if [ $watermark = "no" ]
then
    echo -e "${yellow} 你选择不添加水印,程序将开始推流. ${font}"
    # 循环
	while true
	do
		cd $folder
		for video in $(ls *.mp4)
		do
		ffmpeg -re -i "$video" -c:v copy -c:a aac -b:a 192k -strict -2 -f flv ${rtmp}
		done
	done
fi
	}

# 停止推流
stream_stop(){
	screen -S stream -X quit
	killall ffmpeg
	}

# 开始菜单设置
echo -e "${yellow} CentOS7 X86_64 FFmpeg无人值守循环推流 For LALA.IM ${font}"
echo -e "${red} 请确定此脚本目前是在screen窗口内运行的! ${font}"
echo -e "${green} 1.安装FFmpeg (机器要安装FFmpeg才能正常推流) ${font}"
echo -e "${green} 2.开始无人值守循环推流 ${font}"
echo -e "${green} 3.停止推流 ${font}"
start_menu(){
    read -p "请输入数字(1-3),选择你要进行的操作:" num
    case "$num" in
        1)
        ffmpeg_install
        ;;
        2)
        stream_start
        ;;
        3)
        stream_stop
        ;;
        *)
        echo -e "${red} 请输入正确的数字 (1-3) ${font}"
        ;;
    esac
	}

# 运行开始菜单
start_menu

3.php视频解析去水印下载适配(参考Images_spider做了进一步适配

1700036202172.jpg

3.vue 编写简单的网页版解析视频下载

<template>
  <div id="home" @mousewheel="handleMouseWheel" v-swipeup="showCenter">
    <!-- 遮罩:防止用户在动画播放期间点击屏幕 -->
    <div
        class="mask_ban_touch"
        v-if="!flag"
        style="width: 100%; height: 100%; z-index: 999; position: absolute"
    ></div>
    <div
        :style="{ background: `url(${imgUrl})` }"
        :class="[{ wrapper_blur: centerShow }, 'wrapper', 'bg-blur']"
    >
      <div :class="['img_shadow', { img_shadow_show: imgLoded }]"></div>
      <div
          style="display: flex;flex-direction: column;position: absolute;z-index: 1000;top: 11rem;align-items: center;left: 0;right: 0;padding-left: 1rem;padding-right: 1rem;">

        <div
            style="height:3.6rem;display: flex;flex-direction: row;width: 100%;max-width: 60rem">
          <input
              style="flex: 1;border: none;outline: none;padding-left: 10px;padding-right: 10px;background-color: white;border-top-left-radius: 8px;border-bottom-left-radius: 8px; font-size: 1rem"
              placeholder="请将复制的链接粘贴到这里"
              v-model="inputValue">
          <div
              style="background-color: #7776FF; border: none;border-color: transparent; border-top-right-radius: 8px;border-bottom-right-radius: 8px;padding-left: 15px;padding-right: 15px;display: flex;align-items: center;justify-content: center">
            <button style="background-color: transparent;border: none;font-size:1rem;color: white" @click="parse"
                    v-if="!isLoading">
              解析视频
            </button>
            <pulseloader :loading="isLoading" color="#ffffff" size="10px"></pulseloader>

          </div>


        </div>
        <div
            style="background-color: #eeeeee;display: flex;flex-direction: column;align-items: center;padding: 10px;border-radius: 8px;width: 100%;max-width: 59rem;margin: 1rem"
            v-if="resultData">
          <p>{{ resultData.desc }}</p>
          <div style="display: flex;align-items: center;flex-direction: row">
            <button
                style="background-color: green;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px"
                v-if="resultData.playAddr" @click="downloadVideo">下载视频
            </button>

            <button
                style="background-color: deepskyblue;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px"
                v-if="resultData.cover" @click="downloadCover">下载封面
            </button>
            <button
                style="background-color: #2196F3;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px"
                v-if="resultData.music" @click="downloadVoice">下载音频
            </button>
          </div>
          <div v-if="resultData.pics">
            <button
                style="background-color: green;height: 40px;padding: 8px;border: none;color: white;border-radius: 6px;margin-right: 15px;margin-top: 10px"
                @click="downloadPic(item)" v-for="(item,index) in resultData.pics" :key="index">下载图片{{ index + 1 }}
            </button>
          </div>
        </div>
      </div>

      <!--      <div class="inner" style="cursor: pointer" @click="goToBlog">-->
      <!--        <img-->
      <!--          :class="['R_logo', { R_logo_top: flag }]"-->
      <!--          src="../assets/logo.svg"-->
      <!--        />-->
      <!--        <div :class="['hello', { hello_bottom: flag }]">-->
      <!--          <div>{{ slogan[i] }}</div>-->
      <!--          <div class="hello_bottom_text">-->
      <!--            点击以访问 {{ $config.BLOG_NAME }}-->
      <!--          </div>-->
      <!--        </div>-->
      <!--      </div>-->
    </div>
    <a></a>
    <!-- 上下滑动指示器 -->
    <div
        :class="['bottom', { bottom_show: flag }]"
        style="cursor: pointer"
        @click="centerShow = !centerShow"
    >
      <div class="bottom-icon">
        <transition name="fade">
          <i v-if="!centerShow" class="mdi-chevron-up mdi"></i>
          <i v-if="centerShow" class="mdi-chevron-down mdi"></i>
        </transition>
      </div>
      <div class="bottom-info">Slide Up</div>
    </div>

    <!-- 备案号 -->
    <a
        class="record_number"
        :class="{ record_number_show: flag }"
        href="http://beian.miit.gov.cn/"
        v-if="recordNumber"
    >{{ recordNumber }}</a
    >

    <!-- 导航抽屉 -->
    <transition name="fade">
      <div class="shadow" v-show="centerShow"></div>
    </transition>
    <transition name="slide">
      <div v-show="centerShow" class="center_wrapper" @click="hideCenter">
        <div class="center_inner" @click="stopPropagation">
          <center @hide="hideCenter" ref="center"></center>
        </div>
      </div>
    </transition>
    <toast ref="toast"></toast>
  </div>
</template>

<script>
import center from "./center.vue";
import toast from "./toast.vue";
import pulseloader from "vue-spinner/src/PulseLoader.vue";
function randomNum(minNum, maxNum) {
  switch (arguments.length) {
    case 1:
      return parseInt(Math.random() * minNum + 1, 10);
    case 2:
      return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
    default:
      return 0;
  }
}

export default {
  name: "home",
  data() {
    this.startTime = new Date();
    return {
      flag: false, // 动画是否播放完毕
      isLoading: false,
      slogan: [],
      i: 0,
      centerShow: false, // 导航抽屉显示状态
      imgLoded: false, // 背景图片加载状态
      imgUrl: "",
      resultData: null,
      inputValue: ''
    };
  },
  components: {
    center,
    toast,
    pulseloader
  },
  computed: {
    recordNumber() {
      return this.$config.RECORD_NUMBER;
    }
  },
  methods: {
    goToBlog() {
      window.location.href = this.$config.BLOG_URL;
    },
    _jieliu(callback, delay) {
      let currentTime = new Date();
      if (currentTime - this.startTime > delay) {
        callback();
        this.startTime = new Date();
      }
    },
    parse() {
      var url = /http[s]?://[\w.]+[\w/]*[\w.]*??[\w=&:-+%]*[/]*/.exec(this.inputValue.trim())
      if (url != null && url.length > 0) {
        let li = url[0];
        if (li.includes(".html")) {
          var endIndex = li.indexOf(".html") + 5;
          li = li.substring(0, endIndex);
        }
        this.isLoading = true
        this.resultData = null
        new Promise((resolve, reject) => {
          this.axios
              .get("videoParse/web/parse?link=" + li)
              .then(res => {
                console.log(res);
                if (res.data['code'] == 0) {
                  this.isLoading = false
                  this.resultData = res.data['data']
                } else {
                  this.isLoading = false
                  this.$refs.toast.show(res.data['msg']);
                  if (li.includes("unwatermarker")) {
                    this.showCenter();

                  }
                }
                resolve();
              })
              .catch(err => {
                this.isLoading = false
                console.log(err);
                reject(err);
              });
        });
      } else {
        this.$refs.toast.show("请输入正确的链接地址");
        this.showCenter();
      }
    },
    downloadVideo() {
      this.downloadFile(this.resultData.playAddr, "video.mp4")
    },
    downloadPic(url) {
      this.downloadFile(url, "image.jpg")
    },
    downloadCover() {
      this.downloadFile(this.resultData.cover, "image.jpg")
    },
    downloadVoice() {
      this.downloadFile(this.resultData.music, "music.mp3")
    },
    downloadFile(fileUrl, fileName) {
      const link = document.createElement('a');
      link.href = fileUrl;
      link.download = fileName;
      link.target = '_blank';
      link.click();
    },
    handleMouseWheel(e) {
      if (e.deltaY < 0) {
        // 如果鼠标滚轮向上滚动
        if (!this.centerShow) {
          this.centerShow = true;
        } else {
          this._jieliu(() => {
            this.$refs.center.scroller.scrollBy(
                0,
                100,
                500,
                "cubic-bezier(0.23, 1, 0.32, 1)"
            );
          }, 500);
        }
      } else {
        // 如果鼠标滚轮向下滚动
        if (!this.centerShow) {
          return;
        } else {
          this._jieliu(() => {
            this.$refs.center.scroller.scrollBy(
                0,
                -100,
                500,
                "cubic-bezier(0.23, 1, 0.32, 1)"
            );
          }, 500);
        }
      }
    },
    showCenter() {
      this.centerShow = true;
    },
    hideCenter() {
      this.centerShow = false;
    },
    stopPropagation(e) {
      e.stopPropagation();
    }
  },
  mounted() {
    setTimeout(() => {
      this.flag = true;

    }, 1300);
    // 图片懒加载
    this.imgUrl = this.$config.BACKGROUND_IMG_URL;
    var img = new Image();
    img.src = this.imgUrl;
    img.onload = () => {
      this.imgLoded = true;
    };
    this.slogan = this.$config.SLOGAN;
    this.i = randomNum(0, this.slogan.length - 1);
  }
};
</script>

<style lang="scss" scoped>
#home {
  height: 100%;
  align-items: center;
  justify-content: center;
  display: flex;
  flex-direction: column;
  overflow: hidden;

  .github {
    display: block;
    position: absolute;
    top: 10px;
    right: 10px;
    color: white;
    font-size: 2rem;
    z-index: 1;
    cursor: pointer;
  }

  .wrapper {
    background-size: cover !important;
    overflow: hidden;
    align-items: center;
    justify-content: center;
    display: flex;
    border-radius: 100%;
    animation: logoEnter 1.2s;
    animation-fill-mode: forwards;
    transition: all 0.8s;

    .inner {
      position: relative;

      .R_logo {
        height: 7rem;
        position: absolute;
        transform: translate(-50%, -50%);
        transition: all 1s;
        top: 0;
      }

      .R_logo_top {
        top: -3.2rem;
      }

      .hello {
        color: #ffffff;
        width: 18.75rem;
        text-align: center;
        position: absolute;
        transform: translate(-50%, -50%);
        font-size: 1.5rem;
        opacity: 0;
        top: 100px;
        transition: all 1s;
      }

      .hello_bottom {
        opacity: 1;
        top: 3.5rem;

        .hello_bottom_text {
          font-size: 1rem;
          margin-top: 0.5rem;
          padding-top: 0.5rem;
          border-top: 1px solid #fff;
        }
      }
    }
  }

  .img_shadow {
    content: "";
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    background-color: #fda085;
    overflow: hidden;
    transition: all 0.5s;
  }

  .img_shadow_show {
    background-color: rgba(0, 0, 0, 0.5);
  }

  .wrapper_blur {
    filter: blur(1px);
  }

  .bottom {
    font-size: 2rem;
    position: absolute;
    color: #fff;
    bottom: 0%;
    opacity: 0;
    transition: all 1s;
    text-align: center;

    .bottom-info {
      font-size: 1rem;
      margin-top: 5px;
      animation: float 4s infinite ease-in-out;
    }
  }

  .bottom_show {
    bottom: 50px;
    opacity: 1;
  }

  .shadow {
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.4);
  }

  .center_wrapper {
    position: absolute;
    width: 100%;
    height: 100%;
    z-index: 2;

    .center_inner {
      width: 70%;
      position: absolute;
      overflow: hidden;
      border-radius: 0.5rem;
      left: 0;
      right: 0;
      margin: 0 auto;
      bottom: 5%;
      height: 70%;
    }
  }

  .record_number {
    width: 100%;
    text-align: center;
    color: #fff;
    text-decoration: none;
    font-size: 12px;
    line-height: 30px;
    background: rgba(0, 0, 0, 0.4);
    position: fixed;
    bottom: 0;
    transform: translateY(30px);
    transition: transform ease 1s;
  }

  .record_number_show {
    transform: translateY(0px);
  }
}

@media screen and (max-width: 900px) {
  #home {
    .center_wrapper {
      .center_inner {
        width: 100%;
        bottom: 0px;
        padding: 10px;
        box-sizing: border-box;
      }
    }
  }
}

.slide-enter-active,
.slide-leave-active {
  transition: all 0.8s ease;
}

.slide-enter,
.slide-leave-to {
  transform: translateY(100%);
}

.fade-enter-active,
.fade-leave-active {
  transition: all 0.8s ease;
}

.fade-enter,
.fade-leave-to {
  opacity: 0;
}

@keyframes logoEnter {
  0% {
    opacity: 0;
    width: 0rem;
    height: 0rem;
  }
  20% {
    opacity: 1;
    width: 15rem;
    height: 15rem;
  }
  80% {
    transform: rotate(360deg);
    width: 15rem;
    height: 15rem;
  }
  100% {
    transform: rotate(360deg);
    width: 100%;
    height: 100%;
    border-radius: 0;
  }
}

@keyframes float {
  0% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-10px);
  }
  100% {
    transform: translateY(0);
  }
}
</style>

最后:

项目开发中,持续更新,感兴趣,关注我!

技术链接彼此 代码拥抱未来!